fix(lifecycle): durably deliver worker-idle completions to the orchestrator#2836
fix(lifecycle): durably deliver worker-idle completions to the orchestrator#2836ikeshavvarshney wants to merge 5 commits into
Conversation
…trator Recording that a worker finished and delivering that fact to the project orchestrator were fused into one best-effort attempt: if no orchestrator existed, it was busy/blocked, or the paste was suppressed, the completion was silently dropped and the orchestrator never learned of it (AgentWrapper#2820). Separate creation from delivery with a durable, at-least-once outbox: - New worker_idle_events table (migration 0024) storing project, worker, transition time, and delivery state, coalesced to one pending row per worker via a partial unique index. - The worker's active->idle transition and its outbox event are persisted atomically (RecordWorkerIdle), so a crash cannot save the idle state while losing the pending delivery. - A dispatcher resolves the current orchestrator at delivery time, applies an explicit contract (idle -> deliver; active -> deliver only for a harness that steers active turns; blocked/waiting/missing -> defer), routes through sessionguard, and marks delivered only when the guard reports a pane write was attempted. - Pending events are retried on event creation, the orchestrator entering idle, an orchestrator spawn/restore, daemon start, and a low-frequency recovery sweep. The nudge stays grounded, telling the orchestrator to run `ao session get <worker>` rather than embedding a stale status snapshot. Closes AgentWrapper#2820
|
Thanks for contributing to Agent Orchestrator. This PR is being picked up by the current external contributor on-call pair: If someone is already working on this, please continue as usual. For faster context or live questions, you can also join the AO Discord. Join the session here: Come by if you want to see what is being built, ask questions, or just hang around with the community. |
illegalcall
left a comment
There was a problem hiding this comment.
This is a strong improvement over #2823. The durable outbox, atomic worker-state/event persistence, coalescing, delivery to the current orchestrator, retained suppressed outcomes, and retry triggers are the right overall architecture. The focused race tests and all current CI checks pass.
I am requesting changes because two correctness gaps can still violate the delivery contract under normal operation.
Blocking correctness issues
-
The safe-delivery policy is not enforced at the final write boundary.
DispatchProjectcheckssafeToDeliverfrom aListSessionssnapshot, butsessionguard.Nudgerereads the session and only suppressesneeds-input. If a non-steering orchestrator changes from idle to active between those operations, the event is still written into an active turn. The same can happen when one delivered event activates the orchestrator while additional pending events remain in the loop.I reproduced this with a temporary test: the dispatcher observed an idle snapshot, the guard observed active Claude Code, and one nudge was still sent.
Please make the last-mile guard capability-aware and re-evaluate the current session state immediately before sending. For harnesses that cannot safely steer an active turn, active must remain suppressed at that boundary.
-
Concurrent dispatch can deliver the same pending event more than once.
There is no per-project serialization or atomic claim/lease around list-pending -> send -> mark-delivered. Startup, sweep, worker transition, orchestrator transition, spawn, and restore paths can overlap. Two dispatchers can read the same row and both send it before either marks it delivered.
I reproduced this with two concurrent
DispatchProjectcalls: the same pending event was sent twice.Please add either per-project dispatch serialization or an atomic outbox claim/lease protocol, plus a deterministic concurrency test. Crash-window redelivery may remain an accepted at-least-once tradeoff, but duplicate delivery during ordinary concurrent execution should not be.
Other required follow-ups
- Retry promptly on every transition into a deliverable state, not only
active -> idle. For example, a Codex orchestrator moving from blocked/waiting to active is safe to steer but currently may wait for the periodic sweep. - Reconsider
worker_idle_events.worker_id ... ON DELETE CASCADE. Deleting/cleaning a worker currently deletes its undelivered completion, which conflicts with the durable-delivery invariant unless source deletion explicitly makes the event obsolete. Document and test whichever semantic is intended. - Add the missing boundary tests:
- waiting-input and terminated orchestrator suppression;
- a real persistence/reopen restart test;
- restore-triggered dispatch;
- forced event insert/coalesce failure proving the worker state update rolls back atomically;
- the safe-state TOCTOU case and concurrent-dispatch duplicate case above.
- Describe the guarantee as at-least-once, not exactly-once, unless the design also closes the send-before-ack crash window.
Architecture and repository fit
HarnessSteersActiveTurnhardcodes Codex terminal behavior in the shared domain package. That capability belongs behind the agent adapter/port so lifecycle policy consumes an adapter-provided capability rather than knowing harness-specific semantics.WithActiveSteeringappears to be an unused one-off injection point. Please either exercise it through a real boundary/test or remove it.- The
kindcolumn is currently redundant in a table dedicated to worker-idle events and is not used by the domain/query path. Unless a concrete near-term consumer requires it, omitting it would keep this migration surgical. - A name such as
DispatchPendingWorkerIdleEventswould make the narrower behavior clearer than the genericDispatchProject.
Once the final-boundary safety check and normal-concurrency duplicate path are addressed, the core outbox approach looks sound.
…ialize dispatch Addresses review on the worker-idle outbox. Two correctness gaps could still violate the delivery contract under normal operation. The safe-delivery policy was checked against a ListSessions snapshot while sessionguard.Nudge only suppressed needs-input, so a non-steering orchestrator that went idle -> active between the check and the write still took the message mid-turn — including when one delivery activated the orchestrator while later events remained in the loop. Delivery now goes through sessionguard.NudgeCoordination, which re-reads the session and its harness at the write boundary and refuses an active turn unless the harness declares it can be steered (new SuppressedBusy outcome). The dispatch loop stops on the first non-Sent outcome and leaves the remainder pending. Dispatch had no serialization around list-pending -> send -> mark-delivered, so overlapping triggers (startup, sweep, worker/orchestrator transitions, spawn, restore) could read the same row and both send it. Delivery is now serialized per project. Also: - Retry on every transition into a deliverable state, not only active -> idle, so a steerable orchestrator leaving blocked/waiting delivers immediately instead of waiting for the sweep. - Move the active-turn steering capability out of the shared domain package to ports.ActiveTurnSteerer, declared by the codex adapter and resolved by the daemon, so lifecycle consumes an adapter-provided capability instead of harness-specific semantics. WithActiveSteering is now wired through that real boundary and defaults to "no harness is steerable". - Document worker_id ON DELETE CASCADE as intended: the event's only instruction is to inspect that worker, so deleting it makes an undelivered completion obsolete. - Describe the guarantee as at-least-once and explain the crash window. - Drop the unused kind column; rename DispatchProject to DispatchPendingWorkerIdleEvents. Tests: guard policy matrix; TOCTOU suppression with a stale idle snapshot; steerable harness still delivered mid-turn; concurrent dispatch delivering once; waiting/blocked/terminated retention; blocked -> active dispatch; restore -triggered dispatch; adapter-backed steering resolution; database reopen persistence; rollback on a failed event insert and on the coalesce path; and cascade-on-delete.
|
Thanks @illegalcall — both blocking issues were real, and the reproductions made them easy to pin down. Fixed in 1. Safe-delivery policy at the final write boundaryYou were right that the policy lived in the wrong place: the dispatcher checked a Delivery now goes through a new The snapshot check is now only an optimization. The dispatch loop also returns on the first non- 2. Concurrent duplicate deliveryDispatch is now serialized per project around the I chose serialization over a database claim/lease because the daemon is a single process. This is an explicit design choice: it does not protect two daemon processes sharing the same database. Happy to switch to an atomic claim if you'd rather have that guarantee. Other follow-ups
Architecture
|
illegalcall
left a comment
There was a problem hiding this comment.
Findings
dispatch: still has over-delivery edge cases and can send multiple nudges in the same cycle.dispatchcan attempt delivery before runtime safety is fully settled at startup/restore.- Please address these before merging.
…a settled orchestrator Two correctness gaps in the worker-idle dispatch remained. Over-delivery: pasting a nudge only moves the orchestrator out of idle asynchronously (its activity hook lands later), so the per-send guard re-read still saw idle and the loop could dump the whole backlog into one turn. Dispatch now delivers at most one pending event per cycle; the orchestrator's next entry into idle re-triggers it, draining one nudge per turn. The trigger is refined: entering idle drains the backlog, and a steerable harness resuming from a user pause (blocked/waiting -> active) delivers immediately, while ordinary idle -> active does not pull the backlog into a fresh turn. Premature delivery at startup/restore: a freshly restored orchestrator is seeded idle before its runtime is up. safeToDeliver now also requires a non-zero FirstSignalAt — an authentic activity signal proving the runtime settled — and delivery fires on that first signal. The premature spawn/restore dispatch in the session manager is removed in favour of this first-signal trigger, so nothing writes into an unready pane. Also: restore-all now treats a session reaped between listing and relaunch (ErrNotFound) as a non-fatal skip rather than aborting the batch. Tests: one-per-cycle delivery that drains on return-to-idle; an unsettled orchestrator retaining its events until the first authentic signal.
|
Thanks @illegalcall — both were real. Fixed in 4370ca4, and I validated the behavior against a running daemon (details below). Over-delivery. Root cause was timing: pasting a nudge only moves the orchestrator out of idle asynchronously (the activity hook lands later), so the per-send guard re-read still saw stale idle and the loop could dump the whole backlog into one turn. Dispatch now delivers at most one event per cycle; the orchestrator's next entry into idle re-triggers it, draining one nudge per turn. The trigger is refined so entering idle drains the backlog and a steerable harness resuming from a pause (blocked/waiting→active) delivers immediately, while ordinary idle→active does not pull the backlog into a fresh turn. Delivery before runtime settled. safeToDeliver now also requires a non-zero FirstSignalAt — an authentic activity signal proving the runtime is up — and delivery fires on that first signal. I removed the premature spawn/restore dispatch from the session manager in favour of this first-signal trigger, so nothing writes into an unready pane. getRecord in restore. RestoreAll now treats a session reaped between listing and relaunch (ErrNotFound) as a non-fatal skip. loadProject fail-fast — I did not apply this. Making the shared helper error on a missing project broke ~20 valid spawn/restore paths, and the motivation (orchestrator-dispatch correctness) is moot now that dispatch lives entirely in lifecycle (which resolves the project/orchestrator itself) rather than the session manager. Happy to add a narrow fail-fast if you still want it, but it seemed out of scope for the fix. Validation. Beyond unit/integration tests, I ran the actual daemon and drove the real POST …/activity path (the same endpoint the agent hook posts): a worker completion stayed pending while the orchestrator was unsettled and delivered once it got its first signal; and with two pending events, one idle cycle delivered exactly one (pending 2→1→0). -race/golangci-lint are on CI as before. |
illegalcall
left a comment
There was a problem hiding this comment.
Reviewed the outbox path end to end. Atomic persistence, fail-closed guard, per-project delivery serialization, and the grounded nudge all hold up — FK ON DELETE CASCADE is real (foreign_keys(ON) is set in db.go), and the new SuppressedBusy outcome is enum-complete (every consumer checks == Sent). One merge blocker on the migration version, plus two minor notes inline.
… dispatch Address review before merge. - Rename 0024_worker_idle_outbox.sql to 0025: main now ships 0024_session_rename_cdc.sql, so keeping 0024 here would give two version-24 migrations after merge and fail TestMigrationVersionsAreUnique. Content is unchanged; goose orders by the numeric prefix. - Run the boot-time pending redelivery in a goroutine so it is off the critical boot path (a store read plus a possible pane write per pending project); the recovery sweep backstops it.
|
Thanks @illegalcall for the thorough end-to-end review — glad the outbox path, fail-closed guard, delivery serialization, and cascade all held up. Addressed in
Rebasing onto |
illegalcall
left a comment
There was a problem hiding this comment.
CI lint (golangci-lint / errcheck) fails on the one spot below — .golangci.yml sets errcheck.check-type-assertions: true, so the single-value assertion lock.(*sync.Mutex) is flagged (it discards ok and would panic on a bad assertion). Two-value form fixes it. Only lint failed; build-test/race/format all pass.
…atch lock errcheck (check-type-assertions) flags the single-value lock.(*sync.Mutex): it discards ok and would panic on a bad assertion. The map only ever stores *sync.Mutex, so behavior is unchanged; the two-value form makes the discard explicit and satisfies lint.
|
I've pushed a follow-up commit ( The latest CI run is currently waiting for maintainer approval because this is my first contribution from a fork. Could you approve the workflow so the lint check can run on the updated commit? |
Wake the orchestrator when a worker finishes — durably, exactly-once, without interrupting it
Closes #2820
Supersedes my earlier PR (#2823 ).
Problem
When the orchestrator spawns a worker, it was never told when that worker finished. Nothing reacted to a worker's active → idle transition, so a completed worker sat silent until the human manually asked the orchestrator to poll (#2820).
Approach
active → idle is the completion signal, and waking the project orchestrator is preferable to polling every worker. The key design point from review: recording that a worker finished and delivering that fact to the orchestrator are two separate responsibilities. They are now decoupled behind a durable, at-least-once outbox.
Invariant: once AO accepts a worker-completion signal, it stays pending until AO successfully attempts delivery to a safe orchestrator. Duplicates after a crash are acceptable; silent loss is not.
What changed
worker_id WHERE pendingcoalesces repeated completions to one pending row.sessionguard.Nudge, and marks an event delivered only when the guard reports a pane write was attempted — suppressed outcomes stay pending.ao session get <worker>, embedding no status snapshot that could be stale by delivery time.Explicit delivery contract
This reconciles the two review threads (one asked for "idle only"; the other correctly noted that active delivery is safe for harnesses like Codex where Enter steers the current turn, and that "idle only without retention" would itself create a lost-notification path):
Active-turn steering is an explicit harness capability (
HarnessSteersActiveTurn), defaulting conservatively tofalsefor any unknown harness.Addressing Reviewer @Vaibhaav-Tiwari (active-orchestrator behavior)
The contract above makes the description and implementation agree: an active orchestrator is only nudged when its harness can safely steer; otherwise the completion is retained, not dropped.
Added a test: a worker goes active → idle while the orchestrator is active (non-steering) and no nudge is sent — the event is retained and delivered later.
Addressing Reviewer @illegalcall (durable at-least-once delivery)
worker_idleevent — ✅sessionguard.Nudge; mark delivered only on an attempted pane write; suppressed stays pending — ✅ao session get <worker>, no stale snapshot) — ✅Tests
DispatchAllPendingdelivers then does not re-deliver.Verification
go build,go vet,gofmt, and all touched-package tests pass locally.I could not run
golangci-lintorgo test -racein my environment (toolchain limitation), so CI is the first full lint/race pass — happy to fix anything it surfaces.cc @Vaibhaav-Tiwari @illegalcall