CodeyBox detects worker crashes via a heartbeat registry and automatically re-queues any work items that were in-flight when the crash occurred.
If the orchestrator process is killed mid-flight (OOM, host shutdown, SIGKILL), any work items in a non-terminal worker-owned state (Planning, PlanReview, Working, Auditing, Reworking, Merging, UpstreamPushing) or a durable phase-boundary state (PlanApproved, WorkComplete, AuditPassed, Merged) may be left orphaned. On restart, recovery re-queues them at the correct resume point.
The dead-worker reaper fixes this: workers prove they are alive every N seconds; items whose worker hasn't heartbeated in M seconds are presumed dead and transitioned back to a safe pick-up point.
Each worker slot writes a row to worker_registry on startup:
| Column | Description |
|---|---|
worker_id |
New GUID on every orchestrator start |
host_name |
Environment.MachineName |
process_id |
Environment.ProcessId |
started_at |
ISO-8601 timestamp |
last_heartbeat_at |
Updated every HeartbeatInterval |
current_work_item_id |
Set when the worker picks up an item; row is deleted on completion |
On clean shutdown the row is deleted. On crash (OOM, SIGKILL, panic) the row stays stale; the reaper cleans it up.
Each active worker fires an UPDATE worker_registry SET last_heartbeat_at = $now ... every HeartbeatInterval (default 15 s). Heartbeat failures are fail-soft: a transient SQLite write failure is logged at Warning level and retried on the next interval — it never crashes the worker.
DeadWorkerReaper (IHostedService) runs a periodic sweep every CheckInterval (default 60 s):
- Compute
cutoff = now − DeadWorkerThreshold(default 90 s). - List stale candidates without deleting them. If a candidate owns a claimed durable agent-turn checkpoint, first fence that exact owner: a current local pipeline is recovery-cancelled and awaited to quiescence; a demonstrably dead local process can be reclaimed; a remote or still-live owner fails closed and keeps its registry row.
- Atomically claim each still-stale row with
TryClaimDeadWorkerAsync. Only the reaper that deletes that exact row may normally recover it; after a local owner was fenced, the guarded work-item state/timestamp write remains the election if the worker deregistered while quiescing. - For each claimed row whose
current_work_item_id IS NOT NULL:- Look up the work item.
- If it is in a recoverable worker-owned state (see table below), increment
RecoveryAttemptsand transition it. - If it is in a durable phase-boundary state, re-dispatch it without changing state, still consuming a recovery attempt.
- If
RecoveryAttemptsexceedsMaxRecoveryAttempts(default 10): transition toAbandonedAfterRecoveryAttemptswithLastError = "exceeded MaxRecoveryAttempts". - Fire a
work_item.recoveredwebhook event for recovery handoffs, including same-state phase-boundary redispatches. - Re-enqueue the item for immediate pick-up.
The reaper also runs once synchronously at orchestrator startup (before the worker pool begins pulling from the queue), ensuring that items orphaned by the previous process crash are recovered before any new work starts.
A work or rework agent can die after making useful edits but before the
orchestrator creates the phase commit. For a bounded set of failures — a
recognised quota failure, a transient network failure, an infrastructure
failure carrying the provider's explicit ExecutionUnavailable signal, or exit
137 (SIGKILL/OOM) — CodeyBox captures the dirty tree and the agent's private
CLI state so the exact turn can resume rather than restart.
The mechanics, the retained-VM fallback for Incus, and the attempt caps are in
agent-turn-checkpoints.md.
| State when worker died | Recovered to | Why |
|---|---|---|
Working |
Working with a typed Git or retained-sandbox recovery boundary; otherwise Failed |
Valid recovery evidence preserves the interrupted turn for bounded resume. Without it there is no durable mid-turn evidence, so explicit retry is required. |
Planning |
Queued |
Planning edits are discarded; rerun the planning-only turn from a clean sandbox |
PlanReview |
PlanReview |
A plan artifact already exists; rerun the auditor-backed plan-review loop, including plan rework if reviewers still block |
PlanApproved |
PlanApproved |
Re-dispatch implementation from the approved-plan boundary and count the recovery handoff |
Reworking |
Reworking with a typed Git or retained-sandbox recovery boundary; otherwise WorkComplete |
Resume the exact interrupted rework when durable evidence exists; otherwise re-run audit against the last published work branch. |
WorkComplete |
WorkComplete |
Re-dispatch audit from the phase boundary and count the recovery handoff |
Auditing |
WorkComplete |
Re-audit the same commit |
AuditPassed |
AuditPassed |
Re-dispatch merge from the phase boundary and count the recovery handoff |
Merging |
AuditPassed |
Re-attempt the merge |
Merged |
Merged |
Re-dispatch upstream push/finalization from the phase boundary and count the recovery handoff |
UpstreamPushing |
Merged |
Re-attempt the upstream push |
| Any terminal state | — (no action) | Already finished |
Queued |
— (no action) | Not worker-owned; safe without intervention |
All options live under CodeyBox:DeadWorker:
| Key | Default | Description |
|---|---|---|
HeartbeatInterval |
00:00:15 |
How often each worker updates its heartbeat row |
DeadWorkerThreshold |
00:01:30 |
Workers not seen in this window are presumed dead |
CheckInterval |
00:01:00 |
How often the reaper periodic sweep runs |
MaxRecoveryAttempts |
10 |
Cap on automatic recovery transitions before the item is abandoned for operator triage |
Constraint: DeadWorkerThreshold must be ≥ 3 × HeartbeatInterval. Startup validation throws if the constraint is violated.
{
"CodeyBox": {
"DeadWorker": {
"HeartbeatInterval": "00:00:15",
"DeadWorkerThreshold": "00:01:30",
"CheckInterval": "00:01:00",
"MaxRecoveryAttempts": 2
}
}
}| Event name | When |
|---|---|
worker.registered |
Worker slot wrote its registry row |
worker.deregistered |
Worker row deleted on clean exit |
work_item.worker_dead_recovered |
Item recovered by the reaper (below cap) |
work_item.worker_dead_failed_terminal |
Item hit MaxRecoveryAttempts; abandoned for operator triage |
See ../reference/webhooks.md for the work_item.recovered payload.
GET /workers lists currently-registered workers (heartbeating and stale). See ../reference/api.md for the response shape.
Running the reaper twice in quick succession (e.g., startup-sync + the first periodic tick) is safe:
- Each candidate is re-checked and deleted by
TryClaimDeadWorkerAsyncinside a transaction. A concurrent heartbeat or another successful claimant makes the claim return no row, so recovery is skipped. - A claimed durable dispatch is never orphaned by deleting its worker row first:
unfenceable remote/live owners keep the row; a current local owner is cancelled
and awaited before the row claim. Recovery then uses an exact work-item
state/
UpdatedAtcomparison so concurrent lifecycle progress wins. - If the reaper crashes after claiming the registry row but before writing the work-item update, the item remains mid-flight. The startup stranded-item sweep sees that no worker owns it and replays the same bounded recovery policy.
Today CodeyBox is single-host. The same registry and reaper design works unchanged in a future multi-host configuration: each host's workers write to the same shared SQLite (or a future shared store), and the reaper on any host can claim and recover orphans from dead workers on other hosts. The worker_id GUID and host_name/process_id fields provide full attribution for cross-host debugging.
CodeyBox is a single ASP.NET process owning one port — in production
http://127.0.0.1:5000 by default (UseUrls in src/CodeyBox.Api/Program.cs,
used when ASPNETCORE_URLS, urls, and Kestrel:Endpoints:Default are all
unset); the dotnet run profile uses 5036. A binary swap or operator restart
therefore refuses TCP connections for a short window. There is no blue/green
port handover, by choice: the design accepts the gap and expects callers to
retry.
| Phase | Typical duration | What a caller sees |
|---|---|---|
| Old process draining | up to CodeyBox:Shutdown:GraceSeconds (default 60 s) |
still bound and serving until the listener stops |
| Port unbound → new process listening | 5–30 s | connection refused |
| Warm-up | < 1 s | first request takes a cold path |
The drain figure is a worker-drain ceiling, not a fixed wait — a shutdown with
nothing to tear down returns in seconds. With a suspend-capable provider the
ceiling rises to the RAM-scaled suspend budget (about 30 minutes for the default
12 GiB VM), because Shutdown:SandboxTeardownMode can be hot-reloaded to
Suspend immediately before shutdown. The HTTP listener stops accepting almost
immediately on SIGTERM, so plan for up to 30 seconds of refused connections.
What that means for each caller:
- Pollers (anything on a timer reading
/workitems,/quota, …) recover on their next tick, since the queries are read-only and recompute from live state. Keep the interval under 5 minutes and send anIdempotency-Keyheader on mutating calls —IdempotencyMiddlewarecaches the response for(method, path, body)for 24 hours, so a retry after the server already applied the change returns the cached2xxinstead of applying it twice. - Outbound webhooks are unaffected by a CodeyBox restart, and tolerate a
receiver restart:
HttpWebhookDispatcherretriesMaxAttemptstimes with a backoff that doubles fromInitialBackoffSeconds. The defaults (3,1 s) put attempts at t+0, t+1, t+3. Because the tail grows geometrically —MaxAttemptsof 4, 5, 6, 7 ends at t+7, t+15, t+31, t+63 — set ≥ 6 if the receiver itself may take 30 s to restart. Delivery runs on a background channel, never blocking the pipeline, and drains for up to 30 s on shutdown. - GitHub release webhooks are retried by GitHub up to 8 times over ~3.5 days, so a 30-second window costs at most a delayed delivery.
- Nothing inside CodeyBox calls its own API. The orchestrator, audit, merge and push paths use in-process services.
Known gap. The changelog webhook handler creates its work item with a fresh
id and does not key on X-GitHub-Delivery or the release tag. A delivery that
the server fully processed but could not acknowledge — a crash after
CreateAsync, before the 202 reaches GitHub — produces a duplicate changelog
work item when GitHub retries. A refused connection does not trigger this,
since the handler never runs.
To rehearse the window: kill -SIGTERM the API, wait for the port to free,
leave it down for 30 s, start it again, and check that work-item count is
unchanged, that your poller's next tick succeeds, and that GitHub's "Recent
deliveries" panel shows a successful retry. The new process logs its recovery
banner as the reaper resets in-flight items to their safe restart point.