ADR — Durable execution for agent jobs: Temporal vs. Postgres¶
- Date: 2026-08-17
- Status: Accepted
- Scope:
core/agents—agent_jobexecution and recovery (autonomous-agents workstream)
Context¶
An agent job outlives the HTTP request that asked for it. It runs on a bounded
worker pool inside core (AgentJobExecutor: 4 platform threads, per-tenant cap
2) and can occupy that worker for minutes. When the core container is
restarted — a deploy, an OOM, a pod eviction, a SIGKILL — every in-flight job's
thread disappears while its agent_job row is still RUNNING.
Two properties are required:
- A job interrupted by a restart must resume or fail loudly. It must never
sit in
RUNNINGforever with nothing executing it. A stuckRUNNINGrow is not merely cosmetic: V208'suq_agent_job_live_per_scheduleunique index makes a live job the schedule overlap guard, so one abandoned row mutes that schedule until the row reaches a terminal state. - Recovery must not double-charge the requester's budget, and must not re-run a tool call that already had a side effect.
The platform already contains a durable-execution engine — Temporal, used by
core/workflow — so "just use the thing we already have" is the obvious first
proposal and has to be answered with evidence rather than taste.
Options considered¶
Option A — Route agent jobs through Temporal¶
Model each job as a Temporal workflow, each turn as an activity. Temporal then owns durability: on restart, the worker re-attaches and the workflow continues from its event history, with activity-level retry and idempotency support.
This is genuinely the stronger primitive in the abstract. It gives durable timers, replay-based resume, and per-activity retry policies that Postgres polling does not.
Evidence against it in THIS deployment:
| Fact | Where |
|---|---|
The Temporal SDK is a dependency of core/workflow only |
core/workflow/pom.xml (io.temporal:temporal-sdk) |
| The Temporal client/worker beans are profile-gated | TemporalConfig is @Profile("temporal"); core/workflow/pom.xml's own comment records that core started without the profile "never even constructs" them |
| Temporal is enabled on staging only | deploy/compose/docker-compose.domain.yml appends ,temporal to SPRING_PROFILES_ACTIVE and sets WORKFLOW_EXECUTION_ENGINE: temporal, with an in-file note that prod stays legacy "until the VM RAM decision" |
Production has neither profile. .ai runs SPRING_PROFILES_ACTIVE: security,knowledge,graph-memory,mcpserver,mcp — no temporal, and workflow was deliberately removed |
deploy/compose/docker-compose.gcp.yml |
| The Temporal containers are not even started in production | deploy/compose/docker-compose.gcp.yml marks temporal, temporal-postgresql, temporal-ui and redpanda as profiles: ["deferred"] |
CI brings up temporal + temporal-postgresql containers but sets no SPRING_PROFILES_ACTIVE override, so core runs on the base file's profile list — which does not include temporal — and never connects |
.github/workflows/stack.yml:101, .github/workflows/e2e.yml:121, plus the @Profile("temporal") gate above |
The consequence is decisive: routing agent jobs through Temporal would make
agent jobs non-functional in production on the day they shipped. They would
work on *.staging.example.com and do nothing on *.example.com. Turning them on in
production requires flipping the .ai profile list and accepting a RAM cost
that the compose file itself records as an explicitly undecided question
(memory headroom on the production host is already a known constraint — see the
2026-08-16 memory-starvation finding that produced the 4 GB swap + 6 GB cap).
Nobody writing this PR has the authority to make that call, and shipping a
feature that silently only works in staging is worse than shipping a simpler one
that works everywhere.
A secondary point: it would also be untestable in CI for the same reason, so the recovery paths would ship with zero coverage.
Option B — Checkpoint to Postgres and reconcile with the existing sweeps¶
Keep agent_job as the queue of record and extend the ownership/liveness
columns V207 already ships (owner_instance, heartbeat_at, attempt_count).
An interrupted job is detected by heartbeat staleness and resolved by the
existing reapOrphans tick, which gains one decision point: re-queue the narrow
class of orphan that provably did no chargeable work, fail everything else with
ORPHANED.
No new dependency, no new container, no new profile, identical behaviour in staging and production, and fully unit-testable.
Decision¶
Option B. Agent-job durability is implemented on Postgres, using the ownership and heartbeat columns that already exist, and is reconciled by the orphan sweep that already exists. Temporal is not used for agent jobs.
Concretely, PR-4 adds:
ZeroWorkOrphanRecoveryPolicy— theAgentJobOrphanRecoveryPolicyimplementation consulted byAgentJobRunner#reapOrphans. It returnsREQUEUEfor, and only for, an orphan that provably did zero chargeable work;FAILotherwise.AgentJobBootReconciliation— anApplicationReadyEventreport (counts, log lines,agent.job.recovery.orphans_at_boot) that transitions nothing.AgentJobRecoveryLookup— a read-only Spring Data view used by that report.
No migration. owner_instance, heartbeat_at and attempt_count are all
already in V207; V212 was reserved for this PR and is deliberately left
unclaimed rather than burning a shared Flyway number on an ALTER nothing
needs.
How "orphaned" is decided — and why a blanket boot reclaim is forbidden¶
An orphan is:
status = RUNNING
AND heartbeat_at < now - staleHeartbeatAfter() -- max(2 min, 5 × heartbeat interval)
AND NOT executor.isActiveHere(jobId)
A boot-time reclaim by status alone is explicitly rejected. core is
single-replica today only because container_name: agentic-core in
deploy/compose/docker-compose.yml blocks --scale; the job design is written
multi-replica-safe throughout, and a rolling deploy is transiently two processes
by construction. A fresh replica cannot tell, from status = RUNNING alone, a
row abandoned by a dead process from one a sibling is executing right now.
Reaping the second kind burns a tenant's budget and loses their work silently —
a worse bug than the one this ADR fixes. Heartbeat staleness is the only signal
that distinguishes them, so it remains the decision rule and the boot listener
only reports.
owner_instance is reported but not trusted as a liveness signal:
AgentJobExecutor.instanceId() is host:pid, and in a container the JVM is
usually PID 1 behind a fixed container_name, so a restarted container produces
the identical instance id as the process that died. This does not affect the
sweep — which discriminates on heartbeat_at plus the in-memory isActiveHere
map (empty on a fresh JVM) and only ever logs owner_instance — but it does
mean the boot report labels its two breakdowns carefully instead of calling
either one "orphans".
The idempotency argument¶
This is why the safe default is fail, not retry, and it is reproduced in
javadoc on ZeroWorkOrphanRecoveryPolicy so it cannot drift from the code.
- There is no job-level cost ledger.
agent_job.cost_usdis a column nothing writes —AgentJobExecutioncallsAgentJob#succeedwith anullcost. A resumed job cannot be reconciled against what its previous attempt spent, because nothing recorded it. - Spend is metered per model call. Every
AgentChatService#chatinvocation independently consumes a FREE-tier message unit and takes aPerUserBudgetServicereservation keyed on (tenant, subject, TEXT) — where the subject is the real human's budget key, on purpose. Re-running a job that already made a model call charges that human twice for one request. - Tool side effects are not idempotent. Nothing in the tool path keys on a
request id;
tool_call_logis a log, not a dedupe key; andToolCircuitBreakerServicecounts failures, not replays. A re-run of a job that already calledsend_channel_message,generate_document, image generation or anymcp_tool.*fires it a second time. - With
max_turns = 1, "resume from the last completed turn" and "re-run the whole job" are arithmetically identical. There is no partial-progress state worth resuming from — which is why this design re-queues from the top rather than attempting a continuation.
Therefore: a job with turns_used = 0 and zero tool_call_log rows for its
session (job:<uuid>) has, by construction, made no model call and taken no
side effect. Its previous attempt died between claim and the first chat.
Re-queuing it double-charges nothing because nothing was charged, and repeats no
side effect because none occurred. Everything else fails loudly.
Additional guards, each closing a way that argument could stop being true:
attempt_count < max-attempts (default 2) bounds recovery; created_at inside
queue-ttl stops a re-queue that the dispatch tick would instantly expire with
QUEUE_TTL_EXPIRED; cancel_requested = false; and a failed tool-call lookup
is treated as FAIL, because an unprovable zero-work claim is not a zero-work
claim.
Consequences¶
Accepted:
- Recovery latency is bounded by heartbeat staleness plus the sweep interval:
worst case ≈
max(2 min, 5 × 15 s)+ 15 s ≈ 2m15s from the last heartbeat, in addition to the orphan sweep's 60 s startup delay. Temporal would have been faster here. For minute-scale background jobs this is acceptable; for a request-scoped path it would not be. - Most interrupted jobs fail rather than resume. Any job that had started a
turn — the common case for a long-running job killed by a deploy — is failed
with
ORPHANED, not retried. This is a deliberate correctness-over-convenience trade: the requester is told plainly and can re-submit, rather than being charged twice without being told. "Resumes or fails loudly" is satisfied by the second half far more often than the first. - No cross-replica interrupt exists (no message bus is added by this workstream), so a cancel of a job owned by another replica remains a durable flag observed at that replica's next heartbeat tick.
- The
queue-ttlguard means a job orphaned more than an hour after submission is failed rather than re-queued, even if it did zero work.
Gained:
- Identical behaviour in staging and production, on the infrastructure that is already running.
- No new dependency, container, profile or migration.
- Fully unit-testable:
ZeroWorkOrphanRecoveryPolicyTest,AgentJobRecoverySweepTestandAgentJobBootReconciliationTestare plain Mockito with no Spring context and no database. - Reversible.
AgentJobOrphanRecoveryPolicyis an interface resolved throughObjectProvider; settingapp.agent-jobs.durability.requeue-zero-work-orphans: falserestores the previously shipped fail-loudly behaviour exactly, and replacing the bean is how a future Temporal-backed policy would be introduced without touching the sweep.
Revisit this ADR when: deploy/compose/docker-compose.gcp.yml activates the
temporal profile in production and the VM RAM question is settled. At that
point routing jobs through Temporal becomes a real option, and this decision's
only load-bearing objection disappears.