Skip to content

Commit d1cb324

Browse files
committed
Give retained sessions their own retention cap (CL-7007)
pruneCompleted's maxCompleted is a TUI display cap (default 20); CL-7002 correctly removed retained sessions' unbounded exemption from it, but that folded reusable-session retention into the same 20-item cap, so resume_agent on an early worker failed with a bare not_found past 20 spawned workers. Open retained sessions (retained:true, lifecycleStatus completed/interrupted) are now excluded from maxCompleted and bounded instead by a separate maxRetained cap (default 50, sized for fan-out), evicting least-recently-used first and never evicting a running session. Eviction still releases sidecars/ reactor/lock entry via releaseHandles, and leaves a tombstone so resume_agent/ followup_task/close_agent report an actionable terminal status plus a read_agent_trace pointer instead of not_found. Updated three CL-7001/CL-7002 tests that had encoded "retained sessions share the display cap" as correct behavior to instead assert the new maxRetained bound.
1 parent fd2acfe commit d1cb324

6 files changed

Lines changed: 297 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Fixed
17+
18+
- Retained worker sessions (`spawn_agent`, resumable via `resume_agent`/`followup_task`) now have
19+
their own retention cap, separate from the TUI's finished-session display cap. Previously they
20+
shared that 20-item cap, so `resume_agent` on an early worker failed with a bare `not_found` once
21+
a fan-out of more than 20 workers had finished. A session dropped by the retention cap still
22+
releases its sidecars/reactor/lock entry, always evicts least-recently-used first, and never
23+
evicts a running session. `resume_agent`/`followup_task` against an evicted session now report
24+
its terminal status plus a pointer to `read_agent_trace`, instead of `not_found`.
25+
1426
## [0.3.0] - 2026-08-24
1527

1628
### Breaking

src/subagent/agent-fleet.test.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -196,12 +196,17 @@ describe("spawn_agent + wait_agents", () => {
196196
// DEFAULT_MAX_COMPLETED on SubAgentSessionStore is 20 finished sessions;
197197
// spawn (and complete) enough workers to blow well past it before any of
198198
// them is collected, proving fleetRecords does not depend on the store's
199-
// cap. CL-7001: a retained session is bounded by this same cap too now
200-
// (it used to be exempt with no separate cap or TTL, which is exactly
201-
// why every spawn_agent worker leaked by default) — so unlike the
202-
// pre-CL-7001 version of this test, the store itself may have already
203-
// evicted (and released) the earliest ones; wait_agents/fleetRecords is
204-
// the durable source of truth this test actually cares about.
199+
// cap.
200+
//
201+
// CL-7007: this test previously asserted (as CL-7001's fix left it) that
202+
// the store itself had already evicted and released the earliest
203+
// session, because a retained session shared the 20-item display cap
204+
// with every other finished session — exactly the shipped defect this
205+
// ticket fixes (resume_agent/followup_task failed with a bare
206+
// "not_found" past 20 spawned workers, blaming the caller for nothing).
207+
// Open retained sessions now have their own cap (`maxRetained`, default
208+
// 50), so 25 of them all stay resumable; fleetRecords/wait_agents is
209+
// still asserted below as the durable source of truth regardless.
205210
const COUNT = 25;
206211
const deps = makeDeps(async () => ({ report: "irrelevant", agentRetained: true }));
207212
const spawn = createSpawnAgentTool(deps);
@@ -220,10 +225,9 @@ describe("spawn_agent + wait_agents", () => {
220225
// Let every spawn's run() resolve and complete() land before collecting.
221226
await new Promise((resolve) => setTimeout(resolve, 20));
222227

223-
// The store's own bound may have already evicted (and released) the
224-
// earliest session — fleetRecords below is what wait_agents actually
225-
// depends on, and it is never subject to this cap.
226-
expect(deps.sessions.get(ids[0]!)).toBeUndefined();
228+
// 25 open retained sessions is under the default maxRetained (50), so
229+
// the earliest is still present and resumable — not evicted.
230+
expect(deps.sessions.get(ids[0]!)).toBeDefined();
227231

228232
// Every single one is retrievable through wait_agents too.
229233
const waited = await callTool(wait, { targets: ids, timeout_ms: 5000 });

src/subagent/lifecycle-tools.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,10 @@ export function createResumeAgentTool(deps: LifecycleToolDeps): AgentTool {
136136
const target = parsed.target.trim();
137137
const outcome = deps.sessions.resumeOne(target);
138138
if (!outcome.ok) {
139+
const hint = outcome.hint !== undefined ? ` ${outcome.hint}` : "";
139140
return lifecycleResult(
140141
call.id,
141-
`Error: cannot resume "${target}" (status: ${outcome.status}).`,
142+
`Error: cannot resume "${target}" (status: ${outcome.status}).${hint}`,
142143
);
143144
}
144145
return lifecycleResult(call.id, JSON.stringify({ agent_id: target, status: "running" }));
@@ -235,9 +236,10 @@ export function createFollowupTaskTool(deps: LifecycleToolDeps): AgentTool {
235236
}
236237
const outcome = await deps.sessions.followupOne(target, message);
237238
if (!outcome.ok) {
239+
const hint = outcome.hint !== undefined ? ` ${outcome.hint}` : "";
238240
return lifecycleResult(
239241
call.id,
240-
`Error: cannot send followup to "${target}" (status: ${outcome.status}).`,
242+
`Error: cannot send followup to "${target}" (status: ${outcome.status}).${hint}`,
241243
);
242244
}
243245
return lifecycleResult(

src/subagent/retain-salvage.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,23 @@ describe("retained session lifecycle", () => {
3232
expect(closed).toBe(true);
3333
});
3434

35-
test("retained completed sessions are exempt from the display cap without bound", () => {
36-
const store = createSubAgentSessionStore({ maxCompleted: 3 });
35+
// CL-7007: retained completed sessions are no longer bounded by
36+
// `maxCompleted` (the TUI display cap) at all — that was CL-7002's fix,
37+
// and it created a new bug: resume_agent/followup_task started failing
38+
// with a bare "not_found" once more than `maxCompleted` (default 20)
39+
// workers had spawned in a turn, even though every one of them was still
40+
// perfectly reusable. Open retained sessions now get their own explicit
41+
// cap, `maxRetained`, sized for fan-out rather than a sidebar list — this
42+
// test moved from asserting `maxCompleted` bounds them to asserting
43+
// `maxRetained` does (still bounded, still no leak, just the right knob).
44+
test("retained completed sessions are bounded by maxRetained, not the display cap", () => {
45+
const store = createSubAgentSessionStore({ maxCompleted: 3, maxRetained: 3 });
3746
for (let i = 0; i < 50; i++) {
3847
const s = store.start({ description: `w${i}`, agentId: "build", brief: "b", retained: true });
48+
store.registerClose(s.id, async () => {});
3949
store.complete(s.id, "done");
4050
}
41-
console.log("sessions retained despite maxCompleted=3:", store.list().length);
51+
console.log("sessions retained despite maxRetained=3:", store.list().length);
4252
expect(store.list().length).toBeLessThanOrEqual(3);
4353
});
4454

src/subagent/session-store.test.ts

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -358,13 +358,19 @@ describe("CL-6943 reusable worker sessions", () => {
358358
expect(store.resumeOne(session.id)).toEqual({ ok: false, status: "shutdown" });
359359
});
360360

361-
// CL-7001: a retained, still-open session used to be exempt from this cap
362-
// entirely — no separate cap or TTL — which is exactly why every
363-
// spawn_agent worker leaked by default. maxCompleted is now the one bound
364-
// the store owns for every finished session, retained or not, and
365-
// eviction releases the session's close handle instead of abandoning it.
366-
test("pruneCompleted evicts a retained, still-open session past maxCompleted and releases it", () => {
367-
const store = createSubAgentSessionStore({ maxCompleted: 1 });
361+
// CL-7001 originally folded a retained, still-open session into
362+
// maxCompleted (the TUI display cap) with no separate bound at all,
363+
// fixing the unbounded leak but creating a new bug: resume_agent /
364+
// followup_task fail once more than `maxCompleted` (default 20) workers
365+
// have spawned, even though every one of them is still perfectly
366+
// reusable. CL-7007 gives open retained sessions their own cap
367+
// (`maxRetained`) instead — this test changed from asserting that
368+
// `maxCompleted` evicts a retained session (no longer true: retained
369+
// sessions are excluded from that cap, see isOpenRetained) to asserting
370+
// that `maxRetained` does, with the same "handles still get released"
371+
// guarantee.
372+
test("pruneRetained evicts a retained, still-open session past maxRetained and releases it", () => {
373+
const store = createSubAgentSessionStore({ maxCompleted: 1, maxRetained: 1 });
368374
const retained = store.start({
369375
description: "keep-me",
370376
agentId: "a",
@@ -378,14 +384,114 @@ describe("CL-6943 reusable worker sessions", () => {
378384
store.complete(retained.id, "## Summary\nDone.");
379385

380386
for (let i = 0; i < 3; i++) {
381-
const s = store.start({ description: `fill-${i}`, agentId: "a", brief: "b" });
387+
const s = store.start({
388+
description: `fill-${i}`,
389+
agentId: "a",
390+
brief: "b",
391+
retained: true,
392+
});
393+
store.registerClose(s.id, async () => {});
382394
store.complete(s.id, "## Summary\nDone.");
383395
}
384396

385397
expect(store.get(retained.id)).toBeUndefined();
386398
expect(closed).toBe(true);
387399
});
388400

401+
// CL-7002's fix (retained sessions are no longer exempt from any cap) must
402+
// survive CL-7007: a non-retained finished session still obeys
403+
// maxCompleted exactly as before.
404+
test("maxCompleted still evicts an ordinary (non-retained) finished session", () => {
405+
const store = createSubAgentSessionStore({ maxCompleted: 1 });
406+
const first = store.start({ description: "first", agentId: "a", brief: "b" });
407+
store.complete(first.id, "## Summary\nDone.");
408+
409+
for (let i = 0; i < 3; i++) {
410+
const s = store.start({ description: `fill-${i}`, agentId: "a", brief: "b" });
411+
store.complete(s.id, "## Summary\nDone.");
412+
}
413+
414+
expect(store.get(first.id)).toBeUndefined();
415+
});
416+
417+
test("resume_agent on a retention-evicted session returns an actionable status, not not_found", () => {
418+
const store = createSubAgentSessionStore({ maxRetained: 1 });
419+
const retained = store.start({
420+
description: "keep-me",
421+
agentId: "a",
422+
brief: "b",
423+
retained: true,
424+
});
425+
store.registerClose(retained.id, async () => {});
426+
store.complete(retained.id, "## Summary\nDone.");
427+
428+
for (let i = 0; i < 3; i++) {
429+
const s = store.start({
430+
description: `fill-${i}`,
431+
agentId: "a",
432+
brief: "b",
433+
retained: true,
434+
});
435+
store.registerClose(s.id, async () => {});
436+
store.complete(s.id, "## Summary\nDone.");
437+
}
438+
439+
const outcome = store.resumeOne(retained.id);
440+
expect(outcome.ok).toBe(false);
441+
if (!outcome.ok) {
442+
expect(outcome.status).toBe("completed");
443+
expect(outcome.hint).toMatch(/read_agent_trace/);
444+
}
445+
});
446+
447+
test("a running session is never evicted by maxRetained even when the cap is exceeded", () => {
448+
const store = createSubAgentSessionStore({ maxRetained: 1 });
449+
const running = store.start({
450+
description: "keep-me",
451+
agentId: "a",
452+
brief: "b",
453+
retained: true,
454+
});
455+
store.markRunning(running.id);
456+
// Resume it back to "running" so it is an open, actively-driven session.
457+
store.registerClose(running.id, async () => {});
458+
store.complete(running.id, "## Summary\nDone.");
459+
store.resumeOne(running.id);
460+
expect(store.get(running.id)?.lifecycleStatus).toBe("running");
461+
462+
for (let i = 0; i < 5; i++) {
463+
const s = store.start({
464+
description: `fill-${i}`,
465+
agentId: "a",
466+
brief: "b",
467+
retained: true,
468+
});
469+
store.registerClose(s.id, async () => {});
470+
store.complete(s.id, "## Summary\nDone.");
471+
}
472+
473+
expect(store.get(running.id)).toBeDefined();
474+
expect(store.get(running.id)?.lifecycleStatus).toBe("running");
475+
});
476+
477+
test("maxRetained bounds memory: many spawned-and-completed retained sessions do not grow without limit", () => {
478+
const store = createSubAgentSessionStore({ maxRetained: 5 });
479+
for (let i = 0; i < 50; i++) {
480+
const s = store.start({
481+
description: `worker-${i}`,
482+
agentId: "a",
483+
brief: "b",
484+
retained: true,
485+
});
486+
store.registerClose(s.id, async () => {});
487+
store.complete(s.id, "## Summary\nDone.");
488+
}
489+
const openRetained = store
490+
.list()
491+
.filter((s) => s.retained === true && s.lifecycleStatus === "completed");
492+
expect(openRetained.length).toBeLessThanOrEqual(5);
493+
});
494+
389495
test("once closed, a retained session becomes a normal finished record subject to the cap", async () => {
390496
const store = createSubAgentSessionStore({ maxCompleted: 1 });
391497
const retained = store.start({

0 commit comments

Comments
 (0)