Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cleanup-keeps-subagents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Keep sub-agent sessions during automatic session cleanup while their parent chat is still in use.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 19 additions & 4 deletions packages/opencode/src/kilocode/session/retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down Expand Up @@ -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<string>()
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<string>()
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)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
41 changes: 36 additions & 5 deletions packages/opencode/test/kilocode/session/retention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
})

Expand Down Expand Up @@ -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())
}),
)

Expand Down
Loading