Files
kandev/docs/specs/workflow-on-agent-error-dispatch/verification.md
T
2026-09-04 10:37:25 +00:00

204 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Verification plan and decision record — `on_agent_error` dispatch for non-Office tasks
## Prior art
**Leg 1 (wiki) and Leg 2 (`saas-kb`) DID NOT RUN** — tooling-unavailable, not empty results; receipts
are in the task plan under `## Prior art — receipts`.
**Leg 3 — in-repo prior reasoning (ran, and it outranks fresh design here).** ADR-0004 already
specified this trigger and governs three decisions:
- §7 "fire on the failing task **at its current step**" — adopted literally; see [AC-A4](spec.md#a-dispatch).
- The engine is "the universal coordinator for task-scoped agent runs. **No discriminator
anywhere.**" An Office-only dispatcher is a discriminator, which is why the answer is a second fire
site rather than a kanban-specific parallel mechanism.
- §7's default action list names `pause_agent`, `queue_run` and `create_inbox_item`. **We depart
here**: the first and third are not `ActionKind` constants and do not exist in
`compileGenericActions`. Adding action kinds is out of scope, so the vocabulary is what the
compiler already accepts.
**Two departures, named because a builder will otherwise trip on them.** ADR-0004 also says
`on_agent_error` fires "only after the runs queue's retry policy exhausts (4 attempts at
[2m, 10m, 30m, 2h])". The shipped Office path does **not** — `event_subscribers.go:654`, "Office
failure path (v1): every agent error is terminal", backed by `AC-OFFICE-RUNTIME-001.1`. This spec
follows shipped Office behaviour, so both paths have the same firing condition.
## Dispatch decision
**Terminal agent-session failure for a non-Office task fires `TriggerOnAgentError` from
`orchestrator.Service.handleRecoverableFailureLockedState`, gated on a fail-closed Office-ownership
check and on the failure not being user-initiated.**
Why that function and not `handleAgentFailedLocked`, the more obvious reading of "the orchestrator's
agent-failure path": it is **the single terminal funnel**. All six routes reaching a terminal
session-backed kanban failure converge here ([Scope](spec.md#scope)), so a site placed after the
`handleRecoverableFailureLockedState(...)` call inside `handleAgentFailedLocked` would miss five. It
is also where the card said to look — "alongside where it already reconciles task state for runtime
failures": it completes the turn, persists the last agent error, sets session state, writes task
REVIEW, and reconciles a pending step-completion signal.
**A retry in flight never reaches it, but a retry that has given up does.** The first delivery of a
transient provider error returns at `handleTransientFailure`. The retry loop's own **terminal exits**
reach it through `handleRecoverableFailure`; two of those three are genuine failures and the third is
a user pressing Cancel. See [AC-A8](spec.md#a-dispatch), [AC-A9](spec.md#a-dispatch), [AC-A10](spec.md#a-dispatch).
Why this cannot become a third path overlapping Office's two. Path A (`dispatchAgentErrorTrigger`)
requires a claimed Office `*models.Run`; Path B (`HandleRunFailure` → `escalateFailure`) queues its
own CEO run and never reaches Path A. Both are Office-run-scoped by construction. But the Office
subscriber and the orchestrator watcher both subscribe to `events.AgentFailed`
(`event_subscribers.go:264`, `watcher/watcher.go:330`), so **both handlers run on every failure** and
without an explicit gate the new site fires alongside Path A on every Office failure. The two
operation-id namespaces are disjoint by construction, so a shared key can never absorb that overlap:
the gate is the only thing preventing it, which is why [AC-B1](spec.md#b-office-isolation) fails closed.
## Why the empty-execution-id fallback exists
Not because the synthetic events omit the execution id — all four `watcher.AgentEventData` sites set
it. It exists because the field can still arrive **empty**: `CancelTransientRetry` discards
`GetExecutionIDForSession`'s error, `retryTransientPrompt` guards teardown with `if execID != ""`,
and a start failure can be raised before an execution row exists. Rare but reachable; the collapsed
key's cost is [AC-C8](spec.md#c-idempotency)'s.
## Why the dispatch runs off the session guard
Rationale for [`spec.md`](spec.md#off-the-session-guard)'s off-guard rule and [AC-A11](spec.md#a-dispatch).
**Why the alternative was rejected.** Excluding `auto_start_agent` reproduces this card's own defect
one action over — an action that parses, compiles, appears in the step definition and silently never
runs, for the most obvious thing an operator configures under a recovery trigger. It is also a
per-kind allow-list needing maintenance forever: any future callback reaching a guard-acquiring path
reopens the hole, where running off the guard closes it for every kind at once. The vocabulary row
stays "yes".
**Why a synchronous release-then-dispatch was chosen over a dispatch goroutine.** Both close the
self-deadlock: the failing property is holding the guard while calling the engine, not the choice of
goroutine boundary. A goroutine adds a completion hook for tests, panic recovery of its own, and an
async lifecycle to reason about at shutdown; releasing the guard before an ordinary synchronous call
needs none of that — the call returns like any other, a test drives it directly, and there is nothing
to drain. The one thing the goroutine design bought for free — a recover above every route — is not
free here, because routes R2-R5 never reach the event bus's own `invokeHandler` wrapper. AC-A11
recovers this explicitly at the closure itself rather than by picking a mechanism that recovers it
incidentally.
**What it costs.** This dispatch is no longer serialized with the session's other decisions the way
it was while running under the guard. [Concurrency](spec.md#concurrency-and-ordering) and
[Failure modes](spec.md#failure-modes) enumerate and accept each consequence; none is a data race.
## Verification
Every criterion below 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).
- **Fire-site invariant.** A test asserting `TriggerOnAgentError`'s production fire sites are exactly
two — the regression guard for the defect itself, failing if a third partial dispatcher is added or
either is removed. Copy the **design** of
`task/repository/sqlite/step_transition_writers_pin_test.go`: a `go/ast` walk keying on a
*statement shape*, comparing enclosing function identities against a registered list.
**A fire site is defined syntactically**, because a bare identifier scan also matches the constant
declaration, the `compileGenericActions` trigger-map entry, a payload doc comment and four
session-resolution references in `internal/office/engine_dispatcher/dispatcher.go`. A fire site is
an occurrence in exactly one of two positions: (1) the value of the `Trigger:` key in an
`engine.HandleInput` composite literal — the orchestrator shape; or (2) a direct call argument to
`dispatchEngineTrigger` — the Office shape, which passes the trigger positionally. A
`*ast.BinaryExpr` operand, a `case` clause, a `const` declaration, a map-literal key and a comment
are **not** fire sites. That predicate, not a directory allowlist, discriminates, so the **scan root
is the whole backend tree** (`apps/backend`, `_test.go` excluded). Assert the set of enclosing
function identities, package-qualified as the prior art qualifies them, equals exactly
`{office/service/Service.dispatchAgentErrorTrigger,
orchestrator/Service.dispatchKanbanAgentErrorTrigger}`. A builder who names the second identity
differently shall update the registered set in the same commit.
- **AC-A1/A2/A9** — one test per dispatching route R1–R5, each asserting exactly one dispatch with
the expected trigger and payload. Five tests, not three.
- **AC-A8** — the `CancelTransientRetry` route produces zero dispatches, asserted on the explicit
marker, not the cancel message text. **AC-A10** — a claimed retry entry that **does** reach R4/R5
dispatches while the cancel's own delivery does not, proving AC-A8's marker did not leak to the
timer's event. Assert **at most one**, never exactly one, and do not assert the timer arrives: a
cancel that cancels `retryCtx` first legitimately yields zero (AC-B5), so a test demanding one
would be asserting a race it cannot win. Drive the marker, not the interleaving.
- **AC-A11** — two tests. **(1) The regression guard for the defect that reopened this spec.** A
non-Office task whose current step declares exactly `on_agent_error: [auto_start_agent]`, driven
through a **guard-holding** entry point — `handleAgentFailed` or `handleRecoverableFailure`, never
a direct call to the dispatch function — completes within a bounded deadline. Because a dispatch
that runs while still holding the guard **hangs** rather than returning a wrong value, the test
shall enforce its own deadline and fail at it. **(2) A panicking dispatch does not take the process
down.** Inject the panic through a registered callback's `Execute` — the only available seam, not a
stub engine — drive it through a guard-holding entry point, and assert the entry point still
returns, the ERROR record names the task and session ids, and no dispatch INFO/DEBUG record is
also emitted for that delivery.
- **AC-A4** — a task with a pending step-completion signal that reconciles into a transition,
asserting the trigger is evaluated against the destination step. Because `PreloadedState` bypasses
`LoadState`, this fails if the handler builds state before the reconciliation rather than after.
- **AC-B1/B4/B5** — an Office-owned task's failure produces one dispatch and it is not the
orchestrator's; an Office-owned task with no claimed run produces zero; a task-load error (AC-B2)
produces zero.
- **AC-C1/C2/C3/C4** — key shape, empty-execution fallback, replay suppression, two executions on one
session each firing. **AC-C6** — an action that errors leaves the operation unmarked and a
redelivery re-runs the list. **AC-C7** — a declined transition is not retried. **AC-C8** — two
failures on one session both carrying an empty `AgentExecutionID` produce one dispatch and the
second reports `Idempotent: true`; AC-C4's test must not be written to contradict this.
- **AC-A6/E2** — the four-case record table is total and disjoint: a step with one declared action
emits the INFO and no DEBUG; a step with none emits the DEBUG and no INFO; the idempotent replay of
either emits neither; an engine error emits only AC-E5's ERROR. Asserting one case without
asserting the absence of the other three does not satisfy this. All four are assertions about
**dispatch records**, so run them on a step whose declared actions are all registered and no
AC-E4 WARNING is in play. Then add one mixed case — a step declaring an **unregistered** action —
asserting that the AC-E4 WARNING and the applicable dispatch record are **both** emitted and that
the pair does not violate "at most one", because only the second is counted. A test that counts
every log line the dispatch emits, rather than every dispatch record, fails on that case.
- **AC-E3** — a step declaring `move_to_step` under `on_agent_error` moves the task **and** dispatches
the destination step's `on_enter`, using a destination with no WIP limit; asserting the move alone
passes with the defect this criterion exists to prevent. Assert the `taskDescription` argument
separately and observably: give the task a **non-empty, distinctive** description and a destination
step declaring `on_enter: auto_start_agent`, then assert that description reaches the prompt
`buildWorkflowPrompt` produces. A test that only asserts the move and the `on_enter` fires passes
with the empty string this criterion forbids. **AC-E10** — the WIP-queued destination
defers `on_enter` instead. **AC-E11** — that same move writes a **`session_step_history`** row whose
`trigger` is `on_agent_error`, not `auto_complete`, and the existing labels are unchanged for their
own triggers. Do **not** assert against `task_step_transitions.trigger`: that column is fed by the
separate `steptelemetry.Trigger` enum, still reads `engine_transition` under this trigger, and is
named [out of scope](spec.md#out-of-scope) — a test asserting `on_agent_error` there cannot pass.
- **AC-E4** — a kind with no registered callback produces the WARNING with all six named fields; a
step declaring **two** `move_to_step` actions produces **no** warning, the false positive the
transition carve-out exists to prevent; and an **idempotent redelivery** for a step with an
unregistered action still produces that WARNING while producing no dispatch record, which is the
carve-out AC-A6 states and which fails if the builder gates the walk on the operation id.
**AC-E12/E13** — a `Set*` call between construction and
dispatch does not make `queue_run` warn, which fails if the value was snapshotted at construction;
and the walk reads through the engine's own store. Do not assert walk-vs-engine version equality:
two `LoadStep` calls with an invalidation between them may legitimately differ (AC-E12's accepted
residual). **AC-E14** — **all three** outcomes after a failed walk, since the cache never caches
the walk's error: when the engine's `LoadStep` also fails, exactly one dispatch record and it is
AC-E5's ERROR; when it succeeds, the dispatch still happens and records AC-A6's INFO or AC-E2's DEBUG per
the result; and when the operation id was already applied, the engine short-circuits above the step
load so no `LoadStep` runs and no dispatch record is written (AC-C3). Asserting only the first case leaves
the contradiction this criterion was rewritten to remove untested, asserting only the first two
treats the `LoadStep` branches as the whole matrix, and a failing walk is never a skip.
**AC-E7/E9** — the second
transition does not transition; a guard-unsatisfied transition and a `requires_approval` transition
neither transition nor warn.
- **AC-F1..F7** — one skip case each, including a failing state-reload (AC-F6) and the
`otherWorkingSessionID` failure asserting the dispatch adds **no** record of its own.
**AC-F8** — at least two failures satisfying two guards at once, each pair chosen so it is
**observable** (at least one of the two records) or the test proves nothing. A sessionless event on
an unloadable task — AC-A5 silent, then AC-B2 WARNING — asserts the **absence** of the WARNING. A
cancelled retry on an archived task — AC-A8 DEBUG, then AC-F4 silent — asserts the **presence** of
AC-A8's DEBUG, the discriminator being the earlier guard's record since AC-F4 has none. Do **not**
write an ordering test for a silent/silent pair such as AC-F2 against AC-F3. Add a **session-read
failure on a task that is also unloadable**, asserting exactly one WARNING and that it is AC-F6's,
which pins the session-before-task order and AC-F6-vs-AC-B2 ownership together. **AC-F9** — every
dispatch record carries `task_id`, `session_id` and its stable message, which is what lets these
assertions key on the dispatch's own lines rather than the pre-existing ones sharing the stream. A
per-guard suite that never overlaps two guards cannot detect a wrong order.
- **Office regression** — the existing `internal/office/service` and
`internal/office/engine_dispatcher` `on_agent_error` tests pass with no edits to their assertions.
Commands:
```
cd apps/backend
go test ./internal/orchestrator/... -run 'AgentError|AgentFailed|RecoverableFailure'
go test ./internal/office/... -run 'AgentError'
go test ./internal/workflow/engine/...
```