Summary
The readiness sweep can hang indefinitely on the agent-placement path. A 5-minute spawn-ack timeout is configured and never fires, because the deadline is checked only between calls rather than applied to the calls themselves.
Production has been observed with a single sweep in flight for 62 minutes while consecutiveFailures stayed at 0. Nothing throws, nothing retries, and the occupied batch slots are never released, so dispatch stops completely.
Observed in production (0.1.66)
From the daemon's own health block:
readinessReconcile: state=stalled consecutiveFailures=0 failureThreshold=3 intervalMs=60000
lastStartedAtMs=1787280884216
lastCompletedAtMs=1787280824210 <-- EARLIER than lastStarted
inFlightMs=3742257 <-- 62 minutes, monotonically climbing
missedPasses=62
dispatchCapacity: state=stalled batchSize=2 active=2 waiting=4
longestWaitMs=3082193 <-- 51 minutes
agentlessOccupants=1
agentlessHoldTimeoutMs=1800000
fleetControlPlane: state=closed (healthy) consecutiveFailures=0
degradedSubsystems: ["readinessReconcile","dispatchCapacity"]
lastStartedAtMs > lastCompletedAtMs with a climbing inFlightMs is the signature of a pass that started and never returned — a hang, not an error. A sweep that does complete costs ~738ms, so this is not slowness.
The decisive fact: 62 minutes elapsed against a 5-minute ack timeout with zero recorded failures. Whatever the layers underneath do, nothing on this path is bounded in practice.
Mechanism (hypothesis, code cited)
src/fleet/relay-fleet-client.ts — the client used when running --backend relay.
1. The deadline guards the loop, not the calls inside it.
async #awaitInvocation(actionName, ack) {
const deadline = Date.now() + this.#spawnAckTimeoutMs; // DEFAULT_SPAWN_ACK_TIMEOUT_MS = 5 * 60_000
while (!terminalStatuses.has(status)) {
if (Date.now() > deadline) throw ... // only reachable BETWEEN iterations
await this.#sleep(this.#pollIntervalMs);
invocation = await messaging.commands.getInvocation(actionName, ack.invocationId); // unbounded
status = invocation.status || 'pending';
}
}
If getInvocation never settles, control never returns to the deadline check. The timeout is real, enforced, and unreachable against precisely the failure it exists for. A deadline observable only between calls does not bound a call.
2. An earlier await has no deadline at all.
async spawn(input) {
const messaging = await this.#ensureMessaging(); // unbounded
await this.#ensureLifecycleAction(messaging); // unbounded
const ack = await messaging.placement.spawn({ ... }); // unbounded, BEFORE any deadline exists
const invocation = await this.#awaitInvocation(...);
placement.spawn runs before #awaitInvocation computes a deadline, so a hang there is unbounded by construction.
Why this client specifically
src/fleet/relay-fleet-client.ts: no AbortSignal, no .kill(, no per-call timeout.
src/fleet/internal-fleet-client.ts: 13 timeout references and a .kill().
src/mount/github-api-issue-read.ts: bounds its fetch with AbortSignal.timeout(30_000).
The codebase knows how to bound calls. This client is the outlier, and it is the one production runs.
Why it is worse than a stall
agentlessOccupants=1 with active=2/batchSize=2: the hung placements hold their slots, four units queue behind them, and the pass never settles to release them. #303's reaper clears agent-less lifecycles after agentlessHoldTimeoutMs, but they re-form on the next pass, so the system oscillates between reaped and wedged and never dispatches. #303 fixed the symptom; this is the cause.
Proposed fix
Bound each individual call, not just the loop:
- Give
messaging.placement.spawn, messaging.commands.getInvocation, #ensureMessaging and #ensureLifecycleAction their own per-call deadline (AbortSignal.timeout or Promise.race against the remaining budget).
- Derive each per-call budget from the remaining overall deadline so total time stays bounded even across many polls.
- On expiry, throw a classified error so the pass records a failure and releases the slot, rather than holding it silently. Note
consecutiveFailures=0 throughout a 62-minute hang — the health signal cannot currently distinguish "working" from "wedged forever" on this path.
Tests worth demanding
- must-fire: a
getInvocation that never resolves causes #awaitInvocation to reject within the ack budget. This must fail before the change.
- must-not-fire: a slow-but-completing sequence of polls that stays within the overall budget still succeeds, and is not killed by an over-eager per-call timeout.
- a hung
placement.spawn (before any deadline exists) also rejects within a bounded time.
- after expiry, the batch slot is released and the failure is counted.
Summary
The readiness sweep can hang indefinitely on the agent-placement path. A 5-minute spawn-ack timeout is configured and never fires, because the deadline is checked only between calls rather than applied to the calls themselves.
Production has been observed with a single sweep in flight for 62 minutes while
consecutiveFailuresstayed at 0. Nothing throws, nothing retries, and the occupied batch slots are never released, so dispatch stops completely.Observed in production (0.1.66)
From the daemon's own health block:
lastStartedAtMs > lastCompletedAtMswith a climbinginFlightMsis the signature of a pass that started and never returned — a hang, not an error. A sweep that does complete costs ~738ms, so this is not slowness.The decisive fact: 62 minutes elapsed against a 5-minute ack timeout with zero recorded failures. Whatever the layers underneath do, nothing on this path is bounded in practice.
Mechanism (hypothesis, code cited)
src/fleet/relay-fleet-client.ts— the client used when running--backend relay.1. The deadline guards the loop, not the calls inside it.
If
getInvocationnever settles, control never returns to the deadline check. The timeout is real, enforced, and unreachable against precisely the failure it exists for. A deadline observable only between calls does not bound a call.2. An earlier await has no deadline at all.
placement.spawnruns before#awaitInvocationcomputes a deadline, so a hang there is unbounded by construction.Why this client specifically
src/fleet/relay-fleet-client.ts: noAbortSignal, no.kill(, no per-call timeout.src/fleet/internal-fleet-client.ts: 13 timeout references and a.kill().src/mount/github-api-issue-read.ts: bounds its fetch withAbortSignal.timeout(30_000).The codebase knows how to bound calls. This client is the outlier, and it is the one production runs.
Why it is worse than a stall
agentlessOccupants=1withactive=2/batchSize=2: the hung placements hold their slots, four units queue behind them, and the pass never settles to release them. #303's reaper clears agent-less lifecycles afteragentlessHoldTimeoutMs, but they re-form on the next pass, so the system oscillates between reaped and wedged and never dispatches. #303 fixed the symptom; this is the cause.Proposed fix
Bound each individual call, not just the loop:
messaging.placement.spawn,messaging.commands.getInvocation,#ensureMessagingand#ensureLifecycleActiontheir own per-call deadline (AbortSignal.timeoutorPromise.raceagainst the remaining budget).consecutiveFailures=0throughout a 62-minute hang — the health signal cannot currently distinguish "working" from "wedged forever" on this path.Tests worth demanding
getInvocationthat never resolves causes#awaitInvocationto reject within the ack budget. This must fail before the change.placement.spawn(before any deadline exists) also rejects within a bounded time.