@@ -26,6 +26,8 @@ const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => {
2626 }
2727 const worker = {
2828 runs : new Map < string , RunFixture > ( ) ,
29+ /** The backoff each successful takeover persisted, in claim order, by run id. */
30+ claims : new Map < string , { attempts : number ; claimedAt : number ; notBefore : number } [ ] > ( ) ,
2931 unexpectedRequests : [ ] as { path : string ; reason : string } [ ] ,
3032 }
3133 const server = createServer ( async ( request , response ) => {
@@ -88,6 +90,19 @@ const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => {
8890} )
8991
9092vi . mock ( '@/lib/auth' , ( ) => authMock )
93+ vi . mock ( '@/lib/mothership/request/lifecycle/controller-ownership' , async ( importOriginal ) => {
94+ const actual =
95+ await importOriginal < typeof import ( '@/lib/mothership/request/lifecycle/controller-ownership' ) > ( )
96+ return {
97+ ...actual ,
98+ claimRunController : async ( ...args : Parameters < typeof actual . claimRunController > ) => {
99+ const claimed = await actual . claimRunController ( ...args )
100+ const [ { runId, recoveryBackoff } ] = args
101+ if ( claimed ) worker . claims . set ( runId , [ ...( worker . claims . get ( runId ) ?? [ ] ) , recoveryBackoff ] )
102+ return claimed
103+ } ,
104+ }
105+ } )
91106vi . mock ( '@/lib/mothership/request/session/buffer' , async ( importOriginal ) => {
92107 const actual = await importOriginal < typeof import ( '@/lib/mothership/request/session/buffer' ) > ( )
93108 const { getRedisClient } = await import ( '@/lib/core/config/redis' )
@@ -330,6 +345,7 @@ async function reconnect(
330345afterAll ( async ( ) => {
331346 await new Promise < void > ( ( resolve ) => worker . server . close ( ( ) => resolve ( ) ) )
332347 worker . runs . clear ( )
348+ worker . claims . clear ( )
333349 for ( const [ key , value ] of Object . entries ( inheritedEnv ) ) {
334350 if ( value === undefined ) delete process . env [ key ]
335351 else process . env [ key ] = value
@@ -428,9 +444,20 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
428444 const { turns, stops } = await reconnect ( run , { tails, windowMs : 60_000 } )
429445
430446 expect ( turns ) . toHaveLength ( MAX_RECOVERY_ATTEMPTS )
431- /** Each takeover waits at least the jittered floor of the one before it. */
432- turns . slice ( 1 ) . forEach ( ( turn , i ) => {
433- expect ( turn . at - turns [ i ] . at ) . toBeGreaterThanOrEqual ( 0.7 * 1_000 * 2 ** i )
447+ /**
448+ * Each takeover is planned no earlier than the jittered floor of the one before it,
449+ * read from the backoff each claim persisted. Worker request arrival would add the
450+ * variable work between a claim and its POST, which concurrent cases stretch.
451+ */
452+ const claims = worker . claims . get ( run . runId ) ?? [ ]
453+ expect ( claims . map ( ( claim ) => claim . attempts ) ) . toEqual (
454+ Array . from ( { length : MAX_RECOVERY_ATTEMPTS + 1 } , ( _ , i ) => i + 1 )
455+ )
456+ claims . slice ( 1 ) . forEach ( ( claim , i ) => {
457+ expect ( claim . claimedAt ) . toBeGreaterThanOrEqual ( claims [ i ] . notBefore )
458+ expect ( claims [ i ] . notBefore - claims [ i ] . claimedAt ) . toBeGreaterThanOrEqual (
459+ 0.8 * 1_000 * 2 ** i
460+ )
434461 } )
435462 expect ( stops ) . toHaveLength ( 1 )
436463 expect ( await storedRun ( run . runId ) ) . toMatchObject ( {
0 commit comments