From 980f5e0f0d8bad76384a049a921464ba16cae35d Mon Sep 17 00:00:00 2001 From: Helge Tesdal Date: Wed, 29 Apr 2026 21:27:30 +0200 Subject: [PATCH 1/4] refactor(session): extract Session.isDescendantOf from auto-reply walk (F13) The lineage walk in SessionAutoReply (formerly run-events) was ad hoc. ACP and TUI will eventually need the same operation. Move the walk to Session.Interface.isDescendantOf with the lookup cache as a caller-provided argument so AutoReply retains dedup across events. - Add Session.isDescendantOf(sid, root, opts?: { maxDepth?, cache? }). Default maxDepth = 64 (generic). Cache, when provided, is mutated with all confirmed-descendant intermediate nodes, turning repeated calls against the same lineage into O(1) after the first walk. NotFoundError defects are caught and treated as a non-match. - AutoReply now delegates via a 2-line shim that passes its own MAX_LINEAGE_DEPTH (32) and its descendants-Set as the cache. Drops the inline 23-line Effect.fn plus Option/NotFoundError imports. - 7 new tests on Session.isDescendantOf: identity, direct child, grandchild, unrelated, non-existent, maxDepth boundary, cache accumulation. AutoReply's existing 14 auto-reply.test tests and the subagent-hang regression continue to pass unchanged. Diamond review: codex-5.3 spec APPROVE, Opus quality APPROVE. Refs: F13 in docs/superpowers/plans/2026-04-23-audit-remediation.md --- .../src/session/auto-reply/auto-reply.ts | 27 +--- packages/opencode/src/session/session.ts | 50 ++++++++ .../opencode/test/session/session.test.ts | 120 +++++++++++++++++- 3 files changed, 172 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/session/auto-reply/auto-reply.ts b/packages/opencode/src/session/auto-reply/auto-reply.ts index 0f94dde5d617..50c249bfbc47 100644 --- a/packages/opencode/src/session/auto-reply/auto-reply.ts +++ b/packages/opencode/src/session/auto-reply/auto-reply.ts @@ -1,9 +1,8 @@ -import { Cause, Effect, Fiber, Option } from "effect" +import { Cause, Effect, Fiber } from "effect" import { Bus } from "@/bus" import { Permission } from "@/permission" import { Question } from "@/question" import { Session } from "@/session" -import { NotFoundError } from "@/storage" import { SessionID } from "@/session/schema" import { Log } from "@/util" import type { Sink } from "./sink" @@ -49,28 +48,8 @@ export const make = Effect.fn("SessionAutoReply.make")(function* (config: Config const descendants = new Set([config.rootSessionID]) - const isDescendant = Effect.fn("SessionAutoReply.isDescendant")(function* (sid: SessionID) { - if (descendants.has(sid)) return true - let cur: SessionID | undefined = sid - const chain: SessionID[] = [] - let depth = 0 - while (cur !== undefined && !descendants.has(cur) && depth < MAX_LINEAGE_DEPTH) { - chain.push(cur) - depth++ - const lookup: Option.Option = yield* session.get(cur).pipe( - Effect.option, - Effect.catchDefect((defect) => { - if (!NotFoundError.isInstance(defect)) return Effect.die(defect) - return Effect.succeed(Option.none()) - }), - ) - if (Option.isNone(lookup)) break - cur = lookup.value.parentID ?? undefined - } - if (cur === undefined || !descendants.has(cur)) return false - chain.forEach((item) => descendants.add(item)) - return true - }) + const isDescendant = (sid: SessionID) => + session.isDescendantOf(sid, config.rootSessionID, { maxDepth: MAX_LINEAGE_DEPTH, cache: descendants }) const bump = (kind: "question" | "permission", sid: SessionID) => { if (kind === "question") stats.autoRejectedQuestions++ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a7607798ba40..00df2f486216 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -375,6 +375,22 @@ export interface Interface { sessionID: SessionID, predicate: (msg: MessageV2.WithParts) => boolean, ) => Effect.Effect> + /** + * 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. Callers that issue many `isDescendantOf` calls against the same + * root (e.g. SessionAutoReply) should seed the cache with `new Set([root])` + * and reuse it across calls so the parent chain is traversed at most once + * per node. + */ + readonly isDescendantOf: ( + sid: SessionID, + root: SessionID, + opts?: { maxDepth?: number; cache?: Set }, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/Session") {} @@ -671,6 +687,39 @@ export const layer: Layer.Layer = return Option.none() }) + const isDescendantOf = Effect.fn("Session.isDescendantOf")(function* ( + sid: SessionID, + root: SessionID, + opts?: { maxDepth?: number; cache?: Set }, + ) { + const maxDepth = opts?.maxDepth ?? 64 + const known = opts?.cache ?? new Set([root]) + if (sid === root) return true + if (known.has(sid)) return true + // Walk parent chain. Track the chain so we can promote every visited + // 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 = yield* get(cur).pipe( + Effect.option, + Effect.catchDefect((defect) => { + if (!NotFoundError.isInstance(defect)) return Effect.die(defect) + return Effect.succeed(Option.none()) + }), + ) + 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 +742,7 @@ export const layer: Layer.Layer = getPart, updatePartDelta, findMessage, + isDescendantOf, }) }), ) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index f63ad9beed9f..3fdc3ca44339 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -5,7 +5,7 @@ import { Bus } from "../../src/bus" import { Log } from "../../src/util" import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" -import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" import { tmpdir } from "../fixture/fixture" @@ -179,3 +179,121 @@ describe("Session", () => { expect(missing).toBe(true) }) }) + +function isDescendantOf( + sid: SessionID, + root: SessionID, + opts?: { maxDepth?: number; cache?: Set }, +) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.isDescendantOf(sid, root, opts))) +} + +describe("Session.isDescendantOf", () => { + test("identity: a session is its own descendant", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await create({ title: "root" }) + expect(await isDescendantOf(root.id, root.id)).toBe(true) + await remove(root.id) + }, + }) + }) + + test("direct child is a descendant", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await create({ title: "root" }) + const child = await create({ title: "child", parentID: root.id }) + expect(await isDescendantOf(child.id, root.id)).toBe(true) + await remove(root.id) + }, + }) + }) + + test("grandchild is a descendant", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await create({ title: "root" }) + const child = await create({ title: "child", parentID: root.id }) + const grandchild = await create({ title: "grandchild", parentID: child.id }) + expect(await isDescendantOf(grandchild.id, root.id)).toBe(true) + await remove(root.id) + }, + }) + }) + + test("unrelated session is not a descendant", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const rootA = await create({ title: "rootA" }) + const rootB = await create({ title: "rootB" }) + expect(await isDescendantOf(rootB.id, rootA.id)).toBe(false) + await remove(rootA.id) + await remove(rootB.id) + }, + }) + }) + + test("non-existent session id is not a descendant", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await create({ title: "root" }) + const ghost = SessionID.make("ses_ghost_does_not_exist_00000") + expect(await isDescendantOf(ghost, root.id)).toBe(false) + await remove(root.id) + }, + }) + }) + + test("respects maxDepth: returns false when chain is deeper than the limit", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await create({ title: "root" }) + // Build a chain root → c1 → c2 → c3. + const c1 = await create({ title: "c1", parentID: root.id }) + const c2 = await create({ title: "c2", parentID: c1.id }) + const c3 = await create({ title: "c3", parentID: c2.id }) + // maxDepth: 1 means we walk at most one parent edge from c3, which + // reaches c2 (not yet known), so we cannot confirm root and must + // return false. With the default depth (64), c3 is reachable. + expect(await isDescendantOf(c3.id, root.id, { maxDepth: 1 })).toBe(false) + expect(await isDescendantOf(c3.id, root.id)).toBe(true) + await remove(root.id) + }, + }) + }) + + test("cache accumulates confirmed descendants across calls", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await create({ title: "root" }) + const c1 = await create({ title: "c1", parentID: root.id }) + const c2 = await create({ title: "c2", parentID: c1.id }) + const cache = new Set([root.id]) + // First call walks the full chain c2 → c1 → root and promotes both + // intermediate nodes into the cache. + expect(await isDescendantOf(c2.id, root.id, { cache })).toBe(true) + expect(cache.has(c1.id)).toBe(true) + expect(cache.has(c2.id)).toBe(true) + // A subsequent call for c1 short-circuits via the cache (depth: 0 + // forbids any walk; cache hit alone must satisfy the call). + expect(await isDescendantOf(c1.id, root.id, { cache, maxDepth: 0 })).toBe(true) + await remove(root.id) + }, + }) + }) +}) From 935f474ed2ad2d1b8a774f89480c55577446e87c Mon Sep 17 00:00:00 2001 From: Helge Tesdal Date: Wed, 29 Apr 2026 21:33:39 +0200 Subject: [PATCH 2/4] address copilot R1: auto-seed root into isDescendantOf cache Copilot caught a footgun: when callers passed opts.cache without seeding it with root, the parent walk would never terminate at root (the loop checks known.has(cur), not cur === root) and would return false even for true descendants. Fix in Session.isDescendantOf: unconditionally known.add(root) at the top of every call, regardless of whether the caller pre-seeded. JSDoc updated to say a fresh empty Set is sufficient. AutoReply now seeds with new Set() (no manual root pre-seed). Also added a regression test that explicitly passes an empty cache and asserts the call succeeds. PR feedback: https://github.com/tesdal/opencode/pull/16#discussion_r3163647699 --- .../src/session/auto-reply/auto-reply.ts | 5 ++++- packages/opencode/src/session/session.ts | 17 ++++++++++++----- packages/opencode/test/session/session.test.ts | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/auto-reply/auto-reply.ts b/packages/opencode/src/session/auto-reply/auto-reply.ts index 50c249bfbc47..c0c552612ff4 100644 --- a/packages/opencode/src/session/auto-reply/auto-reply.ts +++ b/packages/opencode/src/session/auto-reply/auto-reply.ts @@ -46,7 +46,10 @@ export const make = Effect.fn("SessionAutoReply.make")(function* (config: Config livelockWarned: false, } - const descendants = new Set([config.rootSessionID]) + // Reused across calls so the parent walk is traversed at most once per + // descendant id. Session.isDescendantOf auto-seeds `rootSessionID` on every + // call, so an empty Set is the simplest seed. + const descendants = new Set() const isDescendant = (sid: SessionID) => session.isDescendantOf(sid, config.rootSessionID, { maxDepth: MAX_LINEAGE_DEPTH, cache: descendants }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 00df2f486216..b5a1c111e0c2 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -381,10 +381,11 @@ export interface Interface { * 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. Callers that issue many `isDescendantOf` calls against the same - * root (e.g. SessionAutoReply) should seed the cache with `new Set([root])` - * and reuse it across calls so the parent chain is traversed at most once - * per node. + * 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. + * 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. */ readonly isDescendantOf: ( sid: SessionID, @@ -693,7 +694,13 @@ export const layer: Layer.Layer = opts?: { maxDepth?: number; cache?: Set }, ) { const maxDepth = opts?.maxDepth ?? 64 - const known = opts?.cache ?? new Set([root]) + const known = opts?.cache ?? new Set() + // Always seed `root` into the working set so the parent walk has a + // termination anchor regardless of whether the caller pre-seeded the + // cache. Without this, a caller-provided cache that happens to omit + // `root` would cause the walk to bottom out at a not-found parent and + // return false even for true descendants — a silent footgun. + 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 diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 3fdc3ca44339..4e1632fe97e7 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -296,4 +296,22 @@ describe("Session.isDescendantOf", () => { }, }) }) + + test("auto-seeds root into cache so callers can pass an empty Set", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await create({ title: "root" }) + const child = await create({ title: "child", parentID: root.id }) + // Caller passes a fresh empty Set — root must still be reachable. + // Without the auto-seed in isDescendantOf this would walk past root, + // hit a not-found parent, and return false. + const cache = new Set() + expect(await isDescendantOf(child.id, root.id, { cache })).toBe(true) + expect(cache.has(root.id)).toBe(true) + await remove(root.id) + }, + }) + }) }) From f54a1adedd4937493c021ec2210b6824adebc759 Mon Sep 17 00:00:00 2001 From: Helge Tesdal Date: Wed, 29 Apr 2026 21:41:58 +0200 Subject: [PATCH 3/4] address copilot R2: guard against cross-root cache reuse + fix test wording R2 caught a remaining footgun: a cache populated against rootA could short-circuit a later call against rootB via known.has(sid), returning silently-wrong results. AutoReply doesn't do this (fresh Set per make call), but future ACP/TUI callers might. Fix: at call entry, if opts.cache is non-empty AND does not already contain the supplied root, die with a clear error message naming the expected root and the cache size. JSDoc updated to spell out the not-shared-across-roots invariant. Also tightened the auto-seed-empty-cache test comment: the failure mode without the auto-seed is walking PAST root (root.has(root) is false in an empty cache) and bottoming out, not necessarily a NotFoundError. New regression test: 'dies if cache is non-empty but missing root' populates a cache under rootA then asserts a subsequent rootB call dies. PR feedback: https://github.com/tesdal/opencode/pull/16 - comment 3163694330 (cross-root cache aliasing) - comment 3163694378 (test wording nit) --- packages/opencode/src/session/session.ts | 26 ++++++++++++----- .../opencode/test/session/session.test.ts | 29 +++++++++++++++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index b5a1c111e0c2..6662c7273724 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -383,9 +383,12 @@ export interface Interface { * 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. - * 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. + * 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. */ readonly isDescendantOf: ( sid: SessionID, @@ -695,11 +698,20 @@ export const layer: Layer.Layer = ) { const maxDepth = opts?.maxDepth ?? 64 const known = opts?.cache ?? new Set() + // 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 regardless of whether the caller pre-seeded the - // cache. Without this, a caller-provided cache that happens to omit - // `root` would cause the walk to bottom out at a not-found parent and - // return false even for true descendants — a silent footgun. + // 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 diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 4e1632fe97e7..8a4d76d5d4f7 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -305,8 +305,9 @@ describe("Session.isDescendantOf", () => { const root = await create({ title: "root" }) const child = await create({ title: "child", parentID: root.id }) // Caller passes a fresh empty Set — root must still be reachable. - // Without the auto-seed in isDescendantOf this would walk past root, - // hit a not-found parent, and return false. + // Without the auto-seed in isDescendantOf this would walk past root + // (since `known.has(root)` would be false), bottom out at the + // not-found parent of root, and return false. const cache = new Set() expect(await isDescendantOf(child.id, root.id, { cache })).toBe(true) expect(cache.has(root.id)).toBe(true) @@ -314,4 +315,28 @@ describe("Session.isDescendantOf", () => { }, }) }) + + test("dies if cache is non-empty but missing root (cross-root reuse guard)", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const rootA = await create({ title: "rootA" }) + const rootB = await create({ title: "rootB" }) + const childA = await create({ title: "childA", parentID: rootA.id }) + // Populate a cache under rootA, then incorrectly try to reuse it + // against rootB. The guard must reject this loudly. + const cache = new Set() + expect(await isDescendantOf(childA.id, rootA.id, { cache })).toBe(true) + // cache now contains {rootA, childA} but not rootB. + let died = false + await isDescendantOf(rootA.id, rootB.id, { cache }).catch(() => { + died = true + }) + expect(died).toBe(true) + await remove(rootA.id) + await remove(rootB.id) + }, + }) + }) }) From aed4e5a68b35c0c04a292e54c01ee5250385ce03 Mon Sep 17 00:00:00 2001 From: Helge Tesdal Date: Wed, 29 Apr 2026 22:04:17 +0200 Subject: [PATCH 4/4] address copilot R3: tighten JSDoc + restore AutoReply trace span R3 caught two issues: 1. JSDoc overstated the cross-root guard. The runtime check only catches a non-empty cache that lacks root; it does not catch a cache that has been mixed (root present plus entries from a different root). Detecting that would require tagging the cache. JSDoc rewritten to be honest: the guard is partial, intended usage is one cache per root, and a Map is the correct shape if disjoint roots must share state. 2. AutoReply lost its 'SessionAutoReply.isDescendant' trace span when the walk delegated to a plain arrow function. Restored Effect.fn wrapper so traces still show the AutoReply-specific call site alongside the inner Session.isDescendantOf span. No behavior change, only observability. PR feedback: https://github.com/tesdal/opencode/pull/16 - comment 3163766945 (JSDoc overstates guard scope) - comment 3163766986 (lost trace span name) --- .../src/session/auto-reply/auto-reply.ts | 12 +++++++++--- packages/opencode/src/session/session.ts | 16 ++++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/auto-reply/auto-reply.ts b/packages/opencode/src/session/auto-reply/auto-reply.ts index c0c552612ff4..a715385b83b3 100644 --- a/packages/opencode/src/session/auto-reply/auto-reply.ts +++ b/packages/opencode/src/session/auto-reply/auto-reply.ts @@ -48,11 +48,17 @@ export const make = Effect.fn("SessionAutoReply.make")(function* (config: Config // Reused across calls so the parent walk is traversed at most once per // descendant id. Session.isDescendantOf auto-seeds `rootSessionID` on every - // call, so an empty Set is the simplest seed. + // call, so an empty Set is the simplest seed. Wrapped in Effect.fn so + // AutoReply-specific lineage checks remain identifiable in traces alongside + // the inner Session.isDescendantOf span. const descendants = new Set() - const isDescendant = (sid: SessionID) => - session.isDescendantOf(sid, config.rootSessionID, { maxDepth: MAX_LINEAGE_DEPTH, cache: descendants }) + const isDescendant = Effect.fn("SessionAutoReply.isDescendant")(function* (sid: SessionID) { + return yield* session.isDescendantOf(sid, config.rootSessionID, { + maxDepth: MAX_LINEAGE_DEPTH, + cache: descendants, + }) + }) const bump = (kind: "question" | "permission", sid: SessionID) => { if (kind === "question") stats.autoRejectedQuestions++ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 6662c7273724..d3ac6aadc03d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -383,12 +383,16 @@ export interface Interface { * 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. + * + * Cache reuse is only safe within a single root. As a partial guard, if a + * non-empty cache is passed that does not already contain `root`, the call + * dies — that case can only arise from a cache leaked from another lineage. + * The guard does NOT catch caches that have been mixed (root present plus + * entries from a different root); detecting that would require tagging the + * cache. Each `SessionAutoReply` instance owns its own cache, which is the + * intended usage. If you have a use case that needs disjoint roots to + * share a cache, build a `Map>` outside this helper + * and pass the per-root inner Set. */ readonly isDescendantOf: ( sid: SessionID,