Repository navigation
F1: apply a stop only after the caller's transaction commits #79
Description
Found by Copilot's review of #74 (comment). Deferred from that PR because the fix needs a Store change.
Problem
RunnerCoordinator.stop and cancelTask (runner/coordinator.ts) can run inside Store.userAction. Cancel, retry and "run again" requests go through userAction so a replayed actionId returns the saved response (see docs/implementation/runner-lifecycle.md, "User actions", and regressions "Lost retry response, then replay" and "Refused action, then replay").
A stop has two effects:
- a database write: the first reason, and
cancel_requestedfor cancel task; - an in-memory effect that can't be undone: the job's first reason is set, preparation is aborted, and D's handle is cancelled.
The coordinator applies the in-memory effect at once, in the same synchronous turn as the write. It does not wait for the caller's transaction to commit.
What goes wrong
- An attempt is running.
- A cancel task runs inside
userAction.cancelTaskreturns'stopping', the job's first reason becomescancelled, andhandle.cancel('cancelled')is called. - The action then throws, or the COMMIT fails, so the transaction rolls back. The row has no first reason, and the task has no pending cancel.
- D stops the agent anyway, because the cancel already reached it.
- The terminal write passes the job's in-memory
cancelled. The Store uses it, because the row has no reason of its own, so the attempt endscancelled.
The user's cancel never committed, but the attempt was stopped and recorded as cancelled, and the task stays open. #confirmSaved only marks the reason as unsaved. It does not undo the stop.
A stop outside a transaction (timers, close(), a plain stop()) is not affected.
What to change
Apply a stop's in-memory effect only after the write that records it has committed:
- Add a way for the Store to run a callback after the outermost transaction commits, and to drop it on rollback. For example:
store.afterCommit(fn), which runsfnat once when no transaction is open. - Have
#requestStopandcancelTaskset the first reason, abort preparation and cancel D's handle inside that callback. - Keep the current behaviour when the write itself fails with a storage error. The in-memory reason is then the source of truth ("Rules for the running state", rule 1), and D must still stop.
- Check the order against the first-reason rules: the first stop in memory still wins, even while its commit is pending.
Tests to add
- A running attempt, then a cancel task inside
userActionthat rolls back. Expect: D's handle is not cancelled, the attempt can still complete, and the task stays open. - The same with a plain
stop()insideuserAction. - A committed cancel task inside
userActioncancels D only after the commit.
Activity
F1: apply a stop only after the caller's transaction commits (#79) (#96)