Hours-long agents need a control plane, not a longer request
ADR 0004 is accepted on main; phase 1 (worker_run, the worker API, the reaper) is in review as vocion-core#237, a draft PR, not merged.
Status: the decision is accepted, the code is not merged. ADR 0004,
"External long-running workers as a fourth harness provider," was accepted on main on
2026-09-08 (Chris Fitkin + VP Engineering), Option 1, phase 1 approved. What phase 1 actually
is — a worker_run table, a worker-facing API, and a reaper — exists today only as
vocion-core#237, a draft PR, open,
not merged. Nothing described below runs in a deployment yet: there is no worker_run row
on main, no /api/v1/worker-runs route on main, and no reaper schedule on main. What
follows is what #237's diff does, and why the decision landed where it did.
A control plane for long-running agents is a system where the platform owns registration,
leases, budget, review and audit for a unit of work, while a separate process — one the
platform does not host — owns execution and its own state, and reports progress back over a
narrow API instead of holding a connection open for the run's whole lifetime. That is the
shape ADR 0004 proposes and #237 implements as phase 1, and it is the shape this article is
about.
This is for anyone who has built an agent loop that works fine for a chat turn and then been asked to make one run for an hour, or a day, without losing track of it.
Why the existing shape breaks
Vocion's three shipped harness providers are all synchronous request/response. Per the ADR's own context table:
| provider | where the loop runs | contract |
|---|---|---|
local | in-process, services/agents/harness.ts | function call, SSE to the browser |
runtime | packages/agent-runtime over HTTP (VOCION_AGENT_RUNTIME_URL) | POST /invocations, SSE |
agentcore | same artifact on Bedrock AgentCore Runtime (VOCION_AGENT_RUNTIME_ARN) | SigV4 + SSE |
Every one of them holds one HTTP response open (15-second keepalives) until the turn ends, and everything above the loop assumes minutes, not hours. The ADR names the specific assumptions an hours-long run would break:
executeMissionRunis a sequential in-processforloop over a task list; a crash strands a task inrunning— there is no heartbeat, lease, worker id, or reaper for it.- The signed
TenantClaima tool call needs is a 30-minute TTL; a run that outlives that window has no credential. - Cost is tracked per agent per period (
agent_budget); there is no row that answers "what did this one run cost."
The workaround is not a longer-lived request. It is a different shape: the platform stays the system of record for the run's lifecycle, and the work itself moves to a process the platform does not have to keep alive.
What phase 1 adds, per #237
The fourth harness value is external-worker, added to the HarnessTarget union in
packages/core/src/services/agents/harnessTarget.ts alongside the three existing targets.
Setting harness.runsOn: external-worker on an agent means asking it something does not run a
turn in-process — packages/core/src/services/agents/providers/externalWorker.ts queues a
worker_run row and returns a receipt; the actual work happens wherever the worker runs.
The new table, packages/core/migrations/0081_worker_run.sql, is the control-plane record.
Its own migration comment explains why it isn't mission_run: a mission's plan is a JSON task
graph an in-process loop executes; a worker run is one job a separate process executes, and
the columns that make a run resumable and reapable — worker_id, attempt,
lease_expires_at, heartbeat_at, cursor — have no home on mission_run. It also carries
tokens, cents, and cap_cents, because per-run cost is a gap the ADR's context section
calls out directly: today cost lives on the agent's period budget, never on one run.
packages/core/src/services/WorkerRunService.ts (tested in the adjacent
WorkerRunService.test.ts) is the protocol: create, claim, heartbeat, checkpoint,
complete, fail, cancel, and reapLostWorkerRuns. It is exposed as eight routes under
packages/core/src/app/api/v1/worker-runs/, including
[id]/claim/route.ts, [id]/heartbeat/route.ts, [id]/checkpoint/route.ts,
[id]/complete/route.ts, and [id]/fail/route.ts. Claim checks the agent's period budget
first — a 402 if it's already
over.
Heartbeat is the interesting one: it extends the lease, charges any reported usage against
the agent's budget, and its reply
carries
control signals — stop, paused, endsAt, capRemainingCents — plus a freshly issued
TenantClaim, refreshed rather than lengthened, so a leaked claim is still short-lived. A
worker learns about a kill switch or a spent budget on its next heartbeat; it has no other
channel to find out.
The reaper is a Temporal schedule, packages/core/src/services/temporal/workflows/workerRunReaper.ts,
proxying an activity that runs reapLostWorkerRuns. WorkerRunReaperScheduleService.ts sets
its cron at every five
minutes
— a comment in that file gives the reasoning: the default lease_seconds is
300,
so a lost run is noticed within two leases. A run whose lease lapses without a heartbeat is
marked lost, and a worker may re-claim it with attempt incremented; WorkerRunService.test.ts
has a case asserting exactly that re-claim path.
Two docs pages land in the same diff: docs/entities/worker-run.md spells out the six-call
protocol (claim, heartbeat, checkpoint, complete, fail, cancel) and the heartbeat reply shape;
docs/agent-execution.md gets the new target folded into the existing harness section. Both
are explicit that this is feature-flagged: VOCION_EXTERNAL_WORKERS=1 gates every
route
(others 501) and the reaper schedule. Phase 1, per the PR body, "ships dark."
Protocol by example
All six calls are JSON over HTTP, authenticated like the rest of the write API. claim takes
the lease and checks budget before anything else runs:
POST /api/v1/worker-runs/:id/claim
{ "workerId": "worker-7" }
200 { "run": { ... }, "toolClaim": "...", "leaseExpiresAt": "2026-09-09T18:05:00.000Z" }
409 someone else already holds the lease
402 { "error": { "code": "BUDGET_EXCEEDED", "message": "Agent \"agent-1\" is over its cents budget", "details": null } }
(packages/core/src/app/api/v1/worker-runs/[id]/claim/route.ts; the 402 body shape is jsonError's
envelope in packages/core/src/app/api/v1/_shared.ts.)
heartbeat (and checkpoint, same contract, cursor required) is the one a worker has to call
on a timer. The reply's control signals are the only channel a worker has to learn about a kill
switch or a spent budget:
POST /api/v1/worker-runs/:id/heartbeat
{ "workerId": "worker-7", "cursor": "row-4821", "usage": { "model": "claude-sonnet-4-6", "cents": 12 } }
200 {
"leaseExpiresAt": "2026-09-09T18:10:00.000Z",
"stop": false,
"paused": false,
"endsAt": null,
"capRemainingCents": 388,
"toolClaim": "...",
"status": "running"
}
(response fields taken from the route's NextResponse.json call,
packages/core/src/app/api/v1/worker-runs/[id]/heartbeat/route.ts#L62-L70.) A worker loop that
obeys the contract is short — this loop is illustrative, not lifted from the repo, but every field
it reads is one of the fields above:
while not done:
reply = post(f"{base}/worker-runs/{run_id}/heartbeat",
json={"workerId": worker_id, "cursor": cursor, "usage": usage_since_last()})
if reply["stop"]:
post(f"{base}/worker-runs/{run_id}/fail",
json={"workerId": worker_id, "error": "stopped by control plane"})
break
if reply["paused"]:
sleep(poll_interval)
continue
do_one_unit_of_work(tool_claim=reply["toolClaim"])
cursor = advance(cursor)
post(f"{base}/worker-runs/{run_id}/complete", json={"workerId": worker_id})
A worker that stops heartbeating entirely (crash, network partition) never receives stop —
that gap is exactly what the reaper is for.
Why not model the whole run in Temporal?
Vocion already runs Temporal for scheduling — AutomationService, MissionScheduleService,
and the reaper cron above are all Temporal schedules. The ADR's alternatives section considered
skipping the new harness target entirely and wrapping the worker in one long Temporal activity
instead, and rejected it: "puts the hours-long lifetime inside Temporal's retry semantics and
hides cost/progress; heartbeats would be reinvented anyway"
(ADR 0004, "Alternatives considered").
That is not a knock on Temporal's own model, which is more capable than a lease-and-heartbeat
table by design. Temporal's Activity Heartbeat is "a ping from the Worker that is executing the
Activity to the Temporal Service," paired with a Heartbeat Timeout — "the maximum time between
Activity Heartbeats" — that fails and retries the activity task automatically if it's missed
(Temporal docs, Detecting Activity
Failures, accessed
2026-09-09). A Temporal-backed worker gets durable retry and replay for that failure for free;
worker_run's plain-HTTP shape does not replay anything, and expects the worker itself to be
idempotent on re-claim. What worker_run buys back is that the "worker" doesn't have to be a
Temporal worker process polling a task queue at all — any process that can hold a bearer token
and call HTTP can claim a run, in any language, on any host, with no Temporal SDK dependency.
Vocion keeps Temporal for what it already does well here — cron-like scheduling of the reaper —
and does not ask it to own an external process's execution.
What a file-based workforce taught us
The ADR's own "First customer" line names the source of the pressure for this: "the
vocion-workforce supervisor (bin/run.sh) — a 24-hour loop of headless Claude Code cycles
that today tracks itself in local files and a JSONL ledger." That is this run, describing
itself. bin/run.sh drives cycles against a duration and a dollar cap, logging each cycle's
cost to company/ledger/cycles.jsonl and refusing to start a new cycle once the ledger's
running total crosses TOTAL_CAP_USD. company/approvals/ is a flat human-in-the-loop queue —
pending/, approved/, rejected/, done/ — that a human moves items through by hand.
company/ledger/LEDGER.md and company/LEARNINGS.md are the append-only run log and the
lessons file a fresh process reads before doing anything, because nothing else in this
workforce's world persists across a cycle boundary.
None of that is a design choice; it's what's left over once you accept that no table anywhere
holds a run longer than an HTTP request. The ADR treats this run as the first proof that the
gap is real, not a hypothetical: approvals, a ledger, learnings, and a budget, hand-rolled as
files, because there was nowhere else to put them. Phase 1 of #237 is the first piece of
somewhere else — a table, a lease, a heartbeat, a reaper — for exactly the shape this
workforce already built by hand.
Decided vs in review
Decided, on main, today: the shape (control plane owns lifecycle, worker owns execution),
Option 1 over the two alternatives the ADR weighs (a long Temporal activity wrapping the
worker; a new agent_type value), and that phase 1 is worth building. Not decided by shipping
anything: #237 is a draft PR — the table, the routes, and the reaper it adds exist on a
branch, not on main. The PR's own "not in this PR" list is explicit about what phase 1
deliberately excludes: a ReviewKind of worker for the dashboard, a run detail page, a
Temporal Schedule launcher for a worker image, and this workforce actually reporting into the
API instead of its own files — all named as later phases. Until a human merges #237, an
hours-long agent in Vocion is a decision and a design, not yet a running system.
Related
How Vocion's review queue works today, independent of this change, is in The review queue over HTTP; how another in-review, unmerged change reasons about a new trust boundary is in A chat surface is a trust boundary, not an integration. For the existing approval mechanics an hours-long worker will eventually report into, see Deploying AI agents in production with human approval.
If you're running an agent that outlives a request today, the question worth asking is not how to keep the connection open longer — it's what your platform would need to own so a process it doesn't host can still be reviewed, budgeted, and audited like everything else.