← All posts

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.

Vocion Teamvocion-v2.47.9

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:

providerwhere the loop runscontract
localin-process, services/agents/harness.tsfunction call, SSE to the browser
runtimepackages/agent-runtime over HTTP (VOCION_AGENT_RUNTIME_URL)POST /invocations, SSE
agentcoresame 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:

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 signalsstop, 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.

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.