fix(mothership): settle chat runs no controller owns - #8457
Conversation
A run whose process died before finalize, whose controller was superseded with no successor, or that was stopped while no controller existed stayed unfinished forever: its chat marker kept pointing at it, so the chat read as busy and a reconnect polled a run nothing would ever end. - Stop now settles the run as cancelled once no controller of its stream holds the chat lock, including after it force-releases a controller that did not exit in time. - The stale-execution cron settles leased runs whose stream holds no chat lock, has no replay buffer left, and has been idle past the orchestration budget (so a reconnect has nothing left to resume), and runs without a lease once idle for 24 hours. A run Stop already closed settles as cancelled, any other as error; its chat marker is released. - Each settle is one conditional update on the run row that requires it to be unfinished, idle, and still naming the controller that was observed, so a finalizing controller or a successor's claim wins or loses against it atomically and the run settles exactly once.
…bel them precisely - Settling now locks the affected chat rows first, in id order, as a controller's claim does. Locking the run and then the chat deadlocked against a concurrent reconnect claim. - Each sweep batch fails on its own, the chat markers of a batch clear in one statement, and a sweep settles at most 5k rows with a short pause between full batches. - A run settles as cancelled only when its user pressed Stop. A newer turn also closes tool admission on older runs, and those now settle as errors. - Runs without a controller lease keep their last write as their completion and retention time and read "never finalized (no controller lease)". - The leased-run grace no longer derives from the orchestration deadline. Liveness comes only from the heartbeat-renewed chat lock; the grace and the replay TTL only bound how long a reconnect can resume a dead run.
A headless turn has no chat lease and no heartbeat, so its age says nothing about whether it is still running once runs have no deadline. The sweep's lease-less rule now applies only to runs admitted before the current tool-execution protocol: every run the current code admits records the current version, so after a deploy no such row can be live. A current headless run is left to its own lifecycle, which always settles it. The protocol version moves beside the other async-run constants so the sweep can read it without importing the repository.
…n settle - Settling a stopped run now passes the Stop-row check as the update's own guard, so it cannot cancel a run nobody stopped; the separate stopped branch is gone and every settle derives cancelled from the Stop row. - Chat lock ownership is read through getChatStreamLockOwners and trusted only when verified, instead of a second Redis read of the same keys. - Settle transactions use the shared DbTransaction type.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
There was a problem hiding this comment.
All reported issues were addressed across 7 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…sweeps where they stopped - The sweep takes each unowned leased run's chat lock under the run's own stream before settling it and releases it after the commit, so a reconnect can no longer lock the chat between the ownership check and the settle and then lose its claim; a reconnect that meets the fence retries. - A sweep examines at most 10k candidates and settles at most about 5k, resuming from a cursor saved in Redis and wrapping to the first run, so runs that cannot be settled yet never starve the ones after them. - Every settled run whose chat marker was released is announced, legacy runs included, so an open client stops showing the chat as busy.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
The Slack Assistant admits its own run row and only ever marked it as an error, so every completed or stopped Slack turn stayed active. It now records the terminal status once, after the turn ends, through the shared run-status update: complete on success, cancelled when its user stopped it (in Slack or in Sim), and error otherwise. Every other run-creating path already settles its run: interactive turns through their controller's finalize, and headless turns that admit their own run in the lifecycle's own finally.
There was a problem hiding this comment.
All reported issues were addressed across 7 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
- The Slack Assistant now writes its run's one terminal status after its outcome and response are persisted, from the final outcome, so a failed save ends the run as an error instead of complete. - A Stop lookup that fails no longer skips that write: the turn is treated as not stopped, logged, and settled as an error. - The orphaned-run suite deletes the sweep cursor before each test and in teardown, so no later suite starts from its leftover position.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Summary
A chat run could stay
activeorpaused_waiting_for_toolforever if it never reached a terminal status. That happened when its controller died (deploy, OOM), was superseded with no successor, or was stopped when no controller remained. Nothing ever settled these runs. Production had a large backlog of them, almost all from before the controller-lease protocol. Some chats kept pointing at a dead run, so they could show as busy.cleanup-stale-executionsnow sweeps runs that no controller owns:tool_execution_version< 2, no lease) are settled after 24 h idle. They keepupdated_at(so the retention clock isn't reset), getcompletedAt = updated_at(so they don't all land on one day), and carry their own filterable error text.Stopping a run that no controller holds now settles it as
cancelled, instead of leaving it active.cancelledrequires a recorded Stop, checked inside the same UPDATE, so a run superseded by a newer turn is never mislabelled.Every settle is atomic against the controller finalizing or a successor claiming the run: status and owner token are re-checked in one UPDATE. Chats are locked before runs, in the claim path's order, so the two can't deadlock.
Chat markers are cleared in one statement per batch. A cron run handles at most 5k rows, in batches of 500, with a short pause between batches.
Slack-search Assistant runs, which own their run row, never recorded success or a stop: only failures were written, so a successful run stayed
active. They now settlecomplete,cancelledorerroronce, through the shared run-status helper.This backlog is the only backfill; no separate one is needed. The sweep drains it gradually after deploy.
Testing
orphaned-runs.integration.tsruns against real PostgreSQL and Redis:slack-search/assistant.integration.ts(real PostgreSQL): completed, stopped and failed Slack-search turns settlecomplete,cancelledanderror.bun run lint,bun run type-check,bun run check:audits, unit tests for the cron route andlib/mothership/request, and the replay-budget integration suite all pass.