diff --git a/.changeset/cleanup-keeps-subagents.md b/.changeset/cleanup-keeps-subagents.md new file mode 100644 index 000000000000..fcc26a57c724 --- /dev/null +++ b/.changeset/cleanup-keeps-subagents.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep sub-agent sessions during automatic session cleanup while their parent chat is still in use. diff --git a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md index 065ea0d2c267..6346b391bdc9 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md +++ b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md @@ -75,6 +75,7 @@ Once enabled, the VS Code extension triggers cleanup about once a day while it i - **Active sessions**: Cleanup skips sessions reported as busy by the current backend and sessions with message or part writes in the last hour, including writes from another window or terminal. Merely keeping an idle session open does not protect it. - **Sessions with a recent fork**: Deleting a session also deletes sessions forked from it, so an old session with a recent fork stays until the fork ages out too. +- **Sub-agents of a chat in use**: A chat and its sub-agents are kept or deleted together, so returning to an older chat keeps its earlier sub-agents. {% callout type="warning" %} Deleted sessions are gone permanently, including their conversation history, across every project on this machine. Make sure the retention window fits how you work before enabling it. diff --git a/packages/opencode/src/kilocode/session/retention.ts b/packages/opencode/src/kilocode/session/retention.ts index c564c5a22e44..e312533047df 100644 --- a/packages/opencode/src/kilocode/session/retention.ts +++ b/packages/opencode/src/kilocode/session/retention.ts @@ -141,7 +141,8 @@ export namespace KiloSessionRetention { * expired ancestors to actually delete (the backend cascades children with * the parent), and expired sessions held back because they or a descendant * look busy. A parent is as fresh — and as protected — as its freshest - * descendant, so an old task with a recent or busy fork survives. + * descendant, so an old task with a recent or busy fork survives, and a + * child is as fresh as its top-level session. */ export function expiredRoots( rows: Row[], @@ -172,10 +173,25 @@ export namespace KiloSessionRetention { return next } + // A sub-agent session stops updating when it finishes, so a child is judged + // by its top-level chat: resuming an old chat keeps its earlier sub-agents. + const byId = new Map(rows.map((row) => [row.id, row])) + const top = (row: Row) => { + const seen = new Set() + let cur = row + while (cur.parentID && !seen.has(cur.id)) { + seen.add(cur.id) + const next = byId.get(cur.parentID) + if (!next) break + cur = next + } + return cur + } + const expired = new Set() const skipped: string[] = [] for (const row of rows) { - const state = touch(row) + const state = touch(top(row)) if (input.now - state.updated < input.maxAgeDays * DAY_MS) continue if (state.busy) { skipped.push(row.id) @@ -184,7 +200,6 @@ export namespace KiloSessionRetention { expired.add(row.id) } - const byId = new Map(rows.map((row) => [row.id, row])) const roots: string[] = [] for (const row of rows) { if (!expired.has(row.id)) continue @@ -290,7 +305,7 @@ export namespace KiloSessionRetention { updated: row.updated ?? now, })) // Select by age first so unrelated fresh sessions never require history - // probes. The second pass still propagates busy descendants to ancestors. + // probes. The second pass still propagates busy status across each session tree. const candidates = expiredRoots(mapped, { maxAgeDays: active.maxAgeDays, busy: new Set(), now }) const recent = yield* busySessions(now, candidates.expired) const memory = yield* SessionStatus.busyAll() diff --git a/packages/opencode/test/kilocode/session/retention.test.ts b/packages/opencode/test/kilocode/session/retention.test.ts index c9517dd768df..b194f9a3d841 100644 --- a/packages/opencode/test/kilocode/session/retention.test.ts +++ b/packages/opencode/test/kilocode/session/retention.test.ts @@ -142,9 +142,40 @@ describe("expiredRoots", () => { expect(run(rows).roots).toEqual(["parent", "grand"]) }) - it("keeps an expired child whose parent is not expired as its own root", () => { - const rows = [session("fresh-parent", 2), session("orphan-child", 35, { parentID: "fresh-parent" })] - expect(run(rows).roots).toEqual(["orphan-child"]) + it("keeps an old sub-agent while its parent chat is fresh", () => { + const rows = [session("fresh-parent", 2), session("old-child", 35, { parentID: "fresh-parent" })] + const result = run(rows) + expect(result.expired.size).toBe(0) + expect(result.roots).toEqual([]) + }) + + it("keeps old siblings when one sub-agent of an old chat is fresh", () => { + const rows = [ + session("parent", 40), + session("fresh", 2, { parentID: "parent" }), + session("stale", 35, { parentID: "parent" }), + ] + expect(run(rows).expired.size).toBe(0) + }) + + it("keeps a chain of old sub-agents under a fresh chat", () => { + const rows = [ + session("chat", 2), + session("mid", 35, { parentID: "chat" }), + session("leaf", 35, { parentID: "mid" }), + ] + expect(run(rows).expired.size).toBe(0) + }) + + it("holds back old sub-agents of a busy chat", () => { + const result = run([session("chat", 40), session("child", 35, { parentID: "chat" })], ["chat"]) + expect(result.expired.size).toBe(0) + expect(result.skipped).toEqual(["chat", "child"]) + }) + + it("judges a child whose parent row is missing by its own tree", () => { + const rows = [session("fresh", 2), session("orphan", 35, { parentID: "missing" })] + expect(run(rows).roots).toEqual(["orphan"]) }) }) @@ -342,9 +373,9 @@ runIt.live("candidate probes preserve cross-project descendant and busy-parent s rows: [{ id: child, parent, updated: old, message: old, part: now }], }) const outcome = yield* KiloSessionRetention.run({ force: true }).pipe(Effect.provide(enabled)) - expect(outcome.ran && outcome.result).toMatchObject({ scanned: 9, deleted: 2, failed: 0, skippedActive: 4 }) + expect(outcome.ran && outcome.result).toMatchObject({ scanned: 9, deleted: 0, failed: 0, skippedActive: 5 }) const rows = yield* db.select({ id: SessionTable.id }).from(SessionTable).all().pipe(Effect.orDie) - expect(rows.map((row) => row.id).sort()).toEqual(ids.filter((id) => id !== idle && id !== expired).sort()) + expect(rows.map((row) => row.id).sort()).toEqual([...ids].sort()) }), )