Skip to content

Commit ee3dbad

Browse files
committed
Allow concurrent same-cwd spawn_agent, cap fleetRecords memory
CL-6992: delete the write-lane refusal on spawn_agent — multiple agents in one worktree is allowed by design, and the refusal was guarding against self-resolving churn, not corruption. CL-6990: bound fleetRecords' in-memory reports. Past MAX_FLEET_RECORDS, the oldest already-collected report is compacted to a status-only tombstone pointing at read_agent_trace, never evicting an uncollected report ahead of a collected one.
1 parent 1fcdd40 commit ee3dbad

3 files changed

Lines changed: 138 additions & 95 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ 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+
### Agent
17+
18+
- `spawn_agent` no longer refuses a second concurrent implement-intent spawn
19+
against the same working directory — running multiple agents against one
20+
worktree is allowed by design, and the refusal was guarding against churn
21+
that resolves on its own, not corruption.
22+
- `fleetRecords` (the store behind `wait_agents`) now caps how many full
23+
reports it holds in memory; past the cap, the oldest report already
24+
delivered to a caller is compacted to a status-only tombstone pointing at
25+
`read_agent_trace` for the detail, so an uncollected report is never
26+
evicted ahead of one that's already been picked up.
27+
1428
## [0.2.109] - 2026-08-24
1529

1630
### Agent

src/subagent/agent-fleet.test.ts

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createFleetRecords,
55
createSpawnAgentTool,
66
createWaitAgentsTool,
7+
MAX_FLEET_RECORDS,
78
type AgentFleetDeps,
89
} from "./agent-fleet.js";
910
import { createSubAgentSessionStore } from "./session-store.js";
@@ -228,67 +229,89 @@ describe("spawn_agent + wait_agents", () => {
228229
});
229230
});
230231

231-
describe("spawn_agent write-lane isolation", () => {
232-
test("refuses a second concurrent implement-intent spawn against the same cwd", async () => {
233-
const gate = deferred<RunSubAgentResult>();
234-
const deps = makeDeps(async () => gate.promise, { cwd: "/repo" });
232+
describe("spawn_agent same-cwd concurrency", () => {
233+
test("two concurrent implement-intent spawn_agent calls against the same cwd both start", async () => {
234+
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
235+
let callIndex = 0;
236+
const deps = makeDeps(async () => gates[callIndex++]!.promise, { cwd: "/repo" });
235237
const spawn = createSpawnAgentTool(deps);
236238

237239
const first = await callTool(spawn, {
238240
description: "build one",
239241
prompt: "implement thing one",
240242
intent: "implement",
241243
});
242-
expect(first.status).toBe("running");
243-
244-
const second = await callToolRaw(spawn, {
244+
const second = await callTool(spawn, {
245245
description: "build two",
246246
prompt: "implement thing two",
247247
intent: "implement",
248248
});
249-
expect(second.isError).toBe(true);
250-
expect(second.content).toContain("Error:");
251-
expect(second.content).toContain(first.agent_id as string);
252249

253-
gate.resolve({ report: "done" });
250+
expect(first.status).toBe("running");
251+
expect(second.status).toBe("running");
252+
253+
gates[0]!.resolve({ report: "one done" });
254+
gates[1]!.resolve({ report: "two done" });
254255
});
256+
});
255257

256-
test("does not refuse a second concurrent explore-intent spawn against the same cwd", async () => {
257-
const deps = makeDeps(async () => ({ report: "explored" }), { cwd: "/repo" });
258+
describe("fleetRecords retention cap", () => {
259+
test("many spawned-and-completed workers whose reports are never collected leave memory bounded", async () => {
260+
const COUNT = MAX_FLEET_RECORDS + 50;
261+
const deps = makeDeps(async () => ({ report: "x".repeat(1000) }));
258262
const spawn = createSpawnAgentTool(deps);
263+
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
259264

260-
const first = await callTool(spawn, {
261-
description: "explore one",
262-
prompt: "look around",
263-
intent: "explore",
264-
});
265-
const second = await callTool(spawn, {
266-
description: "explore two",
267-
prompt: "look around more",
268-
intent: "explore",
269-
});
265+
const ids: string[] = [];
266+
for (let i = 0; i < COUNT; i++) {
267+
const spawned = await callTool(spawn, {
268+
description: `job-${i}`,
269+
prompt: `p-${i}`,
270+
intent: "explore",
271+
});
272+
ids.push(spawned.agent_id as string);
273+
}
274+
await new Promise((resolve) => setTimeout(resolve, 20));
270275

271-
expect(first.status).toBe("running");
272-
expect(second.status).toBe("running");
276+
const waited = await callTool(wait, { targets: ids, timeout_ms: 5000 });
277+
const results = waited.results as { status: string; report?: string }[];
278+
const withReport = results.filter((r) => r.report !== undefined).length;
279+
280+
// Payloads are capped: well under COUNT full reports survive uncollected.
281+
expect(withReport).toBeLessThanOrEqual(MAX_FLEET_RECORDS);
282+
expect(withReport).toBeLessThan(COUNT);
273283
});
274284

275-
test("releases the write lane once the implement worker finishes, allowing another", async () => {
276-
const deps = makeDeps(async () => ({ report: "built" }), { cwd: "/repo" });
285+
test("an evicted-but-uncollected agent resolves to its terminal status plus a read_agent_trace pointer", async () => {
286+
const COUNT = MAX_FLEET_RECORDS + 50;
287+
const deps = makeDeps(async () => ({ report: "x".repeat(1000) }));
277288
const spawn = createSpawnAgentTool(deps);
278289
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
279290

280-
const first = await callTool(spawn, {
281-
description: "build one",
282-
prompt: "implement thing one",
283-
intent: "implement",
284-
});
285-
await callTool(wait, { targets: [first.agent_id as string], timeout_ms: 5000 });
291+
const ids: string[] = [];
292+
for (let i = 0; i < COUNT; i++) {
293+
const spawned = await callTool(spawn, {
294+
description: `job-${i}`,
295+
prompt: `p-${i}`,
296+
intent: "explore",
297+
});
298+
ids.push(spawned.agent_id as string);
299+
}
300+
await new Promise((resolve) => setTimeout(resolve, 20));
286301

287-
const second = await callTool(spawn, {
288-
description: "build two",
289-
prompt: "implement thing two",
290-
intent: "implement",
291-
});
292-
expect(second.status).toBe("running");
302+
// The earliest spawned agent's payload should have been tombstoned —
303+
// never collected, so it was evicted once the cap was exceeded.
304+
const waited = await callTool(wait, { targets: [ids[0]!], timeout_ms: 5000 });
305+
const results = waited.results as {
306+
agent_id: string;
307+
status: string;
308+
report?: string;
309+
hint?: string;
310+
}[];
311+
expect(results).toHaveLength(1);
312+
expect(results[0]!.status).not.toBe("unknown");
313+
expect(["done", "failed"]).toContain(results[0]!.status);
314+
expect(results[0]!.report).toBeUndefined();
315+
expect(results[0]!.hint).toContain("read_agent_trace");
293316
});
294317
});

src/subagent/agent-fleet.ts

Lines changed: 62 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@
1818
* a caller can spawn far more workers than the cap in one turn and only
1919
* `wait_agents` them later, so an evicted report would otherwise vanish
2020
* silently. `fleetRecords` below is a small, deliberately-separate map
21-
* (agent id -> terminal status/report/error) that is never capped and is
22-
* only ever cleared when `wait_agents` actually delivers that result to a
21+
* (agent id -> terminal status/report/error), kept alive across the store's
22+
* own eviction and cleared only when `wait_agents` delivers a result to a
2323
* caller — it exists precisely because the store's cap cannot be trusted for
24-
* this use.
24+
* this use. Its heavy payloads (report/error text) are capped at
25+
* `MAX_FLEET_RECORDS`: past that, the oldest already-collected entry is
26+
* compacted to a tombstone (status only, plus a pointer at
27+
* `read_agent_trace` for the detail), falling back to the oldest
28+
* uncollected one only once every collected entry is gone — a caller who
29+
* never called wait_agents still gets a terminal status, never a bare
30+
* "unknown".
2531
*
2632
* Argument shape intentionally mirrors `task()`'s (description/prompt/
2733
* context/goals/intent/success_criteria/do_not/report_focus/maxTurns) so a
@@ -31,13 +37,6 @@
3137
* no nested orchestration, no re-dispatch ledger. Those remain `task()`-only
3238
* for now; nothing here stops adding them later.
3339
*
34-
* Worktree isolation: task() supports it, spawn_agent does not (yet). Since
35-
* spawn_agent's whole point is running several workers at once, two workers
36-
* sharing one cwd with write intent would silently corrupt each other's
37-
* edits. Rather than duplicate task()'s worktree machinery here, spawn_agent
38-
* refuses a second concurrent implement-intent (director "build") spawn
39-
* against the same cwd with an actionable error — explore/plan/review
40-
* workers, which do not write, are unaffected and may run concurrently.
4140
*/
4241

4342
import { tool } from "@intx/agent";
@@ -76,12 +75,25 @@ interface FleetRecord {
7675
status: "running" | "done" | "failed";
7776
report?: string;
7877
error?: string;
78+
/** Set once a wait_agents caller has been handed this result. */
79+
collected?: boolean;
80+
/** Set once the payload has been compacted away to bound memory. */
81+
tombstoned?: boolean;
82+
/** Present only on a tombstoned record — how to recover the detail. */
83+
hint?: string;
7984
}
8085

86+
const RECOVERY_HINT =
87+
"Report evicted to bound fleet memory; recover full detail via read_agent_trace(agent_id).";
88+
89+
/** Payload cap: terminal records still holding a report/error. */
90+
export const MAX_FLEET_RECORDS = 200;
91+
8192
/**
82-
* Never-capped terminal-result store, cleared only once a result is
83-
* delivered to a wait_agents caller. See the module doc comment for why the
84-
* session store's own retention cannot be reused here.
93+
* Terminal-result store, cleared once a result is delivered to a
94+
* wait_agents caller. See the module doc comment for why the session
95+
* store's own retention cannot be reused here, and for the tombstone
96+
* eviction policy once more than `MAX_FLEET_RECORDS` payloads are held.
8597
*/
8698
class FleetRecords {
8799
private readonly records = new Map<string, FleetRecord>();
@@ -92,25 +104,59 @@ class FleetRecords {
92104

93105
resolve(id: string, report: string): void {
94106
this.records.set(id, { status: "done", report });
107+
this.enforceCap();
95108
}
96109

97110
reject(id: string, error: string): void {
98111
this.records.set(id, { status: "failed", error });
112+
this.enforceCap();
99113
}
100114

101115
/** Read without consuming — used for the terminal-yet check. */
102116
peek(id: string): FleetRecord | undefined {
103117
return this.records.get(id);
104118
}
105119

106-
/** Read and, if terminal, remove — a delivered result is not kept around. */
120+
/**
121+
* Read and, if terminal, mark collected. The entry is kept (not deleted)
122+
* so a later query still resolves to a real status instead of "unknown" —
123+
* it just becomes the preferred eviction target once the payload cap is
124+
* hit.
125+
*/
107126
take(id: string): FleetRecord | undefined {
108127
const record = this.records.get(id);
109128
if (record !== undefined && record.status !== "running") {
110-
this.records.delete(id);
129+
record.collected = true;
111130
}
112131
return record;
113132
}
133+
134+
private hasPayload(record: FleetRecord): boolean {
135+
return record.status !== "running" && !record.tombstoned;
136+
}
137+
138+
/**
139+
* Compacts the oldest already-collected payload to a tombstone first —
140+
* its caller already has the detail — and only reaches into uncollected
141+
* payloads once no collected one remains.
142+
*/
143+
private enforceCap(): void {
144+
let payloadCount = 0;
145+
for (const record of this.records.values()) {
146+
if (this.hasPayload(record)) payloadCount++;
147+
}
148+
while (payloadCount > MAX_FLEET_RECORDS) {
149+
const victim =
150+
[...this.records.values()].find((r) => this.hasPayload(r) && r.collected === true) ??
151+
[...this.records.values()].find((r) => this.hasPayload(r));
152+
if (victim === undefined) break;
153+
delete victim.report;
154+
delete victim.error;
155+
victim.tombstoned = true;
156+
victim.hint = RECOVERY_HINT;
157+
payloadCount--;
158+
}
159+
}
114160
}
115161

116162
// One registry per orchestrator install (shared by its spawn_agent and
@@ -286,22 +332,8 @@ function resolveDirectorDispatch(
286332
};
287333
}
288334

289-
/**
290-
* Director ids that write. Only "build" (the implement-intent director)
291-
* needs cwd exclusivity today; explore/plan/review/critique-style directors
292-
* do not write and may run concurrently against the same cwd.
293-
*/
294-
function isWriteRiskDirector(directorId: string): boolean {
295-
return directorId === "build";
296-
}
297-
298335
export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
299336
const telemetry = deps.telemetry ?? NOOP_TELEMETRY;
300-
// cwd -> agent ids of running write-risk (implement) workers against it.
301-
// deps.cwd is fixed for the lifetime of this tool instance (one per
302-
// orchestrator install), so this only ever guards concurrent spawns from
303-
// the same orchestrator turn, which is exactly the case with no isolation.
304-
const writeLanes = new Map<string, Set<string>>();
305337
return tool({
306338
definition: spawnAgentToolDefinition,
307339
handler: async (call, _signal): Promise<ToolResult> => {
@@ -341,20 +373,6 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
341373
const resolved = resolveDirectorDispatch(agentId, intent);
342374
if (!resolved.ok) return fleetResult(call.id, resolved.error);
343375

344-
const isWriteRisk = isWriteRiskDirector(resolved.directorId);
345-
if (isWriteRisk) {
346-
const lane = writeLanes.get(deps.cwd);
347-
if (lane !== undefined && lane.size > 0) {
348-
return fleetResult(
349-
call.id,
350-
`Error: spawn_agent refused — an implement-intent worker (${[...lane].join(", ")}) is ` +
351-
`already running against ${deps.cwd} and spawn_agent has no worktree isolation yet, so a ` +
352-
`second one would risk corrupting the first one's edits. Wait for it via wait_agents first, ` +
353-
`or use task(useWorktree: true) for isolated concurrent implementation work.`,
354-
);
355-
}
356-
}
357-
358376
let taskMaxTurns: number | undefined;
359377
if (rawMaxTurns !== undefined) {
360378
const verdict = validateTaskMaxTurns(rawMaxTurns);
@@ -396,14 +414,6 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
396414
brief,
397415
});
398416
deps.fleetRecords.register(session.id);
399-
if (isWriteRisk) {
400-
let lane = writeLanes.get(deps.cwd);
401-
if (lane === undefined) {
402-
lane = new Set();
403-
writeLanes.set(deps.cwd, lane);
404-
}
405-
lane.add(session.id);
406-
}
407417
const agentName = classifyAgentName(resolved.directorId);
408418
telemetry.capture("subagent_start", { agent_name: agentName });
409419
const startedAt = Date.now();
@@ -459,19 +469,14 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
459469
// fleetRecords is written before it so the synchronous subscribe
460470
// notification fired by complete()/fail() always sees the up-to-date
461471
// record.
462-
const releaseWriteLane = (): void => {
463-
if (isWriteRisk) writeLanes.get(deps.cwd)?.delete(session.id);
464-
};
465472
deps
466473
.run(params)
467474
.then((result) => {
468-
releaseWriteLane();
469475
if (childCtl.signal.aborted) return;
470476
deps.fleetRecords.resolve(session.id, result.report);
471477
deps.sessions.complete(session.id, result.report);
472478
})
473479
.catch((err) => {
474-
releaseWriteLane();
475480
if (childCtl.signal.aborted) return;
476481
const message = err instanceof Error ? err.message : String(err);
477482
deps.fleetRecords.reject(session.id, message);
@@ -578,6 +583,7 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
578583
status: taken.status,
579584
...(taken.report !== undefined ? { report: taken.report } : {}),
580585
...(taken.error !== undefined ? { error: taken.error } : {}),
586+
...(taken.hint !== undefined ? { hint: taken.hint } : {}),
581587
};
582588
});
583589

0 commit comments

Comments
 (0)