Skip to content

Commit 00cedfa

Browse files
authored
fix(mothership): claim under the chat lock in the Stop-versus-recovery settlement test (#8728)
* fix(mothership): claim under the chat lock in the Stop-versus-recovery settlement test The test raced Stop's settle against a recovering controller that claimed the run without holding the chat lock. Recovery always locks the chat, proves its lease, then claims, and settleStoppedRunWithoutController treats a run with no lock holder as unowned. So when the claim committed before Stop read the run, which a loaded runner does, Stop read the new controller token, found no lock holder and cancelled a run the test had just claimed: both sides reported success. With the lock held, the same ordering leaves the run to its controller. The recovering controller now locks the chat and proves its lease before claiming, as recover-stream does, and releases the lock when done. Both orderings are asserted deterministically before the concurrent loop, which keeps its exactly-once assertion. * fix(mothership): guard the recovering controller's lease instead of asserting it non-null * fix(mothership): guard the run's controller token in the recovery helper and shrink the non-null baseline
1 parent 48cbd4f commit 00cedfa

2 files changed

Lines changed: 45 additions & 8 deletions

File tree

‎apps/sim/lib/mothership/async-runs/orphaned-runs.integration.ts‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -565,23 +565,60 @@ describe.runIf(Boolean(redisUrl))('Chat runs no controller owns', () => {
565565
})
566566

567567
it('settles a stopped run exactly once when Stop races a recovering controller claiming it', async () => {
568+
/**
569+
* Claims the run as recovery does: lock the chat under the run's stream, prove the lease,
570+
* then claim, so the claimed run has a lock holder for as long as its controller lives.
571+
*/
572+
const recover = async (orphan: Awaited<ReturnType<typeof admittedRun>>) => {
573+
const previousToken = orphan.controllerToken
574+
if (!previousToken)
575+
throw new Error('Recovery needs the run it takes over to have a controller')
576+
if (!(await acquirePendingChatStream(orphan.chatId, orphan.streamId, 0))) return false
577+
const lease = getLocalChatStreamLease(orphan.chatId, orphan.streamId)
578+
if (!lease) throw new Error('Recovery acquired the chat lock without a local lease')
579+
await assertChatStreamLease(lease)
580+
const claimed = await claimRunController({
581+
runId: orphan.runId,
582+
chatId: orphan.chatId,
583+
previousToken,
584+
token: lease.value,
585+
recoveryBackoff: FIRST_RECOVERY,
586+
})
587+
if (!claimed) await releasePendingChatStream(orphan.chatId, orphan.streamId, lease)
588+
return claimed
589+
}
590+
/** Ends a recovered controller, as its stream finishing would. */
591+
const release = async (orphan: Awaited<ReturnType<typeof admittedRun>>) => {
592+
const lease = getLocalChatStreamLease(orphan.chatId, orphan.streamId)
593+
if (lease) await releasePendingChatStream(orphan.chatId, orphan.streamId, lease)
594+
}
595+
596+
/** A claim committed before Stop reads the run is the interleaving a loaded runner hits. */
597+
const claimedFirst = await admittedRun()
598+
await stop(claimedFirst)
599+
expect(await recover(claimedFirst)).toBe(true)
600+
expect(await settleStoppedRunWithoutController(claimedFirst.runId)).toBe(false)
601+
expect((await stored(claimedFirst.runId)).status).toBe('active')
602+
await release(claimedFirst)
603+
604+
const stoppedFirst = await admittedRun()
605+
await stop(stoppedFirst)
606+
expect(await settleStoppedRunWithoutController(stoppedFirst.runId)).toBe(true)
607+
expect(await recover(stoppedFirst)).toBe(false)
608+
expect((await stored(stoppedFirst.runId)).status).toBe('cancelled')
609+
568610
for (let attempt = 0; attempt < 50; attempt++) {
569611
const orphan = await admittedRun()
570612
await stop(orphan)
571613

572614
const [claimed, stopped] = await Promise.all([
573-
claimRunController({
574-
runId: orphan.runId,
575-
chatId: orphan.chatId,
576-
previousToken: orphan.controllerToken!,
577-
token: `${orphan.streamId}\n${generateId()}`,
578-
recoveryBackoff: FIRST_RECOVERY,
579-
}),
615+
recover(orphan),
580616
settleStoppedRunWithoutController(orphan.runId),
581617
])
582618

583619
expect(claimed !== stopped).toBe(true)
584620
expect((await stored(orphan.runId)).status).toBe(stopped ? 'cancelled' : 'active')
621+
await release(orphan)
585622
}
586623
})
587624

‎scripts/check-explicit-any.baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1592,7 +1592,7 @@
15921592
"apps/sim/lib/mothership/agent-cli/workbench-file-provenance.integration.ts": 4,
15931593
"apps/sim/lib/mothership/agent-cli/workbench-file-provenance.ts": 2,
15941594
"apps/sim/lib/mothership/assistant/tool-policy.test.ts": 2,
1595-
"apps/sim/lib/mothership/async-runs/orphaned-runs.integration.ts": 11,
1595+
"apps/sim/lib/mothership/async-runs/orphaned-runs.integration.ts": 10,
15961596
"apps/sim/lib/mothership/auth/internal.ts": 1,
15971597
"apps/sim/lib/mothership/billing/service-store.integration.ts": 3,
15981598
"apps/sim/lib/mothership/chat/application/context.ts": 1,

0 commit comments

Comments
 (0)