Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
63 KiB
status, created, updated, owner
| status | created | updated | owner |
|---|---|---|---|
| draft | 2026-09-02 | 2026-09-03 | Kandev |
Workflow on_agent_error Dispatch for Non-Office Tasks
Decisions:
- ADR-0004 task-model-unification — establishes the
workflow engine as "the universal coordinator for task-scoped agent runs", declares
on_agent_erroras one of the seven Phase 2 triggers, and specifies in §7 that it "fires on the failing task at its current step".
Related spec (same defect class, different trigger):
docs/specs/workflow-on-enter-action-dispatch/spec.md—on_enterdeclared, compiled, only partly dispatched. Its AC-A6 (warn rather than silently discard) is reused here; see Action vocabulary.
Why
on_agent_error is the workflow trigger that recovers from an agent session failure. Every layer
around it works. The dispatch does not.
Verified 2026-09-02 by grep -rn TriggerOnAgentError --include=*.go over apps/backend, minus
_test.go. The only production fire site is internal/office/service/event_subscribers.go:677
(dispatchAgentErrorTrigger), gated on an Office *models.Run resolved by resolveLifecycleRun
(event_subscribers.go:162), which returns sql.ErrNoRows for a task with no claimed Office run.
internal/orchestrator and internal/workflow contain zero fire sites. The remaining non-test hits
are the constant, the payload type, the trigger-map entry and the Office dispatcher's
session-resolution branches — telling those apart from a fire site is the regression guard's whole
problem; see Verification.
Everything else round-trips: the YAML loader parses it (config/workflows/loader.go:302), the engine
compiles it (engine/types.go:299), it persists (workflow/models/models.go:158), exports through
git-sync (export.go:275), and appears over the API (task/dto/dto.go:1099). And no path except
embedded YAML validates the action type at all. So a kanban step can declare on_agent_error, have
it validate, compile, survive an export/import cycle — and silently never run. An inert recovery path
reads as configured, so nobody looks for the missing safety net.
Current exposure, measured
Queried against the local install's SQLite database on 2026-09-02: exactly one step row declares
on_agent_error, Office Default / Work (queue_run → workspace.ceo_agent). Fix / Chore and
New Feature Dev are real kanban workflows there and neither declares it; no shipped template
declares it outside config/workflows/office-default.yml:53. So this is a latent trap, not an
active regression: the first operator to configure on_agent_error on a kanban board gets a clean
bill of health for a step that will do nothing. The ACs are therefore written against the mechanism,
not against a board someone has to edit first.
Prior art and dispatch decision
Both live in the companion verification.md: the prior-art receipts, and the
reasoning that put the fire site in handleRecoverableFailureLockedState rather than any other
candidate home. Provenance, not contract.
What
Scope
A terminal agent-session failure is a failure that reaches handleRecoverableFailureLockedState
with a non-empty session id. Exactly six production routes do so, and the dispatch must be
correct on all six:
| # | Route | Site | Dispatches? |
|---|---|---|---|
| R1 | bus-driven terminal failure | handleAgentFailedLocked (event_handlers_agent.go:1411) |
yes |
| R2 | managed-npm-runtime start failure | handleAgentStartFailed (:2088) |
yes |
| R3 | auth-error start failure | handleAgentStartFailed (:2105) |
yes |
| R4 | transient retry, no cached prompt | retryTransientPrompt (event_handlers_transient.go:269) |
yes |
| R5 | transient retry, prompt failed synchronously | retryTransientPrompt (:317) |
yes |
| R6 | user cancelled the retry loop | CancelTransientRetry (:565) |
no — AC-A8 |
R4 and R5 are the transient loop giving up: the agent genuinely failed and no retry remains, so they
are terminal and fire. R6 is a human pressing Cancel on the recovery banner, and a recovery trigger
must not move a card out from under the person who just asked the retries to stop. R6 reaches the
handler through the same handleRecoverableFailure wrapper as R4/R5 and is otherwise
indistinguishable from them — which is why AC-A8 requires an explicit marker, not inference.
A transient retry still in flight, a dynamic-route fallback, a dropped rotated execution, a dropped terminal-session event, and a cancellation-in-flight deferral all return before this function and are not terminal failures.
A non-Office task is one for which models.Task.IsFromOffice is false — the
IsFromOfficePredicate projection (internal/task/repository/sqlite/task.go:138), computed at read
time as project_id != '' OR workflow_id == workspace.office_workflow_id, the same predicate
writeTaskReviewState and isOfficeTask use. No second notion of Office ownership is introduced.
Session-backed only. Engine.handleTrigger errors unless both TaskID and SessionID are
non-empty (engine.go:249), and handleRecoverableFailureLockedState is also reachable with an empty
session id (the wrapper's data.SessionID == "" branch). That call cannot dispatch — named
exclusion, see AC-A5.
Execution model
Position: last. The dispatch is the final action of handleRecoverableFailureLockedState — after the
session-state write, after writeTaskReviewState, after the pending step-completion reconciliation,
and after the go s.cleanupAgentExecution(...) launch that currently ends the function. That
goroutine runs concurrently, touches only agent-execution teardown, and the dispatch must neither
wait on it nor assume it finished. Two reasons, and the second decides it. First, it cannot mask or
race the failure bookkeeping already committed — Office's dispatchAgentErrorTrigger states the same
rationale for its own position. Second, it picks the right step: a step_complete_kandev signal
that landed mid-turn is reconciled by reconcileStepCompletionSignalLocked a few lines earlier, and
that reconciliation can transition the task. Firing before consults the step the agent failed on;
firing after consults the step the task now occupies. The latter is correct — if the signal
reconciled, the agent's work at the old step was accepted, and moving the card again from the old
step's recovery config would fight the transition that just committed. ADR-0004 §7's "at its current
step" is read as current at dispatch time.
Off the session guard
Every dispatching route reaches handleRecoverableFailureLockedState with the failed session's
per-session cancelInFlight mutex already held by an outer frame — handleAgentFailed,
handleRecoverableFailure, handleAgentStartFailed. Only the sessionless variants do not, and
AC-A5 already declines to dispatch from those, so "held" is universal, not a case
split.
That mutex is not reentrant, and one action in this vocabulary re-acquires it for the same
session. autoStartAgentCallback.Execute (workflow_callbacks.go:278) calls
LaunchSession(IntentWorkflowStep, SessionID: in.State.SessionID) → launchWorkflowStep →
StartSessionForWorkflowStep (task_operations.go:2326) → PromptTask →
claimSessionRunningForPrompt (:4918), whose first statement locks that same session's guard.
Calling the dispatch while still holding that guard is a permanent self-deadlock, not a stall:
the guard is refcounted, never released, so every later Stop / Cancel / Delete / Prompt on the
session blocks behind it.
So the dispatch shall not run while holding that guard. handleRecoverableFailureLockedState
performs its recovery bookkeeping under the guard and returns a func() instead of dispatching
directly (AC-A11). Its caller releases the guard (lock.Unlock(); release()) and
only then invokes the returned closure — synchronously, on the same goroutine. No new goroutine is
spawned, no completion hook is needed for a test to observe it, and no async lifecycle exists to
drain at shutdown: the closure runs and returns like any other call. A callback that reacquires the
guard now finds it free.
The closure recovers panics. An agent.failed dispatch delivered through the event bus runs
inside invokeHandler's own recover-and-log wrapper (events/bus/safe_handler.go:32); routes
R2-R5 never reach the bus and gain no equivalent protection now that the dispatch is an ordinary
synchronous call rather than a bus-wrapped one. dispatchKanbanAgentErrorTriggerRecovered wraps the
call: a panicking callback (auto_start_agent is the obvious one, since it is the action that
re-enters the guard) is recovered, logged at ERROR with the task and session ids, and does not take
the process down. That ERROR is not a dispatch record (AC-A6) and does not count
against its at-most-one.
Why the alternative — excluding auto_start_agent — was rejected, and what running off the guard
costs, are recorded in the companion
verification.md. The costs
themselves are contract and stay here, in Concurrency and
Failure modes; none is a data race.
How the engine sees that step. The dispatch follows processOnTurnCompleteViaEngine
(event_handlers_workflow.go:4721-4751): EvaluateOnly: true with an explicit PreloadedState.
PreloadedState replaces LoadState — loadExecutionContext (engine.go:333) uses one or the
other, never both — so AC-A4's freshness comes from this handler, not the engine. State is built as
the dispatch's first act, after the reconciliation: reload the session and then the task, in that
order and with the short-circuit Guard evaluation order fixes, then
buildMachineState(ctx, task, session). LoadStep still reads the step through the store, keyed on
the reloaded task's WorkflowStepID — assembleMachineState copies that field into
MachineState.CurrentStepID; models.Task has no field of that name.
That reload is ONE read of each, reused, and it is keyed on the EVENT. The task is read by
data.TaskID and the session by data.SessionID — the same two fields the surrounding handler
already uses for persistLastAgentError, writeTaskReviewState and the reconciliation. The dispatch
shall not re-derive the task id from the loaded session's TaskID, and shall not compare the
two. Using exactly one source makes a mismatched pair unrepresentable inside the dispatch, which
matters because the two are consumed differently downstream — callbacks act on in.State.TaskID
while transitions use HandleInput.TaskID, so a pair drawn from two sources could start work on one
task while moving another. A row whose task_id disagrees with the event is a repository-integrity
failure the whole handler already assumes away; see Out of scope.
The object each read produces is the same one the AC-B1 ownership check, the AC-F2 / AC-F3 / AC-F4
shape guards and buildMachineState consult; the dispatch shall not re-read either mid-flight,
because two reads of one row can observe two versions and let the ownership gate and the engine's
state disagree about the same task.
Guard evaluation order places that read; AC-A7 /
AC-F6 give its failure behaviour.
Idempotency. The dispatch passes an OperationID to Engine.HandleTrigger, which short-circuits
to HandleResult{Idempotent: true} when TransitionStore.IsOperationApplied reports the key applied
(engine.go:252). The key is derived from the failure's identity, not from a run:
agent_error:session:<sessionID>:<agentExecutionID>
agent_error:session:<sessionID> // when AgentExecutionID is empty
Both components are needed: a session can host several executions in sequence (rotation, resume, dynamic re-route) and each can fail, so a session-only key would suppress every failure after the first for the life of the process.
Why the fallback exists is in the companion; cost: AC-C8.
When the marker is written, and what that costs. handleTrigger calls markOperationApplied
only after processActions returns without error, and applyEngineTransition runs after
HandleTrigger has returned and marked. So an action list that errors part-way leaves the operation
unmarked and a redelivery re-runs it from the start (AC-C6), while a transition
the engine resolved but applyEngineTransition then declines leaves it marked and unretried
(AC-C7).
The durability limit is real. workflowStore.IsOperationApplied / MarkOperationApplied
(workflow_store.go:886) are backed by an in-memory sync.Map (ledger), no
persistence, no eviction — exactly-once within one backend process lifetime, not across a
restart. Acceptable
because agent.failed is a live bus event with no replay: a restart loses the event rather than
redelivering it. A builder must not read "idempotent" as "durable".
Transitions go through the orchestrator, not the raw engine. Engine.applyTransition delegates
to TransitionStore.ApplyTransition, which moves the task but does not dispatch the destination
step's on_enter: workflowStore.applyTransition only builds a stepentry.PendingAllocation when
ctx is wrapped with a stepentry.ResultHolder, which only applyEngineTransition does
(workflow_store.go:312-325). A bare HandleTrigger would move a card into a step whose
on_enter: auto_start_agent never runs — this card's defect, one trigger over. The dispatch calls
applyEngineTransition(..., triggerOnEnter: true), which validates the target step, runs the
credential preflight, and fires on_enter.
Guard evaluation order
The guards in group F, plus AC-A5, AC-A8, AC-B1 and AC-B2, are a first-match-wins sequence, not an unordered set. Each guard is either silent or recording, and that split is what makes the order enforceable at all:
- Silent: AC-F1, AC-A5, AC-B1, AC-F2, AC-F3, AC-F4.
- Recording: AC-A8, AC-A7 and AC-F5 at DEBUG; AC-B2 and AC-F6 at WARNING — AC-F6 except its
otherWorkingSessionIDsub-case, which records nothing of its own.
From the event alone, before any repository read:
- AC-F1 engine snapshot absent or its engine nil → skip.
- AC-A5 no session id → skip.
- AC-A8 user-initiated (route R6) → skip, DEBUG.
Then the single task + session read described above. It is session first, then task, and it
short-circuits: the task is not read when the session step already skipped. That order is
contractual — it is what decides the record when both reads would fail — and it puts the commonest
vanish case (AC-A7) before any task read. A nil row returned with a nil error is the
not-found case, not a success, for both reads: the orchestrator treats err != nil || row == nil
as one condition at a dozen sites, and assembleMachineState dereferences task.WorkflowStepID
while buildMachineState dereferences session.ID, so admitting a nil row here is a panic in the
terminal-failure handler. The full table, first match wins:
- Session, read by
data.SessionID:errors.Is(err, models.ErrTaskSessionNotFound), or a nil session with a nil error → AC-A7, DEBUG no-op. Any other error → AC-F6, WARNING. - Task, read by
data.TaskID, only if step 4 did not skip: any error, or a nil task with a nil error → AC-B2, WARNING. AC-B2 owns every task-read failure; AC-F6 owns none of them.
Then, against that one loaded pair:
- AC-B1 Office-owned → skip. It precedes the shape guards deliberately: Office ownership decides whether this mechanism acts at all.
- AC-F2 ephemeral, AC-F4 archived, AC-F3 empty
WorkflowStepID→ skip. - AC-F5 another session on the task is working → skip, DEBUG.
Then dispatch. A failure satisfying two guards at once takes the earlier one.
What is contractual here, and what deliberately is not. The order between two co-occurring guards is contractual only where it is observable — where at least one of them records. Where both are silent the relative order is explicitly not enforced: step 7's three guards may be evaluated in any order or folded into one condition, and the same goes for AC-F1 against AC-A5 and for AC-B1 against step 7. Nothing distinguishes those outcomes — same skip, no dispatch, no record — so a criterion demanding one would be untestable. Every recording guard's position is contractual, because a wrong order shows up as the wrong record, or as one that should not have been emitted at all.
Action vocabulary
The set of actions a kanban step may declare under on_agent_error is exactly the set
engine.compileGenericActions already compiles. No new allow-list, no new action kinds, no
kanban-specific filter at the dispatch site. That set is seven kinds (engine/types.go:437):
| Kind | Meaningful on a kanban task? | Executor |
|---|---|---|
move_to_next / move_to_previous / move_to_step |
yes | engine transition (structural — no callback) |
auto_start_agent |
yes, and it is a restart loop — see Failure modes | autoStartAgentCallback |
queue_run |
yes for target: primary; needs an Office workspace CEO for workspace.ceo_agent |
QueueRunCallback |
clear_decisions |
degenerate — a kanban step has no decisions | ClearDecisionsCallback |
queue_run_for_each_participant |
degenerate — a kanban step has no participants | QueueRunForEachParticipantCallback |
The three move_to_* kinds have no registered callback (workflow_callbacks.go:32-60); they are
handled structurally inside evaluateActions. That is load-bearing for AC-E4 and AC-E7, which is why
both carry an explicit transition carve-out.
Two facts a builder will otherwise assume wrongly. The embedded-YAML allow-list validGenericAction
(config/workflows/loader.go:200) accepts only the last three, all Office-shaped, so the four
kanban-meaningful kinds are not declarable through a shipped template. And that allow-list is the
only validation of generic action types anywhere — import, git-sync and step-write validate none —
so all seven are already reachable. This spec does not widen it; it makes the compiled set execute.
A recognised-and-skipped no-op is what hid this defect, so it does not stay silent.
Engine.executeCallback returns nil for a kind with no registered callback (engine.go:391-394),
the exact shape of the failure this card is about. This spec adopts
workflow-on-enter-action-dispatch's AC-A6 answer: an action that reaches no executor warns.
Where that warning lives is a decision, not a detail. It is emitted at the orchestrator dispatch
call site, not inside executeCallback, which evaluateActions calls identically for all eleven
triggers: a warning there would change behaviour for every trigger the moment this ships, and would
collide with the sibling on_enter spec, which needs the same warning at the same line for its own
AC-A6. The call site instead walks the compiled on_agent_error actions against the callback
registry before invoking the engine. That registry is a local in Service.initWorkflowEngine
(service.go:1797) that neither Service nor Engine exposes, so this card retains it — an
additive field, no engine-package change.
Retaining it is not snapshotting it. buildWorkflowCallbacks(svc) rebuilds the registry on
every initWorkflowEngine call and registers queue_run, queue_run_for_each_participant and
clear_decisions only when svc.engineRunQueue / svc.engineParticipants / svc.engineDecisions
are non-nil — and the eleven reinitWorkflowEngine() sites re-enter it as the Set* methods wire
those in after construction. A value captured at construction reports those three kinds unregistered
forever, so AC-E4 would warn on every dispatch of an action the engine executes correctly: a
false alarm shipped by the criterion written to prevent one. Nor are two fields one atomic
replacement. initWorkflowEngine rebuilds three collaborators together — the store
(service.go:1798), the registry, and the engine built from both (:1805) — and the dispatch reads
all three: the engine to call, the registry to walk, and the store to read the actions being walked.
Held separately, each reinit is three unsynchronized writes and each dispatch three unsynchronized
reads, so one racing a Set* call can pair a new engine with an old registry, or walk one version of
a step while the engine executes another. One value carrying all three, published once and read once,
closes it by construction. See AC-E12 and AC-E13.
Acceptance criteria
A. Dispatch
-
AC-A1 WHEN an agent session on a non-Office task reaches a terminal agent failure, the system shall dispatch
engine.TriggerOnAgentErrorfor that task against the session that failed. -
AC-A2 The dispatch shall occur on every route in the Scope table marked "yes" — R1 through R5. A dispatch covering only the bus-driven route R1, or only R1–R3, does not satisfy this criterion.
-
AC-A3 The dispatch shall be the last action of terminal-failure handling: after the session state write, REVIEW and signal reconcile, and the
go s.cleanupAgentExecution(...)launch. ReleasecancelInFlightbefore dispatch; callbacks can reacquire. Do not wait on cleanup. -
AC-A4 The step whose
on_agent_erroractions are evaluated shall be the task's current step at dispatch time, read from a task reloaded after the step-completion reconciliation and supplied to the engine asPreloadedState. WHEN a pending step-completion signal reconciled into a transition earlier in the same handler, the destination step's configuration is consulted, not the step the agent failed on. -
AC-A5 IF the failure carries no session id, THEN the system shall not dispatch and shall not warn — a sessionless failure is an ordinary outcome, not an anomaly.
-
AC-A6 A trigger is dispatched when the dispatch called
Engine.HandleTriggerand the call returned no error andHandleResult.Idempotentwas false. A call the engine short-circuited as idempotent is not a dispatch for the purpose of this criterion, whatever AC-C3's prose calls it. WHEN a trigger is dispatched andHandleResult.ActionCount > 0, the system shall emit exactly one INFO record naming task id, session id, step id, and the operation id.ActionCountislen(actions)from the engine's own result, so this is decided after the call, not guessed before it — the same field Office's dispatcher already reads as its "something happened" predicate. At most one record is emitted per invocation that reaches the engine — exactly one in three of the four cases and none in the fourth — and the four cases are total and disjoint: dispatched with actions → this INFO; dispatched with none → AC-E2's DEBUG; short-circuited as idempotent → nothing (AC-C3, AC-C8); engine error → AC-E5's ERROR. "At most one" is the invariant and the enumeration is authoritative: a builder shall not read this criterion as requiring a record on the idempotent path, which AC-C3 and AC-C8 both forbid."Record" here, and everywhere this spec counts records, means a dispatch record: one of the four outcomes enumerated above, or a recording guard's line; this criterion is where that set is defined, and AC-F9 states the identity requirement it places on the records it names. AC-E4's walk WARNING is deliberately not a dispatch record and does not count against "at most one". It reports how the step is configured, is decided by the pre-engine walk rather than by the call's outcome, and is therefore orthogonal to the four cases: a step declaring an action with no registered callback emits it in addition to whichever case applies — including the idempotent case, where the dispatch-record count is still zero. Without this carve-out the criterion would be unsatisfiable for such a step, since AC-E4's WARNING plus this INFO is two, and AC-C3's and AC-C8's "no dispatch record" would be false whenever the redelivered step happens to declare an unregistered action. This is the same exemption shape AC-F8 already applies to its two non-dispatch log classes.
-
AC-A7 IF the failed session row no longer exists when the dispatch runs — deleted between the failure and this point — THEN the system shall treat it as a no-op logged at DEBUG, not as an error. Detection shall use
errors.Is(err, models.ErrTaskSessionNotFound), which the repository wraps with%w. This criterion takes precedence over AC-F6 for the session reload that buildsPreloadedState: a reload failing withErrTaskSessionNotFoundis this DEBUG no-op; a reload failing for any other reason is AC-F6's WARNING. A missing current step is deliberately not covered here — it surfaces as an unwrapped error with no sentinel and takes AC-E5's ERROR path. See Out of scope. -
AC-A8 IF the failure is user-initiated — route R6, the user cancelling the transient retry loop — THEN the system shall not dispatch, and shall log at DEBUG. The signal shall be an explicit field on
watcher.AgentEventDataset only atCancelTransientRetry's call site, defaulting to false so every other route is unaffected. It shall not be inferred from the error message, the failure code, or a missing execution id. -
AC-A9 WHEN the transient retry loop reaches a terminal exit that is not user-initiated — routes R4 and R5 — the system shall dispatch. Exhausting or failing to start automatic retries is an agent failure, and suppressing recovery there would defeat the trigger for exactly the failures it exists to catch.
-
AC-A10 AC-A8's suppression is scoped to the R6 delivery itself and shall not leak to a concurrent retry timer's delivery. WHEN a
runTransientRetrytimer has already claimed its retry entry (transientRetries.Loadreturned the live entry andentry.claim()succeeded) and that timer does reach R4 or R5, THEN it shall dispatch, with the AC-A8 marker at its defaultfalse— the cancel's marker rides on the cancel's own event and never on the timer's. Whether the timer reaches R4 or R5 at all is deliberately NOT this criterion's, and is not guaranteed: the cancel also cancels theretryCtxthat timer is running on, andretryTransientPromptcan return at one of itsctx.Err()checks first — the mechanism is in Concurrency. So the pair yields one when the timer gets through, zero when the cancel outruns it; both are correct and AC-B5 names the zero. A test shall pin the non-leak — a timer that reaches R4/R5 dispatches despite a concurrent cancel — and shall assert at most one, never exactly one. Accepted residual, and it shall not be closed by detaching the post-claim continuation fromretryCtx: that re-drives the prompt after the user pressed Cancel (Out of scope). -
AC-A11 The dispatch shall not run while the failed session's
cancelInFlightguard is held.handleRecoverableFailureLockedStateshall return afunc()to its guard-holding caller instead of dispatching directly; the caller shall release the guard and only then invoke the returned closure, synchronously, on the same goroutine — no new goroutine is spawned. This is what lets an action list containing a bareauto_start_agentcomplete instead of self-deadlocking; the reasoning, including why excluding that action was rejected, is in Off the session guard.The closure shall recover panics. Routes R2-R5 never reach the event bus's own recover-and-log
invokeHandler(events/bus/safe_handler.go:32), and the dispatch is now an ordinary synchronous call with nothing above it on the stack to recover a panic, so a panicking callback would otherwise crash the whole backend process. The closure shall recover, log at ERROR with the task and session ids, and return. That ERROR is not a dispatch record (AC-A6) and does not count against its at-most-one.
B. Office isolation
- AC-B1 IF the failing session's task is Office-owned by the authoritative projection
(
models.Task.IsFromOffice), THEN the system shall not dispatch. Office's Path A remains the only fire site for those tasks. - AC-B2 IF the task cannot be loaded — the read errored, or returned a nil task with a nil
error — THEN the system shall not dispatch and shall log at WARNING. This is the sole record for
every task-read failure; AC-F6 emits none. The check fails closed: an unavailable ownership answer must never be read as "not
Office", because that is the state that double-fires with Path A. This mirrors
writeTaskReviewState's fail-closed task load, and deliberately does not reuseisOfficeSession, which returnsfalseon a lookup error (fails open). - AC-B3 Office behaviour shall be unchanged.
dispatchAgentErrorTrigger, itsagent_error:<run.ID>key,HandleRunFailureandescalateFailurekeep their current semantics, and the Office dispatcher'sresolveSession/resolveSessionByIDbranches forTriggerOnAgentErrorshall be untouched. - AC-B4 For one agent failure, at most one
TriggerOnAgentErrorshall be dispatched across both mechanisms. "At most" is exact; the ways the count is legitimately zero are enumerated in AC-B5, which is the sole register of them — this criterion deliberately carries no count of its own, because a number here goes stale the moment AC-B5 gains an entry and has already done so once. The one case in which two may fire is the ownership-flip race in Failure modes, an accepted residual; a test for this criterion shall assert "not more than one", never "exactly one" unconditionally. - AC-B5 The dispatch count for one failure shall be zero, not one, in each of these cases, none
of them a defect: an Office-owned task whose Office run is not claimed (Path A's
resolveLifecycleRunreturnssql.ErrNoRowsand AC-B1 blocks the orchestrator, so neither fires); any AC-A5, AC-A8 or AC-B2 skip; any AC-F1 through AC-F6 guard skip; and a user cancel that cancelsretryCtxinside an already-claimed timer's window, where the timer returns at one ofretryTransientPrompt'sctx.Err()checks before reaching R4 or R5 and the cancel's own R6 delivery is AC-A8-suppressed, so neither delivery dispatches (AC-A10).
C. Idempotency
-
AC-C1 The dispatch shall pass
OperationID=agent_error:session:<sessionID>:<agentExecutionID>. -
AC-C2 IF
AgentExecutionIDis empty, THEN theOperationIDshall beagent_error:session:<sessionID>. -
AC-C3 WHEN the same failure is handled twice within one backend process lifetime and the first handling completed action evaluation without error, the second delivery shall execute no action and the engine shall report
HandleResult{Idempotent: true}. That delivery is not a "dispatched trigger" under AC-A6 and emits no dispatch record. AC-E4's walk WARNING is not a dispatch record (AC-A6) and this criterion does not suppress it: the walk runs before the engine call and cannot know the operation is already applied. -
AC-C4 WHEN two distinct agent executions on one session fail in sequence, both shall dispatch. Suppressing the second is a defect, not idempotency.
-
AC-C5 The exactly-once guarantee is scoped to one backend process lifetime, because
workflowStore.ledgerwraps an in-memorysync.Map. This is a stated limit of the contract, not a gap to be closed by adding durable marker storage in this card. -
AC-C6 IF an action in the list returns an error, THEN the operation shall be left unmarked (the engine's existing behaviour:
markOperationAppliedruns only afterprocessActionssucceeds) and a subsequent delivery of the same failure shall re-run the list from the start, including actions that already succeeded. Actions under this trigger are not assumed idempotent and this card does not make them so. -
AC-C7 IF the engine resolved a transition and marked the operation, but
applyEngineTransitionthen declines it — target step missing, or credential preflight failure — THEN the transition shall not be retried. It is not an AC-E5 case: the engine returned success. The outcome is already recorded byapplyEngineTransition's own logs (WARNING for a missing target step, ERROR for a failed commit) and the dispatch shall not add a duplicate record. The marker precedes the transition commit and this card does not reorder that. -
AC-C8 IF two failures on the same session both carry an empty
AgentExecutionID, THEN they collapse to the one AC-C2 key and only the first shall dispatch; the second is reportedIdempotent: trueand emits no dispatch record, carrying the same AC-E4 carve-out AC-C3 states. That suppression holds only where the first handling completed action evaluation without error: IF the first left the operation unmarked because an action errored, THEN the second re-runs the list as AC-C6 requires and this criterion does not suppress it. AC-C4 is therefore satisfied by distinguishable executions — both ids non-empty and different — since an empty id is not distinguishable from a prior empty one. The cost, a genuinely second empty-id failure on one session being skipped, is an accepted residual bounded by AC-C5's process lifetime; a fresh key per delivery would defeat the operation id for every redelivery to rescue a rarer case. It shall not be closed by adding a counter, a timestamp or any other discriminator to the key.
D. Payload
- AC-D1
OnAgentErrorPayload.FailedSessionIDshall be the id of the session that failed, never empty on a dispatched trigger (AC-A5 excludes the only case where it could be). - AC-D2
OnAgentErrorPayload.FailedAgentIDshall be the failure event'sAgentProfileID; IF that is empty, THEN the failed session'sAgentProfileID; IF both are empty, THEN the empty string. It shall not be populated with an Office run id, an agent execution id, or an agent type. The synthetic events built byhandleAgentStartFailed(R2, R3) and by the transient-loop exits (R4, R5) carry noAgentProfileID, so the session fallback is the normal path for four of the five dispatching routes, not an edge case. - AC-D3
OnAgentErrorPayload.ErrorMessageshall be the failure event'sErrorMessage; IF that is empty, THEN the literalagent failed, matching the defaultpersistLastAgentErrorwrites for the same failure.
E. Action vocabulary and transitions
-
AC-E1 The dispatch shall not filter by action kind. Every action
compileGenericActionsproduces foron_agent_erroris handed to the engine, and the engine's per-kind semantics apply unchanged. -
AC-E2 WHEN the current step declares no
on_agent_erroractions — the trigger was dispatched andHandleResult.ActionCount == 0— the dispatch shall be a successful no-op logged at DEBUG, and shall mark the operation applied (the engine already does so on its empty-actions path). This is the overwhelmingly common case: it must not warn, and per AC-A6 it emits this DEBUG instead of, never in addition to, the AC-A6 INFO. -
AC-E3 WHEN an
on_agent_erroraction resolves a transition target, the system shall apply that transition throughapplyEngineTransitionwithtriggerOnEnter: true— the same call anon_turn_completetransition receives, including target-step validation and credential preflight. The engine's bareApplyTransition, which skips the destination'son_enter, does not satisfy this criterion. The one case whereon_enterlegitimately does not run despite a committed transition is AC-E10's WIP-queued destination.The
taskDescriptionargument shall be theDescriptionof the task already reloaded forPreloadedState(Execution model) — not the empty string, and not a third read of the task. That argument is not inert:applyEngineTransitionWithCommithands it toprocessOnEnter, which passes it tobuildWorkflowPrompt, so it becomes the prompt given to an agent the destination step auto-starts — the path Failure modes already names. The repository supplies this argument two different ways at its three existing call sites (the task's description at theon_turn_completeandon_children_completedsites, the empty string at theon_turn_startsite), so it is named here rather than left to precedent. A recovery move carries the task's description for the same reasonon_turn_completedoes: the relaunched agent needs to know what the task is. Substituting the empty string does not satisfy this criterion; a task whose ownDescriptionis empty passes that empty value through unchanged, which is the field's value and not a substitution. No default, no placeholder, and no second read of the task. -
AC-E4 IF a declared
on_agent_erroraction reaches no executor, THEN the system shall log at WARNING naming workflow id, step id, step name, action type, task id and session id, and the remaining actions shall be unaffected. "Reaches no executor" means no callback is registered for its kind in the running configuration. The threemove_to_*kinds are explicitly excluded: they have no callback by design and execute structurally, so warning on them would be a false positive — including for the second and subsequent transition actions AC-E7 sends down the callback path. The warning is emitted at the orchestrator dispatch call site by walking the step's compiledon_agent_erroractions against the callback registry, before the engine call; it shall not be added insideengine.executeCallback, which is shared by all eleven triggers. This WARNING is not a dispatch record (AC-A6): it does not count against AC-A6's or AC-F8's "at most one", and because the walk precedes the engine call it is emitted even when the engine then short-circuits the operation as idempotent (AC-C3). -
AC-E5 IF the engine returns an error, THEN the system shall log at ERROR and not propagate it. Failure bookkeeping already committed by the surrounding handler must not be masked by a recovery-dispatch failure — the contract
dispatchAgentErrorTriggerstates for Office. This is also the path a missing current step takes (AC-A7). -
AC-E6
validGenericActioninconfig/workflows/loader.goshall be unchanged and no shipped workflow template shall gain anon_agent_errordeclaration. This card makes the trigger work; it configures no board. -
AC-E7 WHEN a step declares the same non-transition action type more than once under
on_agent_error, each declaration shall execute, in declared order. There is no deduplication and none shall be added. Transition actions are excluded:evaluateActionsgates transition handling ontargetStepID == "", so only the first eligible transition resolves a target and any later transition action is not executed as a transition. This is first-eligible-transition-wins, the engine's existing behaviour for every trigger, unchanged here. -
AC-E8 IF a transition action resolves to the step the task already occupies, THEN no transition shall be applied and no
on_enterdispatched. The trigger is still marked applied under AC-C1. -
AC-E9 Transition guards and
requires_approvalshall behave exactly as they do under every other trigger, and the dispatch shall add no special case. A transition action whose guard is not satisfied is skipped, recorded through the engine's existingrecordGuardNotFired, and resolves no target; an action carryingrequires_approvalis not treated as a transition at all (evaluateActionstests!action.RequiresApproval) and takes the callback path, where AC-E4's transition carve-out keeps it silent. Neither case shall produce an AC-E4 warning. -
AC-E10 IF a transition commits but the destination step is WIP-limited and the task is queued rather than admitted (
QueuedForStepID == ToStepID && !WIPAdmitted), THENon_enteris deferred to the queue-promotion event, not dispatched by this handler, and that is not an AC-E3 failure.applyEngineTransitionWithCommitalready takes this branch and returns true. An AC-E3 test shall therefore use a destination step with no WIP limit, or assert the deferral. -
AC-E11 A transition committed under this trigger shall be recorded in the ADR-0015 audit ledger — the
session_step_historytablerecordAutoStepTransitionwrites — under its own trigger label.applyEngineTransitionWithCommitpicks that label with a switch casing onlyTriggerOnTurnStartandTriggerOnChildrenCompleted, everything else falling through towfmodels.StepTransitionTriggerAutoComplete— exhaustive for its three existing callers, and this card is the first to route a fourth trigger through it. The system shall addwfmodels.StepTransitionTriggerAgentError = "on_agent_error"and the matchingcase engine.TriggerOnAgentError, so a recovery move is distinguishable from a routine move insession_step_history.trigger. Recording a recovery move asauto_completedoes not satisfy this criterion. The new value needs no migration (that column isTEXT NOT NULL, no CHECK) and no DTO or frontend change;queue_promotionandplugin_movewere added to this enum the same way.The ledger named here is exact, and the other one is deliberately excluded. There are two step-transition ledgers with two separate enums, and the switch this criterion changes reaches only the first:
session_step_history.trigger←wfmodels.StepTransitionTrigger, written byrecordAutoStepTransition→workflow/service.CreateStepTransition. This criterion's target.task_step_transitions.trigger, and thetelemetry_step_transitions_inserted_totalexpvar counter keyed off it, ← the separate, explicitly closedsteptelemetry.Triggerenum, written fromsteptelemetry.FromContext(ctx).Triggerand set byengineTransitionAttribution, which hardcodesTriggerEngineTransitionand, by an outermost-caller-wins rule (steptelemetry.HasTrigger), will not overwrite an attribution a caller already placed on the context.
A
wfmodelsaddition therefore does not reach the task ledger or that counter, and this criterion does not claim it does: both continue to readengine_transitionfor anon_agent_errormove, exactly as they do for every other engine transition. Distinguishing the recovery move there is out of scope. A test for this criterion shall assert thesession_step_historyrow and shall not assert againsttask_step_transitions.trigger, whose value under this trigger is unchanged by this card.This criterion constrains the label only:
recordAutoStepTransition's pre-existing best-effort semantics (nil recorder, swallowed write error, async enqueue in production) are shared by every trigger and are not changed here. The pending-signal clear stays scoped toTriggerOnTurnComplete. -
AC-E12 The walk of AC-E4 shall read the action list as
StepSpec.Events[TriggerOnAgentError]fromTransitionStore.LoadStep(ctx, task.WorkflowID, task.WorkflowStepID)— the same store the engine will use for its ownLoadStep. Re-reading the step row and callingengine.CompileStepat the call site does not satisfy this: it bypasses the store'sstepCacheand can compile a newer row than the engine executes, which is precisely the false alarm AC-E4 exists to prevent. The guarantee this buys is bounded, and the bound is contractual. The walk and the engine make twoLoadStepcalls, not one, so they see one compiled version only absent an interleaved cache change on that key —stepSpecCacheentries carry a TTL and are dropped bystepCache.invalidate, whichhandleWorkflowStepCacheInvalidationfires on any step edit. A concurrent edit between the two reads may have the walk inspect one version while the engine executes another. Accepted residual: the cost is one spurious or missed AC-E4 WARNING on a step being edited at the instant it fails, the dispatch is unaffected, and pinning oneStepSpecacross both would mean handing a pre-compiled step to the engine, whichPreloadedStatedoes not carry and no engine API accepts. A test shall assert the shared-store read path, not a version equality that only holds when nothing invalidates. -
AC-E13 The engine, the registry walked by AC-E4 and the store read by AC-E12 shall be retained as one value — a single struct holding all three — built inside
initWorkflowEnginewhere the store andcallbacksare already built, published in a single atomic write (anatomic.Pointeror equivalent), and read exactly once per dispatch, that one read supplying all three. Separate fields do not satisfy this criterion: each of the elevenreinitWorkflowEngine()calls is then three unsynchronized writes, and a dispatch racing one can pair a new engine with an old registry, or walk one version of a step while the engine executes another. A value snapshotted at construction does not satisfy it either — it would reportqueue_run,queue_run_for_each_participantandclear_decisionsas unregistered even after theSet*wiring registers them. Synchronizings.workflowEngine's ands.workflowStore's own pre-existing readers is out of scope. -
AC-E14 IF AC-E12's
LoadStepfails, THEN the walk shall be skipped, no AC-E4 warning shall be emitted, the walk shall contribute no dispatch record of its own, and the dispatch shall still proceed to the engine. This is not a guard and it does not fail closed: the walk is an advisory diagnostic, and a dispatch that cannot be pre-inspected is still a dispatch that must happen. The dispatch's record is then whatever the engine's own outcome dictates, and all three of the following are reachable —stepSpecCache.getOrLoadSteppropagates a fetch error to its waiters and never caches it, so the engine'sLoadStepon the same key is an independent retry, not a replay of the walk's failure:- IF the engine's
LoadStepalso fails, THEN the outcome is AC-E5's single ERROR, and it is the only record. - IF the engine's
LoadStepsucceeds — the walk hit a transient error the retry did not — THEN the dispatch proceeds normally and records exactly what AC-A6's four-case table says for the result it gets: the INFO when actions ran, AC-E2's DEBUG when there were none. This criterion does not suppress that record, and a builder shall not read "no warning" as covering the dispatch. - IF the operation id was already applied, THEN the engine never calls
LoadStepat all and neither branch above is taken:handleTriggertestsisOperationAlreadyAppliedbeforeloadExecutionContext, so it short-circuits toHandleResult{Idempotent: true}above the step load. The outcome is AC-C3's — no action executed, no dispatch record — and this criterion neither adds one nor overrides it. A builder shall not treat the twoLoadStepbranches as the complete matrix. The "no warning" here is scoped to the walk, so one failure never becomes two dispatch records and AC-F8 is not contradicted; the idempotent case contributes zero dispatch records, which "at most one" admits. Skipping the dispatch on a failed walk does not satisfy this criterion.
- IF the engine's
F. Guards, nil, empty, and boundaries
Each is a skip with no dispatch; whether it records is fixed by Guard evaluation order. They mirror guards the orchestrator's other engine call sites apply, and are enumerated because omitting one produces a nil-dereference or a nonsensical dispatch.
- AC-F1 IF the retained value (AC-E13) is absent or its engine nil, THEN skip.
- AC-F2 IF the task is ephemeral (
task.IsEphemeral), THEN skip: it has no workflow. MirrorsprocessOnTurnStartViaEngine. - AC-F3 IF
task.WorkflowStepIDis empty, THEN skip. - AC-F4 IF the task is archived, THEN skip. Mirrors
writeTaskReviewState. - AC-F5 IF another session on the same task is in a working state, THEN skip and log at DEBUG
naming the blocking session. A sibling still doing work must not have its card moved out from
under it by a peer's failure. The predicate is
otherWorkingSessionID(event_handlers_streaming.go:2127), the onewriteTaskReviewStateapplies a few lines earlier. It is re-evaluated, not inherited: a second, later read that may legitimately disagree with the first if a sibling started or settled in between. Accepted. - AC-F6 IF a repository read the dispatch needs fails — the session reload that builds
PreloadedState(see Execution model), orotherWorkingSessionIDreturning itsok == falseerror result — THEN skip. A read "fails" when it returns an error or a nil row with a nil error; the two are one condition. Every read on this path fails closed, matching AC-B2: acting on a partial view of a task's state is worse than not acting, because the action is moving the operator's card. Which record it emits depends on which read failed. A failed session reload logs the dispatch's own WARNING, except one failing withErrTaskSessionNotFoundor returning a nil session, which is AC-A7's DEBUG no-op. A failedotherWorkingSessionIDlogs nothing of the dispatch's own: that helper already writes its own WARNING ("failed to list task sessions before REVIEW state reconcile") before returningok == false, and a second line saying the same is the duplicate AC-F8 forbids. The task reload is deliberately NOT this criterion's: every task-read failure, nil task included, is AC-B2's WARNING and only AC-B2's, so one skip never produces two WARNINGs. Each skip stays observable as the absence of a dispatch. - AC-F7 These guards shall be evaluated before the
OperationIDis marked applied, so a skip caused by a transient condition does not suppress a later valid dispatch of the same failure within the process lifetime. - AC-F8 The guards shall be evaluated first-match-wins in the sequence given in
Guard evaluation order, and one skip shall produce at most one record
of the dispatch's own: that of the guard that fired when it is a recording guard, and none at all
when it is silent. No later guard's record shall be emitted. The relative order of two
silent guards is not part of this criterion — it is unobservable and therefore not contractual;
only a recording guard's position is. Two classes of log line do not count against the "at most
one", neither being this card's: those the surrounding handler writes before the dispatch runs
(
handleRecoverableFailure's own session-reload WARNING), and those a shared helper the dispatch calls writes for itself (otherWorkingSessionID's list-failure WARNING — see AC-F6). AC-E4's walk WARNING is this card's and is likewise not counted, because it is not a dispatch record (AC-A6); it cannot in any case co-occur with a guard skip, since the walk runs at the dispatch, after every guard in Guard evaluation order has passed. - AC-F9 Every record the dispatch emits — the AC-A6 INFO, the AC-E2 DEBUG, and each recording
guard's line — shall carry
task_id,session_idand a message string stable and unique to this dispatch within the orchestrator package. Without that identity AC-F8's "no later guard's record" is unassertable: the log stream already carries the two classes AC-F8 exempts, and a test cannot tell them from the dispatch's own. AC-E4's walk WARNING is not a dispatch record (AC-A6 defines that set) and is outside this criterion's enumeration; it carries the six fields AC-E4 requires, which includetask_idandsession_id, so it stays identifiable in a test without being counted.
Concurrency and ordering
- Two failures, same session, in sequence.
cancelInFlightserializes bookkeeping, then is released before dispatch so callbacks can reacquire it. Distinct executions produce distinct operation ids (AC-C1) and both dispatch; a repeat is absorbed by AC-C3. - Two failures, two sessions, same task, concurrent. Not serialized — the guard is per-session.
AC-F5 resolves the common shape: whichever session settles while the other is still working skips.
IF both reach a terminal state such that neither observes the other as working, THEN both may
dispatch, and
ApplyTransitionis unguarded, so the last transition to commit wins, with no ordering required. Accepted; a compare-and-swap here is out of scope. - A user cancel racing an already-claimed retry timer.
runTransientRetryclaims its entry before callingretryTransientPrompt, whileCancelTransientRetryreadsactiveand only then clears state — but clearing it callsentry.cancel()unconditionally, with noclaimedcheck, and that cancels theretryCtxthe claimed timer is still running on.retryTransientPromptthen has fourctx.Err()early returns before its R4 / R5 calls. So a won claim does not guarantee arrival: the timer dispatches if it gets past those checks first and returns silently if the cancel does. Both deliveries funnel throughhandleRecoverableFailureand serialize oncancelInFlight, so this is an ordering outcome, not a data race. The total is therefore one or zero, never two — AC-A10 fixes which part is guaranteed (the non-leak of AC-A8's marker) and AC-B5 names the zero as legitimate. - Ordering against the surrounding handler is fixed by AC-A3 and is total: session state → REVIEW
→ step-completion reconciliation →
cleanupAgentExecutionlaunch → dispatch. - Ordering of actions within one dispatch is the stored order of the step's
events.on_agent_errorJSON array —compileGenericActionspreserves it,evaluateActionsiterates it — with first-eligible-transition-wins as tiebreak (AC-E7). Unchanged by this card.
Failure modes
auto_start_agentunderon_agent_erroris an unbounded restart loop, and this card does not bound it.autoStartAgentCallbackcallsLaunchSessionwith the failed session's own id, so it relaunches the agent that just failed; the repeat failure has a new execution id, mints a new operation id (AC-C1) and must dispatch again (AC-C4) — indefinitely, no backoff, no cap, and it is the most obvious thing an operator would configure here. Accepted residual: the loop is a property of that configuration rather than of the dispatch, bounding it needs per-session attempt state that does not exist, and AC-A6's one INFO per dispatch makes it visible from the first iteration. Amove_to_stepwhose destination declareson_enter: auto_start_agentproduces the identical loop one step over, via AC-E3.- Ownership flips mid-failure. A task whose
project_idis cleared, or whose workspaceoffice_workflow_idchanges, between Office'sresolveLifecycleRunand this site's task load while an Office run is claimed, satisfies both predicates and double-fires — the one case AC-B4 exempts. Accepted; the outcome is one duplicate CEO run. The same window has a second symptom: the handler gates its step-completion reconciliation on the earlyisOfficeSessionread while AC-B1 re-reads ownership later, so a flip between them skips the reconciliation and the dispatch evaluates the stale failed-at step rather than AC-A4's destination step. Named so a builder does not read AC-A4 as unconditional. A future card making Office ownership a stored column closes both. - Current step deleted between the failure and the dispatch.
LoadStepsurfaces it as an unwrappedfmt.Errorf("step %s not found")(or"load step %s: %w"wrapping the getter's error) with no sentinel, so it takes AC-E5's ERROR path — louder than the DEBUG a vanished row ideally gets (Out of scope). - Engine error mid-action-list.
evaluateActionsaborts on the first action error, so later actions do not run and the operation is left unmarked (AC-C6); AC-E5 makes it visible at ERROR. Abort-on-first-failure is the engine's contract for every trigger. queue_runwithtarget: workspace.ceo_agenton a kanban workspace with no CEO. Resolution fails, the engine errors, AC-E5 logs it, nothing else in the list runs. A misconfiguration, loud after this card and silent before it.- A callback panics during dispatch. AC-A11's closure recovers it, logs ERROR with the task and session ids, and returns — the process keeps running and no other route's handling is affected. Accepted as a defect in that callback, not in the dispatch; the ERROR is the only signal, there is no retry.
Out of scope
Each entry is a named exclusion and part of the contract.
- Office's Path A and Path B. No change to
dispatchAgentErrorTrigger,HandleRunFailure,escalateFailureor the Office dispatcher's session resolution. AC-B3 freezes them. - New action kinds, and compile-time validation generally. ADR-0004 §7's
pause_agentandcreate_inbox_itemare not implemented and are not added.compileGenericActionsdrops an unrecognised type — and amove_to_stepwhosestep_idis missing or unreadable — before the engine sees an action, so AC-E4's warning cannot reach either: an operator's typo'dstep_idstill reads as configured and still does nothing. That is this card's defect shape one layer up; the fix is compile-time validation shared by all seven Phase 2 triggers. Different site, different card. Validating action types on the import / git-sync / step-write paths — none do today, which is how an unrecognised type reaches the compiler at all — belongs to that same card. - Widening
validGenericAction. The embedded-YAML allow-list stays at its three Office-shaped kinds; the four kanban-meaningful kinds remain declarable only through import, git-sync and the step API. A template-authoring change, not a dispatch change. - Distinguishing an
on_agent_errormove intask_step_transitionsor its expvar counter. AC-E11 labels thesession_step_historyledger only, and names why the other one is unreachable from that switch. Retargeting it means adding a value to the closed repo-widesteptelemetry.Triggerenum and either branching the sharedengineTransitionAttributionon the engine trigger or wrapping the attribution further out — blast radius every transition writer, not just this dispatch. Anon_agent_errormove therefore reads asengine_transitionthere, indistinguishable from a routine engine move; a follow-up wanting them separable in task-level telemetry must decide the attribution site for all engine triggers at once. - A
models.ErrWorkflowStepNotFoundsentinel. Distinguishing a vanished step from a genuine engine error means a sentinel threaded throughworkflow/repository/sqlite.go,workflow/serviceandorchestrator/workflow_store.go— shared plumbing every trigger depends on. AC-A7 is narrowed to the session-vanished case, which already hasmodels.ErrTaskSessionNotFound. - Making a user cancel and an already-claimed retry timer agree. AC-A10 accepts that the outcome
of that race is decided by which side reaches
retryTransientPrompt'sctx.Err()checks first: the timer dispatches if it gets through, and nothing dispatches if the cancel'sentry.cancel()lands first. Making it deterministic in either direction needs per-session cancel state outliving the single event delivery AC-A8's field rides on — its own mechanism, with its own lifetime and eviction rules. Note the seemingly obvious fix is the wrong one: detaching the post-claim continuation fromretryCtxwould make the timer always arrive, but at the cost of re-driving the prompt after the user pressed Cancel. - Bounding the
auto_start_agentrestart loop. An accepted residual in Failure modes; a bound needs per-session or per-step attempt accounting that does not exist and would change what AC-C4 means. - Reconciling an event whose
task_iddisagrees with its session row. Execution model makes this unrepresentable inside the dispatch by reading onlydata.TaskIDand neversession.TaskID. Detecting and reporting a genuine disagreement is a repository-integrity concern shared by every consumer ofwatcher.AgentEventData, not a dispatch fix. - The no-session failure branch. It cannot dispatch: engine state is keyed on
(taskID, sessionID)and there is no session to key on. Giving it one is not a dispatch fix. - Durable operation markers. AC-C5 states the process-scoped limit; making the ledger durable
is
workflow-on-enter-action-dispatch's step-entry-record territory. - Synchronizing
s.workflowEngine's existing readers. AC-E4 makes this dispatch's engine and registry one atomically-published value, read once. The field's other readers stay as they are: they were already unsynchronized before this card, and fixing them is a separate change. - A compare-and-swap on
on_agent_errortransitions. See Concurrency; last-write-wins is accepted. - Data-bag semantics. No kind in this vocabulary produces a
DataPatchtoday andEvaluateOnly: trueskipsPersistData.applyEngineTransitiondoes persistresult.DataPatchwhen a transition commits, so the path is empty only because no compilable kind emits one. IF a future kind under this trigger produces aDataPatch, THEN this spec must be re-opened.
Verification
The per-criterion test plan, the fire-site pin test's syntactic definition, and the commands live in
the companion verification.md. Every criterion above is observable without
reading logs, except where a log record is the contract (AC-A6, AC-A8, AC-B2, AC-E2, AC-E4, AC-E5,
AC-F5, AC-F6, AC-F8, AC-F9).