ADR-104: Worker heartbeat, stale-run reaper, retry and dead-letter 

Status

Accepted

Date

2026-07-21

Authors

Netresearch DTT GmbH

Context 

Queued runs (ADR-102) claim a row with a lease (claimed_by + lease_expires), but until now nothing renewed or acted on it — the lease was diagnostic only. Two gaps remained:

  • A dead worker strands its run forever. If the PHP process is killed, the container recycled or the machine rebooted mid-loop, the run stays RUNNING with a lease nobody renews. No other worker can take it (the claim is optimistic on QUEUED), so it never finishes.
  • A transient provider failure is terminal. A queued run that hit a 5xx, a rate limit or an exhausted fallback chain settled FAILED immediately — no retry, even though a different worker moments later might have succeeded.

The P1 roadmap asks for a heartbeat, a stale-run reaper, retry-per-failure-class and a dead-letter terminus.

Decision 

Heartbeat. The runtime's trace hook (the same step-boundary closure the cancellation probe uses, ADR-103) renews the lease on every step for a worker run — an ownership-guarded UPDATE (WHERE status='running' AND claimed_by = :me). A renewal that affects no row means the worker lost the run (reaped, re-claimed or terminated); it throws the internal RunLeaseLostException, caught by a dedicated ladder arm that stops without settling (the row belongs to its new owner now) and returns AgentRunOutcome::LEASE_LOST. Interactive run()/approve() segments hold no lease and never renew — the heartbeat is worker-only. To avoid a zombie worker appending an event whose sequence collides with the new owner's stream, the boundary step is persisted after the renewal check, not before.

Reaper. nrllm:agent:reap (schedulable) finds RUNNING runs whose lease has expired (lease_expires > 0 AND lease_expires < now — the > 0 excludes interactive runs) and either requeues them (budget permitting) or dead-letters them. Both mutations re-check staleness inside the UPDATE, so a heartbeat renewal that lands between the reaper's SELECT and its write wins and the merely slow (not dead) worker is left alone.

Retry. A queued run's failure runs through a recovery hook in the ladder, before the default settle. The failure is classified through the existing FailureClassifier (extended so a FallbackChainExhausted wrapper classifies by its most recent attempt rather than as UNKNOWN):

  • Not retryable (auth, configuration, 4xx) → dead-letter now with reason NOT_RETRYABLE.
  • Retryable but the requeue budget is spent → dead-letter with RETRIES_EXHAUSTED.
  • Retryable and under budget → ownership-guarded requeue (bumping requeue_count) and a re-dispatch with an exponential DelayStamp backoff, returning AgentRunOutcome::REQUEUED.

Interactive runs pass no recovery hook and surface failures unchanged.

Budget. A new requeue_count column, shared by both requeue sources (failure retry and stale reclaim), capped by AgentRuntime::MAX_REQUEUES (3), so a deterministically crashing or hanging run cannot loop forever.

Dead-letter = FAILED + reason axis. No new status is introduced (the status model stays stable, and purge/retention are untouched). Dead-lettering is expressed on the ADR-092 reason axis via the two new non-retryable reasons above.

Consequences 

  • AgentRunOutcome gained REQUEUED and LEASE_LOST; AgentRunTerminationReason gained RETRIES_EXHAUSTED and NOT_RETRYABLE (both isRetryable() === false). All are the documented minor-release growth path — consumers match with a default arm, and the playground never sees the new outcomes (they arise only on the worker path).
  • tx_nrllm_agentrun gained requeue_count.
  • Real exponential backoff requires the doctrine transport (which honours the DelayStamp); the default SyncTransport ignores the delay and retries in-process, bounded by MAX_REQUEUES — the same requirement ADR-102 already places on async execution.
  • A worker that renews its lease adds one guarded UPDATE per step boundary (alongside the ADR-103 read); steps are provider-call-slow, so the cost is noise.
  • The reaper only reclaims abandoned queue workers. An interactive run abandoned by a dying client keeps no lease and is still reaped by the age-based retention path (nrllm:privacy:purge).