Skip to content

[factory] Spawn-ack timeout cannot fire: the deadline guards the poll loop, not the calls inside it — sweep hung 62min with 0 failures #306

Description

@khaliqgant

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:

  1. Give messaging.placement.spawn, messaging.commands.getInvocation, #ensureMessaging and #ensureLifecycleAction their own per-call deadline (AbortSignal.timeout or Promise.race against the remaining budget).
  2. Derive each per-call budget from the remaining overall deadline so total time stays bounded even across many polls.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions