Side-effect and event reconciliation (Phase 2 audit)
#Current guarantee
The Kernel persists event intent to kernel_event_outbox before the workflow advances past each ActivityRecordEvent. Outbox rows are unique by (source_id,event_id). The publisher retries Temporality delivery, accepts accepted and repeated, and marks rows delivered only after acknowledgement. Temporality's idempotency key makes same-payload redelivery a semantic no-op; a different payload under the same key is a conflict. This is at-least-once publication with idempotent ingestion, not exactly-once transport.
For a tool side effect, tool.started is committed to the local outbox before ActivityRunTool is scheduled. The workflow configures one attempt for that activity. On a normal returned error it records tool.failed and, for MCP, marks the outcome uncertain. A successful Activity result is held in Temporal history; then tool.completed is enqueued.
#Unresolved crash window
A worker can lose contact after a remote effect commits but before Temporal records the Activity completion. The tool may have executed even though the Activity is retried/timed out and no tool.completed event exists. The outbox can prove intent, not the remote outcome. MCP currently has no generic idempotency/read-after-write contract, and ToolRequest.OperationID is not passed as a standardized idempotency key to every MCP server. Docker command effects are confined to the mounted workspace but are not transactionally rolled back.
Therefore the current policy is: do not automatically retry a consequential operation; preserve tool.started plus failed/uncertain status when observable; require operator reconciliation before repeating. This is not yet a complete reconciliation mechanism: nothing queries the downstream service automatically. The supervisor wall pass does close piles of unverifiable operations on its own — it records best-effort operation.reconciled effect verdicts for every operation in a run's wall and re-triages the run whenever new uncertain operations appear — but those verdicts are the model's most plausible inference, not a read-after-write probe.
#Failure matrix
| Boundary | Durable before next step | Failure interpretation | Recovery available |
|---|---|---|---|
| Event record → Kernel PostgreSQL | Outbox row | No row means workflow cannot safely proceed past that event activity | Temporal activity retries up to its configured attempts |
| PostgreSQL → Temporality | Outbox row | Projection may lag while local event intent remains | Publisher retries; same event ID is idempotent |
| Before tool Activity | tool.started outbox row |
Intent exists; effect may not have started | Inspect Temporal Activity state |
| During remote MCP effect | tool.started; possibly no result |
Outcome can be uncertain | Operator reconciles over operation.reconciled |
| During sandbox command | tool.started; command may have modified workspace |
Partial workspace effect is possible | Inspect workspace, rerun validation, then record a new operation |
After Activity completion, before tool.completed enqueue |
Temporal Activity result | Effect/result may exist without semantic completion event | Listing surfaces it; operator reconciles |
#Implemented (2026-09-25)
The remaining-work list below is now implemented; the design decision along the
way was that the ledger is a projection, not a table: the event stream stays
the single source of truth, and operation state is derived from tool.*
events plus Temporal liveness on demand.
- Effect semantics on
tool.failed—effect=nonemarks pre-execution rejections (non-retryable argument validation: safe to re-issue),effect=uncertainmarks failures at or after the execution boundary, andeffect=occurredmarks a failed call whose effects fully landed and are settled by the journal (currently only delegation). Legacy events without the field but witherror_type=activity_failedcount as uncertain. - Delegation classification — the
delegatetool is internal, so its failures are classified precisely instead of defaulting to uncertain:delegated_run_start_failed— the child execution was rejected before it began (duplicate workflow id, namespace shutdown):effect=none, nothing ran.delegated_run_failed— the child started and failed. Right after the failure thekernel.summarize_child_runactivity replays the child's own events from the journal: if every child operation is settled, the parent'stool.failedcarrieseffect=occurred(ornonewhen the child performed zero operations) withchild_ops_total/child_ops_unresolvedfields; only genuinely unresolved child operations keepeffect=uncertain. The operations listing surfaceschild_run_idand the summary so the UI can point at the child trajectory instead of "check the external system".
- MCP idempotency keys — every
mcpclient.Callpropagates the operation id via the protocol's_metafield; servers that support idempotent execution deduplicate retries. The test MCP server persists the key forcreate_issue, so the contract survives the per-call stdio process restart that mimics the retry window. - Operations listing —
GET /v1/agent/operations?project=(reader) projects the journal into operations whose effect state is unresolved:crash_window(no terminal event, workflow no longer running),stale_in_flight(no terminal event after 10 minutes inside a running workflow) andfailed_uncertain(uncertain failure). Completed, blocked, rejected (effect=none) and already-reconciled operations are settled and do not resurface. The listing scans a bounded window — 7 days by default,TEMPORALITY_OPERATIONS_WINDOWto override — because it runs on every UI poll and pages through six event types with full payloads; unbounded scans grew with journal history until busy projects turned every poll into a journal-flooding walk and a 500. Operations older than the window stop surfacing (their journal rows remain queryable by run id), and pagination is capped at 40 pages per event type as a pathological-journal guard. The same window covers terminal events, so windowing never marks a settled operation as uncertain: a terminal event always follows itstool.started. - Reconciliation —
POST /v1/agent/operations/reconcile(operator) writes a durable derivedoperation.reconciledevent:effectis a closed vocabularynone|occurred|unknown(unknownis a legitimate terminal record), the note is bounded to 2000 bytes, and provenance links back to thetool.startedevent viaparent_event_id. A recorded verdict settles the operation — including verdicts over previously uncertain failures. Settling matches operations by(project, run, operation_id), deliberately not by event source: tool events carry the run's own journal source (external agents report under their own source id) whileoperation.reconciledis recorded under the kernel's, and a source-qualified match stranded those verdicts forever. - Supervisor wall reconciliation — a run's pile of uncertain operations
goes to the supervisor model as ONE wall: the integrated conclusion
(needs_attention, causes, remedies, a durable lesson) is journaled as
supervisor.verdict(kind=reconciliation), and the per-operation effect verdicts are recorded as supervisor-actor reconciliations — the same code path a human verdict takes. The wall itself is the budget bound and the dedup: settled operations leave the listing, so a run is re-triaged exactly while unsettled operations remain — including fresh ones that appeared after an earlier verdict. (The previous "one verdict per run, ever" guard stranded those behind an already-closed card.) - Fault injection —
KERNEL_FAULT_AFTER_EFFECTarms a live drill. The value is either an exact operation id or a tool name (run_command,mcp__create_issue), which faults the first matching execution per run — operation ids embed model-generated call ids and cannot be predicted before the run starts. The effect commits for real, then the worker reports a crash-style failure, producing an honesttool.failed(effect=uncertain).
Live drill (2026-09-25, compose stack): a run with KERNEL_FAULT_AFTER_EFFECT=run_command
wrote drill-proof.txt in the workspace, recorded tool.failed(effect=uncertain),
retried the command under a new operation id (success), and completed. The
operator then verified the file, recorded operation.reconciled(effect=occurred)
with the workspace file as evidence, and the listing cleared.
#Phase 2 remaining work
The mechanism above is implemented; what remains open on this front: automated downstream read-after-write probes (the supervisor's verdicts are inferences, not probes). Do not call this exactly-once. Do not infer successful execution from an agent-authored final answer.