@@ -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