@@ -10,45 +10,68 @@ import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/
1010import { authMock , authMockFns } from '@sim/testing/mocks/auth.mock'
1111import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
1212
13- const { redisUrl, inheritedEnv, worker, faults } = await vi . hoisted ( async ( ) => {
13+ const { redisUrl, inheritedEnv, worker } = await vi . hoisted ( async ( ) => {
1414 const { readTestRedisUrl } = await import ( '@sim/db/testing/test-infrastructure' )
15+ const { toRecord } = await import ( '@sim/utils/object' )
1516 const { createServer } = await import ( 'node:http' )
16- const faults = {
17- /** Which leased appends fail, and how; `undefined` lets every append through. */
18- append : undefined as
19- | undefined
20- | {
21- frames : 'any_tool' | 'tool_result'
22- effect : 'throw' | 'throw_once' | 'lose_lease'
23- } ,
24- /** Fails a recovering controller's read of the whole replay ring. */
25- recoveryRead : false ,
17+ interface RunFixture {
18+ mode : 'frames' | 'json500' | 'drop'
19+ frames : unknown [ ]
20+ posts : { path : string ; at : number } [ ]
21+ append ?: {
22+ frames : 'any_tool' | 'tool_result'
23+ effect : 'throw' | 'throw_once' | 'lose_lease'
24+ }
25+ recoveryRead : boolean
2626 }
2727 const worker = {
28- posts : [ ] as Array < { path : string ; at : number } > ,
29- mode : 'frames' as 'frames' | 'json500' | 'drop' ,
30- frames : [ ] as unknown [ ] ,
28+ runs : new Map < string , RunFixture > ( ) ,
29+ unexpectedRequests : [ ] as { path : string ; reason : string } [ ] ,
3130 }
3231 const server = createServer ( async ( request , response ) => {
33- await new Promise ( ( resolve ) => request . on ( 'end' , resolve ) . resume ( ) )
34- worker . posts . push ( { path : request . url ?? '' , at : Date . now ( ) } )
32+ let body : Record < string , unknown >
33+ try {
34+ let raw = ''
35+ request . setEncoding ( 'utf8' )
36+ for await ( const chunk of request ) raw += chunk
37+ body = toRecord ( JSON . parse ( raw ) )
38+ } catch {
39+ worker . unexpectedRequests . push ( { path : request . url ?? '' , reason : 'malformed_body' } )
40+ response . writeHead ( 404 , { 'content-type' : 'application/json' } )
41+ response . end ( JSON . stringify ( { error : 'Run not found' } ) )
42+ return
43+ }
44+ const fixture = typeof body . messageId === 'string' ? worker . runs . get ( body . messageId ) : undefined
45+ if (
46+ ! fixture ||
47+ ( request . url !== '/api/mothership' && request . url !== '/api/streams/explicit-abort' )
48+ ) {
49+ worker . unexpectedRequests . push ( {
50+ path : request . url ?? '' ,
51+ reason : fixture ? 'unknown_path' : 'unknown_stream' ,
52+ } )
53+ response . writeHead ( 404 , { 'content-type' : 'application/json' } )
54+ response . end ( JSON . stringify ( { error : 'Run not found' } ) )
55+ return
56+ }
57+ fixture . posts . push ( { path : request . url , at : Date . now ( ) } )
3558 if ( request . url === '/api/streams/explicit-abort' ) {
3659 response . writeHead ( 200 , { 'content-type' : 'application/json' } )
3760 response . end ( JSON . stringify ( { settled : true } ) )
3861 return
3962 }
40- if ( worker . mode === 'json500' ) {
63+ if ( fixture . mode === 'json500' ) {
4164 response . writeHead ( 500 , { 'content-type' : 'application/json' } )
4265 response . end ( JSON . stringify ( { error : 'internal' } ) )
4366 return
4467 }
4568 response . writeHead ( 200 , { 'content-type' : 'text/event-stream' } )
46- if ( worker . mode === 'drop' ) {
47- response . write ( `data: ${ JSON . stringify ( worker . frames [ 0 ] ) } \n\n` )
69+ if ( fixture . mode === 'drop' ) {
70+ response . write ( `data: ${ JSON . stringify ( fixture . frames [ 0 ] ) } \n\n` )
4871 setTimeout ( ( ) => response . socket ?. destroy ( ) , 20 )
4972 return
5073 }
51- for ( const frame of worker . frames ) response . write ( `data: ${ JSON . stringify ( frame ) } \n\n` )
74+ for ( const frame of fixture . frames ) response . write ( `data: ${ JSON . stringify ( frame ) } \n\n` )
5275 response . end ( 'data: [DONE]\n\n' )
5376 } )
5477 await new Promise < void > ( ( resolve ) => server . listen ( 0 , '127.0.0.1' , resolve ) )
@@ -61,7 +84,7 @@ const { redisUrl, inheritedEnv, worker, faults } = await vi.hoisted(async () =>
6184 /** The real Redis module and worker URL resolution read these at import. */
6285 process . env . REDIS_URL = url
6386 process . env . SIM_AGENT_API_URL = `http://127.0.0.1:${ port } `
64- return { redisUrl : url , inheritedEnv, worker : Object . assign ( worker , { server } ) , faults }
87+ return { redisUrl : url , inheritedEnv, worker : Object . assign ( worker , { server } ) }
6588} )
6689
6790vi . mock ( '@/lib/auth' , ( ) => authMock )
@@ -71,8 +94,15 @@ vi.mock('@/lib/mothership/request/session/buffer', async (importOriginal) => {
7194 return {
7295 ...actual ,
7396 appendEvents : async ( ...args : Parameters < typeof actual . appendEvents > ) => {
74- const [ envelopes , , lease ] = args
75- const fault = faults . append
97+ const [ envelopes , scope , lease ] = args
98+ if ( envelopes . length === 0 ) return actual . appendEvents ( ...args )
99+ const streamId = scope ?. streamId ?? envelopes [ 0 ] ?. stream . streamId
100+ const fixture = streamId ? worker . runs . get ( streamId ) : undefined
101+ if ( ! fixture ) {
102+ worker . unexpectedRequests . push ( { path : 'appendEvents' , reason : 'unknown_append_stream' } )
103+ throw new Error ( 'Append has no registered recovery fixture' )
104+ }
105+ const fault = fixture . append
76106 const hit =
77107 lease &&
78108 fault &&
@@ -85,16 +115,21 @@ vi.mock('@/lib/mothership/request/session/buffer', async (importOriginal) => {
85115 if ( hit && fault . effect === 'throw' ) throw new Error ( 'simulated Redis write failure' )
86116 /** A Redis blip that outlasts the append retries, after which Redis is healthy again. */
87117 if ( hit && fault . effect === 'throw_once' ) {
88- faults . append = undefined
118+ fixture . append = undefined
89119 throw new Error ( 'simulated transient Redis write failure' )
90120 }
91121 /** The lock expires under a live controller, and nobody else holds it. */
92122 if ( hit && fault . effect === 'lose_lease' ) await getRedisClient ( ) ! . del ( lease . key )
93123 return actual . appendEvents ( ...args )
94124 } ,
95125 readEvents : async ( ...args : Parameters < typeof actual . readEvents > ) => {
96- const [ , afterCursor ] = args
97- if ( faults . recoveryRead && afterCursor === '0' )
126+ const [ streamId , afterCursor ] = args
127+ const fixture = worker . runs . get ( streamId )
128+ if ( ! fixture ) {
129+ worker . unexpectedRequests . push ( { path : 'readEvents' , reason : 'unknown_read_stream' } )
130+ throw new Error ( 'Read has no registered recovery fixture' )
131+ }
132+ if ( fixture . recoveryRead && afterCursor === '0' )
98133 throw new Error ( 'simulated Redis read failure' )
99134 return actual . readEvents ( ...args )
100135 } ,
@@ -141,6 +176,7 @@ async function orphanedRun(
141176 options : {
142177 status ?: 'active' | 'paused_waiting_for_tool'
143178 recoveryBackoff ?: Record < string , number >
179+ mode ?: 'frames' | 'json500' | 'drop'
144180 } = { }
145181) {
146182 const chatId = generateId ( )
@@ -180,6 +216,13 @@ async function orphanedRun(
180216 ...( options . recoveryBackoff ? { recoveryBackoff : options . recoveryBackoff } : { } ) ,
181217 } ,
182218 } )
219+ const fixture : NonNullable < ReturnType < typeof worker . runs . get > > = {
220+ mode : options . mode ?? 'frames' ,
221+ frames : [ ] ,
222+ posts : [ ] ,
223+ recoveryRead : false ,
224+ }
225+ worker . runs . set ( streamId , fixture )
183226 await appendEvents (
184227 [ 1 , 2 ] . map ( ( seq ) =>
185228 createEvent ( {
@@ -201,7 +244,7 @@ async function orphanedRun(
201244 stream : { streamId, chatId } ,
202245 payload,
203246 } )
204- worker . frames = [
247+ fixture . frames = [
205248 frame ( 1 , 'tool' , {
206249 phase : 'call' ,
207250 toolCallId : 'go-call' ,
@@ -221,7 +264,7 @@ async function orphanedRun(
221264 } ) ,
222265 frame ( 3 , 'complete' , { status : 'complete' , textLength : 14 } ) ,
223266 ]
224- return { chatId, streamId, runId }
267+ return { chatId, streamId, runId, fixture }
225268}
226269
227270async function storedRun ( runId : string ) {
@@ -245,8 +288,12 @@ async function storedRun(runId: string) {
245288 * lock released, or `windowMs` passes, and returns the worker requests made meanwhile.
246289 */
247290async function reconnect (
248- { chatId, streamId, runId } : { chatId : string ; streamId : string ; runId : string } ,
249- { tails = 1 , windowMs } : { tails ?: number ; windowMs : number }
291+ { chatId, streamId, runId, fixture } : Awaited < ReturnType < typeof orphanedRun > > ,
292+ {
293+ tails = 1 ,
294+ windowMs,
295+ waitForLeaseRelease = true ,
296+ } : { tails ?: number ; windowMs : number ; waitForLeaseRelease ?: boolean }
250297) {
251298 const startedAt = Date . now ( )
252299 const controllers = Array . from ( { length : tails } , ( ) => new AbortController ( ) )
@@ -270,8 +317,9 @@ async function reconnect(
270317 }
271318 for ( const controller of controllers ) controller . abort ( )
272319 await Promise . allSettled ( drains )
273- for ( let i = 0 ; i < 50 && ( await redis ( ) . get ( chatStreamLockKey ( chatId ) ) ) ; i ++ ) await sleep ( 100 )
274- const requests = worker . posts . filter ( ( post ) => post . at >= startedAt )
320+ if ( waitForLeaseRelease )
321+ for ( let i = 0 ; i < 50 && ( await redis ( ) . get ( chatStreamLockKey ( chatId ) ) ) ; i ++ ) await sleep ( 100 )
322+ const requests = fixture . posts . filter ( ( post ) => post . at >= startedAt )
275323 return {
276324 startedAt,
277325 turns : requests . filter ( ( post ) => post . path === '/api/mothership' ) ,
@@ -281,10 +329,12 @@ async function reconnect(
281329
282330afterAll ( async ( ) => {
283331 await new Promise < void > ( ( resolve ) => worker . server . close ( ( ) => resolve ( ) ) )
332+ worker . runs . clear ( )
284333 for ( const [ key , value ] of Object . entries ( inheritedEnv ) ) {
285334 if ( value === undefined ) delete process . env [ key ]
286335 else process . env [ key ] = value
287336 }
337+ expect ( worker . unexpectedRequests ) . toEqual ( [ ] )
288338} )
289339
290340describe . runIf ( Boolean ( redisUrl ) ) ( 'reconnecting to an orphaned Chat run' , ( ) => {
@@ -332,7 +382,6 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
332382 it . each ( [ 'active' , 'paused_waiting_for_tool' ] as const ) (
333383 'takes over an orphaned %s run at once and completes it' ,
334384 async ( status ) => {
335- worker . mode = 'frames'
336385 const run = await orphanedRun ( { status } )
337386
338387 const { startedAt, turns } = await reconnect ( run , { windowMs : 10_000 } )
@@ -347,9 +396,8 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
347396 )
348397
349398 it ( 'hands off a turn whose append fails once, and the next controller completes it' , async ( ) => {
350- worker . mode = 'frames'
351399 const run = await orphanedRun ( )
352- faults . append = { frames : 'any_tool' , effect : 'throw_once' }
400+ run . fixture . append = { frames : 'any_tool' , effect : 'throw_once' }
353401 try {
354402 const { turns, stops } = await reconnect ( run , { windowMs : 15_000 } )
355403
@@ -360,22 +408,22 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
360408 recoveryBackoff : { attempts : 2 } ,
361409 } )
362410 } finally {
363- faults . append = undefined
411+ run . fixture . append = undefined
364412 }
365413 } , 60_000 )
366414
367- it . each ( [
415+ it . concurrent . for ( [
368416 { frames : 'any_tool' , effect : 'throw' , tails : 1 } ,
369417 { frames : 'any_tool' , effect : 'throw' , tails : 3 } ,
370418 { frames : 'tool_result' , effect : 'throw' , tails : 1 } ,
371419 { frames : 'tool_result' , effect : 'throw' , tails : 3 } ,
372420 { frames : 'any_tool' , effect : 'lose_lease' , tails : 3 } ,
373421 ] as const ) (
374422 'gives up on a run whose recovered controllers keep failing ($effect on $frames, $tails tails)' ,
375- async ( { frames , effect , tails } ) => {
376- worker . mode = 'frames'
423+ { timeout : 120_000 } ,
424+ async ( { frames , effect , tails } , { expect } ) => {
377425 const run = await orphanedRun ( )
378- faults . append = { frames, effect }
426+ run . fixture . append = { frames, effect }
379427 try {
380428 const { turns, stops } = await reconnect ( run , { tails, windowMs : 60_000 } )
381429
@@ -391,14 +439,12 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
391439 recoveryBackoff : { attempts : MAX_RECOVERY_ATTEMPTS + 1 } ,
392440 } )
393441 } finally {
394- faults . append = undefined
442+ run . fixture . append = undefined
395443 }
396- } ,
397- 120_000
444+ }
398445 )
399446
400447 it ( 'waits out the backoff of a recent takeover, then takes over and completes the run' , async ( ) => {
401- worker . mode = 'frames'
402448 const now = Date . now ( )
403449 const notBefore = now + 3_000
404450 const run = await orphanedRun ( {
@@ -415,7 +461,6 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
415461 } , 30_000 )
416462
417463 it ( 'starts a fresh budget once the previous takeover is older than the reset window' , async ( ) => {
418- worker . mode = 'frames'
419464 const claimedAt = Date . now ( ) - RECOVERY_BUDGET_RESET_MS - 1_000
420465 const run = await orphanedRun ( {
421466 recoveryBackoff : { attempts : MAX_RECOVERY_ATTEMPTS , claimedAt, notBefore : claimedAt } ,
@@ -431,14 +476,13 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
431476 } , 30_000 )
432477
433478 it ( 'leaves the run untouched when a takeover fails before it starts, then ends it once exhausted' , async ( ) => {
434- worker . mode = 'frames'
435479 const claimedAt = Date . now ( ) - 1_000
436480 const recoveryBackoff = { attempts : MAX_RECOVERY_ATTEMPTS , claimedAt, notBefore : claimedAt }
437481 const run = await orphanedRun ( { recoveryBackoff } )
438482 const { controllerToken } = ( await storedRun ( run . runId ) ) . requestContext as {
439483 controllerToken : string
440484 }
441- faults . recoveryRead = true
485+ run . fixture . recoveryRead = true
442486 try {
443487 const { turns } = await reconnect ( run , { windowMs : 2_000 } )
444488
@@ -449,7 +493,7 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
449493 recoveryBackoff,
450494 } )
451495 } finally {
452- faults . recoveryRead = false
496+ run . fixture . recoveryRead = false
453497 }
454498
455499 const { turns, stops } = await reconnect ( run , { windowMs : 10_000 } )
@@ -464,17 +508,21 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
464508 } , 30_000 )
465509
466510 it ( 'never takes over a parked run whose controller still holds the chat lock' , async ( ) => {
467- worker . mode = 'frames'
468511 const run = await orphanedRun ( { status : 'paused_waiting_for_tool' } )
469512 const liveController = `${ run . streamId } \n${ generateId ( ) } `
470513 await redis ( ) . set ( chatStreamLockKey ( run . chatId ) , liveController , 'EX' , 60 )
471514 try {
472- const { turns } = await reconnect ( run , { tails : 3 , windowMs : 3_000 } )
515+ const { turns } = await reconnect ( run , {
516+ tails : 3 ,
517+ windowMs : 3_000 ,
518+ waitForLeaseRelease : false ,
519+ } )
473520
474521 expect ( turns ) . toHaveLength ( 0 )
475522 const stored = await storedRun ( run . runId )
476523 expect ( stored . status ) . toBe ( 'paused_waiting_for_tool' )
477524 expect ( stored . recoveryBackoff ) . toBeUndefined ( )
525+ expect ( await redis ( ) . get ( chatStreamLockKey ( run . chatId ) ) ) . toBe ( liveController )
478526 } finally {
479527 await redis ( ) . del ( chatStreamLockKey ( run . chatId ) )
480528 }
@@ -483,16 +531,11 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
483531 it . each ( [ 'json500' , 'drop' ] as const ) (
484532 'ends the run after its bounded retries when the worker fails every leg (%s)' ,
485533 async ( mode ) => {
486- worker . mode = mode
487- const run = await orphanedRun ( )
488- try {
489- const { turns } = await reconnect ( run , { windowMs : 20_000 } )
534+ const run = await orphanedRun ( { mode } )
535+ const { turns } = await reconnect ( run , { windowMs : 20_000 } )
490536
491- expect ( turns ) . toHaveLength ( 4 )
492- expect ( ( await storedRun ( run . runId ) ) . status ) . toBe ( 'error' )
493- } finally {
494- worker . mode = 'frames'
495- }
537+ expect ( turns ) . toHaveLength ( 4 )
538+ expect ( ( await storedRun ( run . runId ) ) . status ) . toBe ( 'error' )
496539 } ,
497540 60_000
498541 )
0 commit comments