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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.

## [Unreleased]

### Fixed

- Retained worker sessions (`spawn_agent`, resumable via `resume_agent`/`followup_task`) now have
their own retention cap, separate from the TUI's finished-session display cap. Previously they
shared that 20-item cap, so `resume_agent` on an early worker failed with a bare `not_found` once
a fan-out of more than 20 workers had finished. A session dropped by the retention cap still
releases its sidecars/reactor/lock entry, always evicts least-recently-used first, and never
evicts a running session. `resume_agent`/`followup_task` against an evicted session now report
its terminal status plus a pointer to `read_agent_trace`, instead of `not_found`.

## [0.3.0] - 2026-08-24

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

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

// Every single one is retrievable through wait_agents too.
const waited = await callTool(wait, { targets: ids, timeout_ms: 5000 });
Expand Down
6 changes: 4 additions & 2 deletions src/subagent/lifecycle-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,10 @@ export function createResumeAgentTool(deps: LifecycleToolDeps): AgentTool {
const target = parsed.target.trim();
const outcome = deps.sessions.resumeOne(target);
if (!outcome.ok) {
const hint = outcome.hint !== undefined ? ` ${outcome.hint}` : "";
return lifecycleResult(
call.id,
`Error: cannot resume "${target}" (status: ${outcome.status}).`,
`Error: cannot resume "${target}" (status: ${outcome.status}).${hint}`,
);
}
return lifecycleResult(call.id, JSON.stringify({ agent_id: target, status: "running" }));
Expand Down Expand Up @@ -235,9 +236,10 @@ export function createFollowupTaskTool(deps: LifecycleToolDeps): AgentTool {
}
const outcome = await deps.sessions.followupOne(target, message);
if (!outcome.ok) {
const hint = outcome.hint !== undefined ? ` ${outcome.hint}` : "";
return lifecycleResult(
call.id,
`Error: cannot send followup to "${target}" (status: ${outcome.status}).`,
`Error: cannot send followup to "${target}" (status: ${outcome.status}).${hint}`,
);
}
return lifecycleResult(
Expand Down
16 changes: 13 additions & 3 deletions src/subagent/retain-salvage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,23 @@ describe("retained session lifecycle", () => {
expect(closed).toBe(true);
});

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

Expand Down
122 changes: 114 additions & 8 deletions src/subagent/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,13 +358,19 @@ describe("CL-6943 reusable worker sessions", () => {
expect(store.resumeOne(session.id)).toEqual({ ok: false, status: "shutdown" });
});

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

for (let i = 0; i < 3; i++) {
const s = store.start({ description: `fill-${i}`, agentId: "a", brief: "b" });
const s = store.start({
description: `fill-${i}`,
agentId: "a",
brief: "b",
retained: true,
});
store.registerClose(s.id, async () => {});
store.complete(s.id, "## Summary\nDone.");
}

expect(store.get(retained.id)).toBeUndefined();
expect(closed).toBe(true);
});

// CL-7002's fix (retained sessions are no longer exempt from any cap) must
// survive CL-7007: a non-retained finished session still obeys
// maxCompleted exactly as before.
test("maxCompleted still evicts an ordinary (non-retained) finished session", () => {
const store = createSubAgentSessionStore({ maxCompleted: 1 });
const first = store.start({ description: "first", agentId: "a", brief: "b" });
store.complete(first.id, "## Summary\nDone.");

for (let i = 0; i < 3; i++) {
const s = store.start({ description: `fill-${i}`, agentId: "a", brief: "b" });
store.complete(s.id, "## Summary\nDone.");
}

expect(store.get(first.id)).toBeUndefined();
});

test("resume_agent on a retention-evicted session returns an actionable status, not not_found", () => {
const store = createSubAgentSessionStore({ maxRetained: 1 });
const retained = store.start({
description: "keep-me",
agentId: "a",
brief: "b",
retained: true,
});
store.registerClose(retained.id, async () => {});
store.complete(retained.id, "## Summary\nDone.");

for (let i = 0; i < 3; i++) {
const s = store.start({
description: `fill-${i}`,
agentId: "a",
brief: "b",
retained: true,
});
store.registerClose(s.id, async () => {});
store.complete(s.id, "## Summary\nDone.");
}

const outcome = store.resumeOne(retained.id);
expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.status).toBe("completed");
expect(outcome.hint).toMatch(/read_agent_trace/);
}
});

test("a running session is never evicted by maxRetained even when the cap is exceeded", () => {
const store = createSubAgentSessionStore({ maxRetained: 1 });
const running = store.start({
description: "keep-me",
agentId: "a",
brief: "b",
retained: true,
});
store.markRunning(running.id);
// Resume it back to "running" so it is an open, actively-driven session.
store.registerClose(running.id, async () => {});
store.complete(running.id, "## Summary\nDone.");
store.resumeOne(running.id);
expect(store.get(running.id)?.lifecycleStatus).toBe("running");

for (let i = 0; i < 5; i++) {
const s = store.start({
description: `fill-${i}`,
agentId: "a",
brief: "b",
retained: true,
});
store.registerClose(s.id, async () => {});
store.complete(s.id, "## Summary\nDone.");
}

expect(store.get(running.id)).toBeDefined();
expect(store.get(running.id)?.lifecycleStatus).toBe("running");
});

test("maxRetained bounds memory: many spawned-and-completed retained sessions do not grow without limit", () => {
const store = createSubAgentSessionStore({ maxRetained: 5 });
for (let i = 0; i < 50; i++) {
const s = store.start({
description: `worker-${i}`,
agentId: "a",
brief: "b",
retained: true,
});
store.registerClose(s.id, async () => {});
store.complete(s.id, "## Summary\nDone.");
}
const openRetained = store
.list()
.filter((s) => s.retained === true && s.lifecycleStatus === "completed");
expect(openRetained.length).toBeLessThanOrEqual(5);
});

test("once closed, a retained session becomes a normal finished record subject to the cap", async () => {
const store = createSubAgentSessionStore({ maxCompleted: 1 });
const retained = store.start({
Expand Down
Loading
Loading