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
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
4342import { 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 */
8698class 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-
298335export 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