11import { describe , expect , test } from "bun:test" ;
2+ import { type } from "arktype" ;
23
34import { createSubAgentSessionStore } from "../src/subagent/index.js" ;
5+ import { waitAgentsToolDefinition } from "../src/subagent/agent-fleet.js" ;
46import { withMockedModuleDuring } from "../testkit/mock-module.js" ;
57import {
68 fromHost ,
@@ -16,7 +18,15 @@ import {
1618 seedFile ,
1719} from "./harness.js" ;
1820
21+ const RequestURL = type ( { url : "string" } ) ;
22+
1923describe ( "e2e — recoverable worker failure does not stall the parent" , ( ) => {
24+ test . serial (
25+ "wait_agents documents the continuable successor contract" ,
26+ ( ) => {
27+ expect ( waitAgentsToolDefinition . description ) . toContain ( "continuable" ) ;
28+ } ,
29+ ) ;
2030 test . serial (
2131 "a worker that dies retryably after tool use reports failed+continuable" ,
2232 async ( ) => {
@@ -102,4 +112,124 @@ describe("e2e — recoverable worker failure does not stall the parent", () => {
102112 } ,
103113 60000 ,
104114 ) ;
115+ test . serial (
116+ "a failed+continuable lane lets the parent spawn a successor sibling that runs to done" ,
117+ async ( ) => {
118+ const fleetSessions = createSubAgentSessionStore ( ) ;
119+ const session = await openE2ESession ( {
120+ permissionGate : e2ePermissionGate ( ) ,
121+ subAgent : {
122+ provider : WORKER_PROVIDER ,
123+ sessions : fleetSessions ,
124+ // Abort the inner retry outright — the production backoff waits
125+ // on the injected scheduler, which is inert under the harness.
126+ outerRetryDelayMs : 0 ,
127+ retryPolicy : ( ) => ( { kind : "abort" } ) ,
128+ } ,
129+ mountWaitAgents : true ,
130+ toolAvailability : {
131+ languageServerAvailable : false ,
132+ waitAgentsMounted : true ,
133+ } ,
134+ } ) ;
135+ try {
136+ seedFile ( session , "marker.txt" , "the marker content\n" ) ;
137+ const parent = fromHost ( "api.anthropic.com" ) ;
138+ const worker = fromHost ( WORKER_HOST ) ;
139+
140+ session . harness . scenario . replyOnce ( "anthropic" , {
141+ predicate : parent ,
142+ text : "Dispatching a worker." ,
143+ toolCalls : [
144+ {
145+ name : "spawn_agent" ,
146+ args : {
147+ description : "read the marker" ,
148+ prompt : "Read marker.txt and report its contents." ,
149+ intent : "explore" ,
150+ } ,
151+ } ,
152+ ] ,
153+ } ) ;
154+ // The first lane runs one real tool, then its inference dies
155+ // retryably — failed+continuable, like the marker test above.
156+ session . harness . scenario . replyOnce ( "openai" , {
157+ predicate : worker ,
158+ toolCalls : [ { name : "read" , args : { path : "marker.txt" } } ] ,
159+ } ) ;
160+ session . harness . scenario . replyOnce ( "openai" , {
161+ predicate : worker ,
162+ text : "upstream unavailable" ,
163+ responseOpts : { status : 503 } ,
164+ } ) ;
165+ session . harness . scenario . replyOnce ( "anthropic" , {
166+ predicate : parent ,
167+ text : "Collecting the failed worker." ,
168+ toolCalls : [ { name : "wait_agents" , args : { timeout_ms : 30_000 } } ] ,
169+ } ) ;
170+ // One successor sibling with the same brief — the most the
171+ // continuable marker allows.
172+ session . harness . scenario . replyOnce ( "anthropic" , {
173+ predicate : parent ,
174+ text : "Spawning one successor." ,
175+ toolCalls : [
176+ {
177+ name : "spawn_agent" ,
178+ args : {
179+ description : "read the marker" ,
180+ prompt : "Read marker.txt and report its contents." ,
181+ intent : "explore" ,
182+ } ,
183+ } ,
184+ ] ,
185+ } ) ;
186+ // A leaf report must carry the full four-heading envelope —
187+ // partial replies trigger the salvage/nudge path instead of done.
188+ session . harness . scenario . replyOnce ( "openai" , {
189+ predicate : worker ,
190+ text : "## Summary\nThe marker holds the marker content.\n## Findings\nnone\n## Blockers\nnone\n## Paths\nmarker.txt" ,
191+ } ) ;
192+ session . harness . scenario . replyOnce ( "anthropic" , {
193+ predicate : parent ,
194+ text : "Collecting the successor." ,
195+ toolCalls : [ { name : "wait_agents" , args : { timeout_ms : 30_000 } } ] ,
196+ } ) ;
197+ session . harness . scenario . replyOnce ( "anthropic" , {
198+ predicate : parent ,
199+ text : "The successor recovered the lane." ,
200+ } ) ;
201+
202+ const { events, reply } = await withMockedModuleDuring (
203+ import . meta. resolve ( "../src/session/assemble-runtime.js" ) ,
204+ ( real : typeof import ( "../src/session/assemble-runtime.js" ) ) => ( {
205+ ...real ,
206+ assembleInferenceBase : async ( ) => session . harness . deps ,
207+ } ) ,
208+ ( ) => runUntilDone ( session , "Run the flaky job to recovery" ) ,
209+ ) ;
210+
211+ const [ failed , settled ] = waitAgentsResults ( events ) ;
212+ expect ( failed ?. timed_out ) . toBe ( false ) ;
213+ expect ( failed ?. results [ 0 ] ?. status ) . toBe ( "failed" ) ;
214+ expect ( failed ?. results [ 0 ] ?. continuable ) . toBe ( true ) ;
215+ expect ( failed ?. results [ 0 ] ?. continue_with ) . toBeString ( ) ;
216+ expect ( settled ?. timed_out ) . toBe ( false ) ;
217+ expect ( settled ?. results [ 0 ] ?. status ) . toBe ( "done" ) ;
218+ expect ( settled ?. results [ 0 ] ?. report ) . toContain ( "marker content" ) ;
219+ // A true sibling lane: a new agent, not a re-wait of the failed one.
220+ expect ( settled ?. results [ 0 ] ?. agent_id ) . toBeString ( ) ;
221+ expect ( settled ?. results [ 0 ] ?. agent_id ) . not . toBe (
222+ failed ?. results [ 0 ] ?. agent_id ,
223+ ) ;
224+ const workerRequests = session . harness . scenario
225+ . matchedRequests ( )
226+ . filter ( ( r ) => RequestURL . assert ( r ) . url . includes ( WORKER_HOST ) ) ;
227+ expect ( workerRequests ) . toHaveLength ( 3 ) ;
228+ expect ( reply ) . toContain ( "recovered the lane" ) ;
229+ } finally {
230+ await closeE2ESession ( session ) ;
231+ }
232+ } ,
233+ 60000 ,
234+ ) ;
105235} ) ;
0 commit comments