ADR: Step journal and resumable jobs/swarms (L1)¶
Status: first slice implemented behind flags, default OFF. Not run on a real Postgres, not deployed.
Context¶
An agent job interrupted mid-run (deploy, OOM, SIGKILL) is failed ORPHANED unless it provably did zero work
(ZeroWorkOrphanRecoveryPolicy). Reasons: there is no job cost ledger, spend is metered per chat call,
turns_used is written BEFORE the model call, tool side effects are not deduped on a request id, and
delegate_parallel state lives only in the tool call's stack. Re-queuing a job with turns_used == max_turns
would also run zero turns and "succeed" empty, so a naive re-queue is wrong, not merely risky.
Options¶
- Temporal / external workflow engine. Rejected by the roadmap (infra weight, not needed for single-replica).
- Re-run from the top with idempotent tools only. Cannot cover the model-call double charge; tools are mostly not idempotent.
- Postgres
run_id + step_idjournal (chosen). One tableagent_step_journal(V376, tenant schema), best-effort writes, fail-safe reads: a missing row can only mean "no resume point".
What is built (flags default OFF)¶
agent_step_journal(run_id, step_id, kind, status STARTED|COMPLETED, result_text), unique(run_id, step_id).StepJournalservice (core/agents,com.agenticai.core.agents.tools), no-op unlessapp.agent-jobs.durability.step-journal-enabled=true. With ONLY that flag it is observability: rows are written, nothing is replayed. Every behaviour change (below: effect dedupe, swarm replay, turn resume) additionally needsresume-from-journal=true(StepJournal.isResumeEnabled()).- Turns:
AgentJobExecution.runTurnswritesturn:<n>STARTED before and COMPLETED after the model call. - Orphan recovery:
ZeroWorkOrphanRecoveryPolicyre-queues instead ofORPHANEDwhenresume-from-journal=trueand the journal shows the last started turn COMPLETED (journal-turn-completed: the resumed attempt reuses the result, no model call, nothing re-charged, no tool re-run).AgentJobExecution.runTurnsapplies the journal ONLY to an orphan-requeue claim (QUEUED withattempt_count >= 1, read BEFORE the claim). An approval-park resume (AWAITING_APPROVAL) is deliberately excluded: its parked turn is STARTED-only, and re-running it after a human approved the action would run two turns where the design grants one and repeat the parked action. Withresume-rerun-incomplete-turn=trueit also re-runs a STARTED-only turn (journal-rerun-incomplete-turn); this can re-bill one model call. Attempt ceiling, queue TTL and cancel clauses still apply first. - Exactly-once tool effects:
EffectJournalgains an optionalDurableStore(theStepJournal). In a job session (job:<uuid>) the effect key uses a stable step id instead of the per-process random turn id, so a resumed attempt hits the recorded result. Requiresapp.tools.effect-journal.enabled=trueas well. Scope limit: inside one job an identical durable call (same tool + args) is performed once, even across turns. - Durable swarms:
delegate_parallelbranches in a job session are journaled persha256(agent|question|context); a COMPLETED branch is replayed (resumedFromJournal: true) instead of re-run. Same identical-question-within-one-job scope limit.
Stale rows and what is not journaled¶
- A fresh run (QUEUED,
attempt_count = 0,turns_used = 0) purges its rows (run_id= job id andjob:<id>) before running, so a reused job id cannot replay another run's COMPLETED turn/swarm row. Effect rows are keyed by a hash that includes the uniquejob:<uuid>session id; they are not purged. - Swarm branch FAILURES are not journaled: only a successful answer is cached, so a failed branch is re-run.
Single-replica assumptions (explicit)¶
- No cross-replica lease on a run. Safety relies on the existing guarded
claim/requeueIfStillRunningUPDATEs (one owner per job) and the orphan sweep's Postgres advisory lock. Two workers racing on one run would both see STARTED. - Effect dedupe is keyed per tenant schema; the in-memory/Redis layers of
EffectJournalstay per-node. - Journal writes are best-effort and not in the same transaction as the tool/model side effect: a crash between the effect and its record can still repeat that one effect. Closing that window needs transactional outbox semantics per tool; out of scope.
Staged plan¶
- (done) table, journal, turn resume, effect backing, swarm branches.
- Run on real Postgres; kill-9 a staging job at each point (before model, mid-tool, after turn) and verify.
- Retention job for
agent_step_journal(index oncreated_atexists); today rows are kept. - Multi-turn partial resume with a journaled message history (today resume reuses only the last turn result).
- Workflow-node runs (
WorkflowExecutionEngine) on the same journal.
Risks¶
- Rerun-incomplete mode can double-bill one model call; non-DURABLE-classified tools are not deduped.
- Journal growth until step 3 (retention is deferred; the table is unbounded meanwhile).
- Effect dedupe across turns of one job may suppress a legitimate identical repeat write.