Skip to content

Commit fddc322

Browse files
committed
Merge F13: extract Session.isDescendantOf from auto-reply walk
Move the lineage walk from SessionAutoReply (where it was inline) to Session.Interface.isDescendantOf. AutoReply still owns its policy (MAX_LINEAGE_DEPTH=32, per-make-call cache); the helper is generic (default maxDepth=64) and reusable by ACP/TUI. The helper auto-seeds root into the cache, guards against cross-root cache reuse (dies if non-empty cache lacks root), and accumulates walked descendants for amortized O(1) repeated lookups against the same lineage. Diamond: codex-5.3 spec APPROVE, Opus quality APPROVE. Copilot 4 rounds: - R1 1 substantive: cache footgun (callers had to remember to seed root) → fixed by unconditionally adding root to known at call entry. - R2 1 substantive + 1 nit: cross-root cache aliasing → fixed by guard that dies on non-empty cache missing root; test wording → fixed. - R3 1 substantive + 1 nit: JSDoc overstated guard scope → tightened to match what the code actually enforces; lost AutoReply trace span → restored via Effect.fn wrapper. - R4 clean. PR (review-only): #16 Refs: F13 in docs/superpowers/plans/2026-04-23-audit-remediation.md
2 parents 347ecbe + aed4e5a commit fddc322

3 files changed

Lines changed: 246 additions & 24 deletions

File tree

packages/opencode/src/session/auto-reply/auto-reply.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { Cause, Effect, Fiber, Option } from "effect"
1+
import { Cause, Effect, Fiber } from "effect"
22
import { Bus } from "@/bus"
33
import { Permission } from "@/permission"
44
import { Question } from "@/question"
55
import { Session } from "@/session"
6-
import { NotFoundError } from "@/storage"
76
import { SessionID } from "@/session/schema"
87
import { Log } from "@/util"
98
import type { Sink } from "./sink"
@@ -47,29 +46,18 @@ export const make = Effect.fn("SessionAutoReply.make")(function* (config: Config
4746
livelockWarned: false,
4847
}
4948

50-
const descendants = new Set<SessionID>([config.rootSessionID])
49+
// Reused across calls so the parent walk is traversed at most once per
50+
// descendant id. Session.isDescendantOf auto-seeds `rootSessionID` on every
51+
// call, so an empty Set is the simplest seed. Wrapped in Effect.fn so
52+
// AutoReply-specific lineage checks remain identifiable in traces alongside
53+
// the inner Session.isDescendantOf span.
54+
const descendants = new Set<SessionID>()
5155

5256
const isDescendant = Effect.fn("SessionAutoReply.isDescendant")(function* (sid: SessionID) {
53-
if (descendants.has(sid)) return true
54-
let cur: SessionID | undefined = sid
55-
const chain: SessionID[] = []
56-
let depth = 0
57-
while (cur !== undefined && !descendants.has(cur) && depth < MAX_LINEAGE_DEPTH) {
58-
chain.push(cur)
59-
depth++
60-
const lookup: Option.Option<Session.Info> = yield* session.get(cur).pipe(
61-
Effect.option,
62-
Effect.catchDefect((defect) => {
63-
if (!NotFoundError.isInstance(defect)) return Effect.die(defect)
64-
return Effect.succeed(Option.none<Session.Info>())
65-
}),
66-
)
67-
if (Option.isNone(lookup)) break
68-
cur = lookup.value.parentID ?? undefined
69-
}
70-
if (cur === undefined || !descendants.has(cur)) return false
71-
chain.forEach((item) => descendants.add(item))
72-
return true
57+
return yield* session.isDescendantOf(sid, config.rootSessionID, {
58+
maxDepth: MAX_LINEAGE_DEPTH,
59+
cache: descendants,
60+
})
7361
})
7462

7563
const bump = (kind: "question" | "permission", sid: SessionID) => {

packages/opencode/src/session/session.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,30 @@ export interface Interface {
375375
sessionID: SessionID,
376376
predicate: (msg: MessageV2.WithParts) => boolean,
377377
) => Effect.Effect<Option.Option<MessageV2.WithParts>>
378+
/**
379+
* Returns true if `sid` is `root` or transitively descends from `root` via
380+
* `parentID`. Walks the parent chain up to `opts.maxDepth` (default 64) and
381+
* stops at any `NotFoundError` (returns false). When `opts.cache` is
382+
* provided, it is used both as a positive-hit short-circuit and as an
383+
* accumulator: every confirmed descendant in the walked chain is added to
384+
* the set. The cache is auto-seeded with `root` on every call, so callers
385+
* can pass a fresh `new Set()` and reuse it across calls without seeding.
386+
*
387+
* Cache reuse is only safe within a single root. As a partial guard, if a
388+
* non-empty cache is passed that does not already contain `root`, the call
389+
* dies — that case can only arise from a cache leaked from another lineage.
390+
* The guard does NOT catch caches that have been mixed (root present plus
391+
* entries from a different root); detecting that would require tagging the
392+
* cache. Each `SessionAutoReply` instance owns its own cache, which is the
393+
* intended usage. If you have a use case that needs disjoint roots to
394+
* share a cache, build a `Map<root, Set<SessionID>>` outside this helper
395+
* and pass the per-root inner Set.
396+
*/
397+
readonly isDescendantOf: (
398+
sid: SessionID,
399+
root: SessionID,
400+
opts?: { maxDepth?: number; cache?: Set<SessionID> },
401+
) => Effect.Effect<boolean>
378402
}
379403

380404
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
@@ -671,6 +695,54 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
671695
return Option.none<MessageV2.WithParts>()
672696
})
673697

698+
const isDescendantOf = Effect.fn("Session.isDescendantOf")(function* (
699+
sid: SessionID,
700+
root: SessionID,
701+
opts?: { maxDepth?: number; cache?: Set<SessionID> },
702+
) {
703+
const maxDepth = opts?.maxDepth ?? 64
704+
const known = opts?.cache ?? new Set<SessionID>()
705+
// Defend against accidental cache sharing across different roots: if a
706+
// caller passes a non-empty cache that lacks `root`, those entries were
707+
// populated under a different lineage and `known.has(sid)` could
708+
// short-circuit to true with the wrong answer. Fail loudly instead of
709+
// returning a silently wrong result.
710+
if (known.size > 0 && !known.has(root)) {
711+
return yield* Effect.die(
712+
new Error(
713+
`Session.isDescendantOf: opts.cache appears to belong to a different root (size=${known.size}, missing root=${root}). Caches must not be shared across roots.`,
714+
),
715+
)
716+
}
717+
// Always seed `root` into the working set so the parent walk has a
718+
// termination anchor and the next cross-root reuse check still works.
719+
known.add(root)
720+
if (sid === root) return true
721+
if (known.has(sid)) return true
722+
// Walk parent chain. Track the chain so we can promote every visited
723+
// node into the cache once we hit a known descendant — turns repeated
724+
// calls against the same lineage into O(1) after the first walk.
725+
const chain: SessionID[] = []
726+
let cur: SessionID | undefined = sid
727+
let depth = 0
728+
while (cur !== undefined && !known.has(cur) && depth < maxDepth) {
729+
chain.push(cur)
730+
depth++
731+
const lookup: Option.Option<Info> = yield* get(cur).pipe(
732+
Effect.option,
733+
Effect.catchDefect((defect) => {
734+
if (!NotFoundError.isInstance(defect)) return Effect.die(defect)
735+
return Effect.succeed(Option.none<Info>())
736+
}),
737+
)
738+
if (Option.isNone(lookup)) break
739+
cur = lookup.value.parentID ?? undefined
740+
}
741+
if (cur === undefined || !known.has(cur)) return false
742+
chain.forEach((item) => known.add(item))
743+
return true
744+
})
745+
674746
return Service.of({
675747
create,
676748
fork,
@@ -693,6 +765,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
693765
getPart,
694766
updatePartDelta,
695767
findMessage,
768+
isDescendantOf,
696769
})
697770
}),
698771
)

packages/opencode/test/session/session.test.ts

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
55
import { Log } from "../../src/util"
66
import { Instance } from "../../src/project/instance"
77
import { MessageV2 } from "../../src/session/message-v2"
8-
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
8+
import { MessageID, PartID, SessionID } from "../../src/session/schema"
99
import { AppRuntime } from "../../src/effect/app-runtime"
1010
import { tmpdir } from "../fixture/fixture"
1111

@@ -179,3 +179,164 @@ describe("Session", () => {
179179
expect(missing).toBe(true)
180180
})
181181
})
182+
183+
function isDescendantOf(
184+
sid: SessionID,
185+
root: SessionID,
186+
opts?: { maxDepth?: number; cache?: Set<SessionID> },
187+
) {
188+
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.isDescendantOf(sid, root, opts)))
189+
}
190+
191+
describe("Session.isDescendantOf", () => {
192+
test("identity: a session is its own descendant", async () => {
193+
await using tmp = await tmpdir({ git: true })
194+
await Instance.provide({
195+
directory: tmp.path,
196+
fn: async () => {
197+
const root = await create({ title: "root" })
198+
expect(await isDescendantOf(root.id, root.id)).toBe(true)
199+
await remove(root.id)
200+
},
201+
})
202+
})
203+
204+
test("direct child is a descendant", async () => {
205+
await using tmp = await tmpdir({ git: true })
206+
await Instance.provide({
207+
directory: tmp.path,
208+
fn: async () => {
209+
const root = await create({ title: "root" })
210+
const child = await create({ title: "child", parentID: root.id })
211+
expect(await isDescendantOf(child.id, root.id)).toBe(true)
212+
await remove(root.id)
213+
},
214+
})
215+
})
216+
217+
test("grandchild is a descendant", async () => {
218+
await using tmp = await tmpdir({ git: true })
219+
await Instance.provide({
220+
directory: tmp.path,
221+
fn: async () => {
222+
const root = await create({ title: "root" })
223+
const child = await create({ title: "child", parentID: root.id })
224+
const grandchild = await create({ title: "grandchild", parentID: child.id })
225+
expect(await isDescendantOf(grandchild.id, root.id)).toBe(true)
226+
await remove(root.id)
227+
},
228+
})
229+
})
230+
231+
test("unrelated session is not a descendant", async () => {
232+
await using tmp = await tmpdir({ git: true })
233+
await Instance.provide({
234+
directory: tmp.path,
235+
fn: async () => {
236+
const rootA = await create({ title: "rootA" })
237+
const rootB = await create({ title: "rootB" })
238+
expect(await isDescendantOf(rootB.id, rootA.id)).toBe(false)
239+
await remove(rootA.id)
240+
await remove(rootB.id)
241+
},
242+
})
243+
})
244+
245+
test("non-existent session id is not a descendant", async () => {
246+
await using tmp = await tmpdir({ git: true })
247+
await Instance.provide({
248+
directory: tmp.path,
249+
fn: async () => {
250+
const root = await create({ title: "root" })
251+
const ghost = SessionID.make("ses_ghost_does_not_exist_00000")
252+
expect(await isDescendantOf(ghost, root.id)).toBe(false)
253+
await remove(root.id)
254+
},
255+
})
256+
})
257+
258+
test("respects maxDepth: returns false when chain is deeper than the limit", async () => {
259+
await using tmp = await tmpdir({ git: true })
260+
await Instance.provide({
261+
directory: tmp.path,
262+
fn: async () => {
263+
const root = await create({ title: "root" })
264+
// Build a chain root → c1 → c2 → c3.
265+
const c1 = await create({ title: "c1", parentID: root.id })
266+
const c2 = await create({ title: "c2", parentID: c1.id })
267+
const c3 = await create({ title: "c3", parentID: c2.id })
268+
// maxDepth: 1 means we walk at most one parent edge from c3, which
269+
// reaches c2 (not yet known), so we cannot confirm root and must
270+
// return false. With the default depth (64), c3 is reachable.
271+
expect(await isDescendantOf(c3.id, root.id, { maxDepth: 1 })).toBe(false)
272+
expect(await isDescendantOf(c3.id, root.id)).toBe(true)
273+
await remove(root.id)
274+
},
275+
})
276+
})
277+
278+
test("cache accumulates confirmed descendants across calls", async () => {
279+
await using tmp = await tmpdir({ git: true })
280+
await Instance.provide({
281+
directory: tmp.path,
282+
fn: async () => {
283+
const root = await create({ title: "root" })
284+
const c1 = await create({ title: "c1", parentID: root.id })
285+
const c2 = await create({ title: "c2", parentID: c1.id })
286+
const cache = new Set<SessionID>([root.id])
287+
// First call walks the full chain c2 → c1 → root and promotes both
288+
// intermediate nodes into the cache.
289+
expect(await isDescendantOf(c2.id, root.id, { cache })).toBe(true)
290+
expect(cache.has(c1.id)).toBe(true)
291+
expect(cache.has(c2.id)).toBe(true)
292+
// A subsequent call for c1 short-circuits via the cache (depth: 0
293+
// forbids any walk; cache hit alone must satisfy the call).
294+
expect(await isDescendantOf(c1.id, root.id, { cache, maxDepth: 0 })).toBe(true)
295+
await remove(root.id)
296+
},
297+
})
298+
})
299+
300+
test("auto-seeds root into cache so callers can pass an empty Set", async () => {
301+
await using tmp = await tmpdir({ git: true })
302+
await Instance.provide({
303+
directory: tmp.path,
304+
fn: async () => {
305+
const root = await create({ title: "root" })
306+
const child = await create({ title: "child", parentID: root.id })
307+
// Caller passes a fresh empty Set — root must still be reachable.
308+
// Without the auto-seed in isDescendantOf this would walk past root
309+
// (since `known.has(root)` would be false), bottom out at the
310+
// not-found parent of root, and return false.
311+
const cache = new Set<SessionID>()
312+
expect(await isDescendantOf(child.id, root.id, { cache })).toBe(true)
313+
expect(cache.has(root.id)).toBe(true)
314+
await remove(root.id)
315+
},
316+
})
317+
})
318+
319+
test("dies if cache is non-empty but missing root (cross-root reuse guard)", async () => {
320+
await using tmp = await tmpdir({ git: true })
321+
await Instance.provide({
322+
directory: tmp.path,
323+
fn: async () => {
324+
const rootA = await create({ title: "rootA" })
325+
const rootB = await create({ title: "rootB" })
326+
const childA = await create({ title: "childA", parentID: rootA.id })
327+
// Populate a cache under rootA, then incorrectly try to reuse it
328+
// against rootB. The guard must reject this loudly.
329+
const cache = new Set<SessionID>()
330+
expect(await isDescendantOf(childA.id, rootA.id, { cache })).toBe(true)
331+
// cache now contains {rootA, childA} but not rootB.
332+
let died = false
333+
await isDescendantOf(rootA.id, rootB.id, { cache }).catch(() => {
334+
died = true
335+
})
336+
expect(died).toBe(true)
337+
await remove(rootA.id)
338+
await remove(rootB.id)
339+
},
340+
})
341+
})
342+
})

0 commit comments

Comments
 (0)