-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(session): extract Session.isDescendantOf from auto-reply walk (F13) #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
980f5e0
935f474
f54a1ad
aed4e5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -375,6 +375,26 @@ export interface Interface { | |
| sessionID: SessionID, | ||
| predicate: (msg: MessageV2.WithParts) => boolean, | ||
| ) => Effect.Effect<Option.Option<MessageV2.WithParts>> | ||
| /** | ||
| * Returns true if `sid` is `root` or transitively descends from `root` via | ||
| * `parentID`. Walks the parent chain up to `opts.maxDepth` (default 64) and | ||
| * stops at any `NotFoundError` (returns false). When `opts.cache` is | ||
| * provided, it is used both as a positive-hit short-circuit and as an | ||
| * accumulator: every confirmed descendant in the walked chain is added to | ||
| * the set. The cache is auto-seeded with `root` on every call, so callers | ||
| * can pass a fresh `new Set()` and reuse it across calls without seeding. | ||
| * Caches MUST NOT be shared across different roots — if a non-empty cache | ||
| * is passed that does not already contain `root`, the call dies with a | ||
| * clear error instead of returning silently-wrong results from another | ||
| * lineage's accumulated entries. Callers that issue many `isDescendantOf` | ||
| * calls against the same root (e.g. SessionAutoReply) should reuse one | ||
| * cache so the parent chain is traversed at most once per node. | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point — the guard is partial. Tightened the JSDoc in aed4e5a to spell that out: the runtime check only catches the missing-root case (a cache leaked from another lineage); a mixed cache (root present plus entries from a different root) needs cache tagging which we don't add here. Intended usage is one cache per root (AutoReply does this — fresh Set per make()); for disjoint-root cache sharing, callers should build a Map<root, Set> outside the helper. Doc now matches what the code actually enforces. |
||
| */ | ||
| readonly isDescendantOf: ( | ||
| sid: SessionID, | ||
| root: SessionID, | ||
| opts?: { maxDepth?: number; cache?: Set<SessionID> }, | ||
| ) => Effect.Effect<boolean> | ||
| } | ||
|
|
||
| export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {} | ||
|
|
@@ -671,6 +691,54 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = | |
| return Option.none<MessageV2.WithParts>() | ||
| }) | ||
|
|
||
| const isDescendantOf = Effect.fn("Session.isDescendantOf")(function* ( | ||
| sid: SessionID, | ||
| root: SessionID, | ||
| opts?: { maxDepth?: number; cache?: Set<SessionID> }, | ||
| ) { | ||
| const maxDepth = opts?.maxDepth ?? 64 | ||
| const known = opts?.cache ?? new Set<SessionID>() | ||
| // Defend against accidental cache sharing across different roots: if a | ||
| // caller passes a non-empty cache that lacks `root`, those entries were | ||
| // populated under a different lineage and `known.has(sid)` could | ||
| // short-circuit to true with the wrong answer. Fail loudly instead of | ||
| // returning a silently wrong result. | ||
| if (known.size > 0 && !known.has(root)) { | ||
| return yield* Effect.die( | ||
| new Error( | ||
| `Session.isDescendantOf: opts.cache appears to belong to a different root (size=${known.size}, missing root=${root}). Caches must not be shared across roots.`, | ||
| ), | ||
| ) | ||
| } | ||
| // Always seed `root` into the working set so the parent walk has a | ||
| // termination anchor and the next cross-root reuse check still works. | ||
| known.add(root) | ||
| if (sid === root) return true | ||
| if (known.has(sid)) return true | ||
| // Walk parent chain. Track the chain so we can promote every visited | ||
|
Comment on lines
+703
to
+722
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in f54a1ad. At call entry, if opts.cache is non-empty AND does not contain root, isDescendantOf now dies with a clear error naming the expected root and the cache size. New regression test 'dies if cache is non-empty but missing root' pins the guard. JSDoc updated. Picked option (a) over (b) because Map<root,Set> changes the API for the AutoReply use case where a single cache is correct, while the guard catches the actual bug class without complicating the simple case. |
||
| // node into the cache once we hit a known descendant — turns repeated | ||
| // calls against the same lineage into O(1) after the first walk. | ||
| const chain: SessionID[] = [] | ||
| let cur: SessionID | undefined = sid | ||
| let depth = 0 | ||
| while (cur !== undefined && !known.has(cur) && depth < maxDepth) { | ||
| chain.push(cur) | ||
| depth++ | ||
| const lookup: Option.Option<Info> = yield* get(cur).pipe( | ||
| Effect.option, | ||
| Effect.catchDefect((defect) => { | ||
| if (!NotFoundError.isInstance(defect)) return Effect.die(defect) | ||
| return Effect.succeed(Option.none<Info>()) | ||
| }), | ||
| ) | ||
| if (Option.isNone(lookup)) break | ||
| cur = lookup.value.parentID ?? undefined | ||
| } | ||
| if (cur === undefined || !known.has(cur)) return false | ||
| chain.forEach((item) => known.add(item)) | ||
| return true | ||
| }) | ||
|
|
||
| return Service.of({ | ||
| create, | ||
| fork, | ||
|
|
@@ -693,6 +761,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = | |
| getPart, | ||
| updatePartDelta, | ||
| findMessage, | ||
| isDescendantOf, | ||
| }) | ||
| }), | ||
| ) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch on the lost trace span. Restored in aed4e5a — wrapped the call in Effect.fn("SessionAutoReply.isDescendant") so traces still show the AutoReply-specific span alongside the inner Session.isDescendantOf span. No behavior change.