The loop
FactoryOrchestrator#scheduleDispatchLifecycleRetry → #driveDispatchLifecycle
- arm/re-arm:
src/orchestrator/factory.ts:5823 (timer) and src/orchestrator/factory.ts:5866 (unconditional re-arm inside .catch)
- interval:
DISPATCH_LIFECYCLE_RETRY_MS = 1_000 (src/orchestrator/factory.ts:410)
- body:
#driveDispatchLifecycle (src/orchestrator/factory.ts:5897), throw site src/orchestrator/factory.ts:5949
It is bounded in neither iterations nor wall-clock. #298 bounded only the relayfile 429/overload path; this one has no bound at all. It logs once per key (#dispatchLifecycleCapacityWaitLogged, factory.ts:5838) and is silent forever after — which is why nothing reports broken.
What it waits for
promoteDispatchLifecycle returning true, i.e.
activeDispatchLifecycleCount(lifecycles, key) < config.batchSize
(src/state/file-state-store.ts:292-313, count at :1395).
config.batchSize defaults to 1 (src/config/schema.ts:357, max 5) and the deployed template
factory-cloud/config/factory.config.template.json does not override it. So the batch has exactly
one slot.
dispatchLifecycleOccupiesSlot (src/state/file-state-store.ts:1398) counts dispatching,
running, parking, publishing, published, writeback-applied as occupying the slot.
queued does not. So one row stuck in a slot-occupying phase makes the condition permanently
false for every other issue, and every one of them spins at 1 Hz forever.
Why the stuck row is never reaped — the actual defect
The only reaper for a wedged in-flight row is the held-agent deadline
(dispatch.agentHoldTimeoutMs, default 4 h). Both halves of it skip records with no agents:
#scheduleHeldAgentDeadline — src/orchestrator/factory.ts:5208: record.agents.size === 0 → return, no timer is ever armed.
#sweepHeldAgentDeadlines — src/orchestrator/factory.ts:5254: record.agents.size === 0 → continue.
A lifecycle that reached dispatching and never got an agent placed (spawn failed, fleet fault,
process died between promote and spawn) therefore has zero agents, occupies the only batch slot,
and has no deadline that can ever fire. It is an unreapable permanent batch lock. Every
subsequent issue is claimed as queued and joins the 1 Hz retry storm.
Measured on production (read-only, wrangler tail agentworkforce-factory, 2026-08-20 ~21:31Z, 111 s window)
Deduped (each request logs twice — once as ContainerProxy, once as the DO):
GET factory-state.do/factory-primary/v1/document 1477 ALL 304 (revision unchanged)
PATCH factory-state.do/factory-primary/v1/document 87 ALL 200
GET factory-auth.do/factory-primary/v1/access 71
Steady state, repeating without a single idle gap:
~21 state GETs (all 304) + 1 auth GET + 1 PATCH of exactly 12,836 bytes every ~1.55 s
Intra-cycle spacing is 25–190 ms throughout — there is no ~1 s quiet period, so the cycle is
I/O-bound on the serialized state store (DocumentStateStore#exclusive, src/state/file-state-store.ts:1243),
not paced by the 1 s timer. The timers cannot keep up, so they coalesce into a continuous spin.
The field the PATCHes write
The Durable Object protocol is whole-record: diffWatchStateDocuments
(factory-cloud/container/durable-state-client.mjs) emits the entire record whenever its JSON
differs, and write() sends nothing at all when the diff is empty. So every observed PATCH is a
real change, and its content-length is the size of the changed record(s).
Every steady-state PATCH is 12,836 bytes — the same record, over and over. It is a
dispatchLifecycles record. Proof: exactly every 60 s (DISPATCH_LIFECYCLE_RENEW_MS, factory.ts:409)
a burst appears — #renewDispatchLifecycles (factory.ts:5292) walking #dispatchLifecycleEpochs —
and it writes this size sequence, identically in both bursts 60 s apart:
t=14.8-16.1s 22162, 17304, 17465, 12836, 15239, 12836, 41823, 12560, 14491, 302, 13368
t=74.7-76.2s 22162, 17304, 17465, 12836, 15239, 41823, 12560, 14491, 302, 13368, 12836
That is 11 non-terminal dispatch lifecycles currently leased by this process, and 12,836 is one
of them. The bytes that change inside the record are lease.leaseUntilMs + updatedAtMs (renewal),
which is why the length never varies.
consecutiveFailures: 0 / lastError: none is accurate: nothing fails. promoteDispatchLifecycle
returns false, it does not throw, and DispatchLifecycleCapacityError is caught and swallowed
into a re-arm.
Confirming state (/evidence, same time)
readinessReconcile: state=stalled consecutiveFailures=0 lastDurationMs=1019
lastStartedAtMs = 2026-08-20T20:53:10.315Z
lastCompletedAtMs = 2026-08-20T20:52:10.306Z
Container up 20:47:32Z, running 0.1.65. Two sweeps completed after boot; the one started at
20:53:10Z has been in flight 37+ minutes against ~1.0 s normal passes.
Consequence
With one slot and one unreapable occupant, no issue can ever be promoted out of queued, so no
issue is ever dispatched — matching ~13 h of zero dispatch. The 90-minute
DEFAULT_READINESS_RECONCILE_TIMEOUT_MS re-arms the sweep, so this self-recovers into the same
wedge rather than surfacing as a failure.
Proposed fix
Three changes, smallest first:
-
Make an agent-less in-flight lifecycle reapable. Drop the record.agents.size === 0 skip
from #sweepHeldAgentDeadlines (factory.ts:5254) and from #scheduleHeldAgentDeadline
(factory.ts:5208), and give a row that reached a slot-occupying phase with zero placed agents
its own, much shorter deadline (heldSinceAtMs is unset for these — use updatedAtMs as the
clock). A dispatching row with no agents is definitionally stuck: nothing else will ever move it.
-
Bound the retry loop. #scheduleDispatchLifecycleRetry must carry a deadline or an
attempt ceiling for DispatchLifecycleCapacityError specifically, and must escalate — counter +
a repeated (rate-limited, not once-only) warning naming the key that holds the slot — instead of
going permanently silent after the first log. As written, "queued for batch capacity" is
indistinguishable from "healthy and idle" on every operator surface.
-
Surface batch occupancy. Put activeDispatchLifecycleCount / batchSize and the key(s)
occupying the slot on the heartbeat and /evidence. A full batch with a dead occupant is
currently invisible: readinessReconcile stays green-ish, consecutiveFailures stays 0, and the
only symptom is a 50× read amplification on the state document.
Separately worth deciding: batchSize defaulting to 1 for a deployment expected to run several
issues concurrently turns any single wedged row into a total outage. Even with (1) fixed, a default
of 1 means one bad row stops everything for a whole agentHoldTimeoutMs.
Caveat on the evidence
wrangler tail does not carry request bodies and container stdout is not reachable
(factory#295), so the record key inside the 12,836-byte PATCH is identified by size-matching it
against the 60 s lease-renewal burst, not read directly. Naming that exact issue key needs either
container stdout or a read of the state document — say the word and I'll pull it.
Evidence gathered read-only: wrangler tail + authenticated GET /evidence. No restarts,
redeploys, merges or deploys were performed.
The loop
FactoryOrchestrator#scheduleDispatchLifecycleRetry→#driveDispatchLifecyclesrc/orchestrator/factory.ts:5823(timer) andsrc/orchestrator/factory.ts:5866(unconditional re-arm inside.catch)DISPATCH_LIFECYCLE_RETRY_MS = 1_000(src/orchestrator/factory.ts:410)#driveDispatchLifecycle(src/orchestrator/factory.ts:5897), throw sitesrc/orchestrator/factory.ts:5949It is bounded in neither iterations nor wall-clock. #298 bounded only the relayfile 429/overload path; this one has no bound at all. It logs once per key (
#dispatchLifecycleCapacityWaitLogged,factory.ts:5838) and is silent forever after — which is why nothing reports broken.What it waits for
promoteDispatchLifecyclereturningtrue, i.e.(
src/state/file-state-store.ts:292-313, count at:1395).config.batchSizedefaults to 1 (src/config/schema.ts:357, max 5) and the deployed templatefactory-cloud/config/factory.config.template.jsondoes not override it. So the batch has exactlyone slot.
dispatchLifecycleOccupiesSlot(src/state/file-state-store.ts:1398) countsdispatching,running,parking,publishing,published,writeback-appliedas occupying the slot.queueddoes not. So one row stuck in a slot-occupying phase makes the condition permanentlyfalse for every other issue, and every one of them spins at 1 Hz forever.
Why the stuck row is never reaped — the actual defect
The only reaper for a wedged in-flight row is the held-agent deadline
(
dispatch.agentHoldTimeoutMs, default 4 h). Both halves of it skip records with no agents:#scheduleHeldAgentDeadline—src/orchestrator/factory.ts:5208:record.agents.size === 0→ return, no timer is ever armed.#sweepHeldAgentDeadlines—src/orchestrator/factory.ts:5254:record.agents.size === 0→continue.A lifecycle that reached
dispatchingand never got an agent placed (spawn failed, fleet fault,process died between promote and spawn) therefore has zero agents, occupies the only batch slot,
and has no deadline that can ever fire. It is an unreapable permanent batch lock. Every
subsequent issue is claimed as
queuedand joins the 1 Hz retry storm.Measured on production (read-only,
wrangler tail agentworkforce-factory, 2026-08-20 ~21:31Z, 111 s window)Deduped (each request logs twice — once as
ContainerProxy, once as the DO):Steady state, repeating without a single idle gap:
Intra-cycle spacing is 25–190 ms throughout — there is no ~1 s quiet period, so the cycle is
I/O-bound on the serialized state store (
DocumentStateStore#exclusive,src/state/file-state-store.ts:1243),not paced by the 1 s timer. The timers cannot keep up, so they coalesce into a continuous spin.
The field the PATCHes write
The Durable Object protocol is whole-record:
diffWatchStateDocuments(
factory-cloud/container/durable-state-client.mjs) emits the entire record whenever its JSONdiffers, and
write()sends nothing at all when the diff is empty. So every observed PATCH is areal change, and its
content-lengthis the size of the changed record(s).Every steady-state PATCH is 12,836 bytes — the same record, over and over. It is a
dispatchLifecyclesrecord. Proof: exactly every 60 s (DISPATCH_LIFECYCLE_RENEW_MS,factory.ts:409)a burst appears —
#renewDispatchLifecycles(factory.ts:5292) walking#dispatchLifecycleEpochs—and it writes this size sequence, identically in both bursts 60 s apart:
That is 11 non-terminal dispatch lifecycles currently leased by this process, and 12,836 is one
of them. The bytes that change inside the record are
lease.leaseUntilMs+updatedAtMs(renewal),which is why the length never varies.
consecutiveFailures: 0/lastError: noneis accurate: nothing fails.promoteDispatchLifecyclereturns
false, it does not throw, andDispatchLifecycleCapacityErroris caught and swallowedinto a re-arm.
Confirming state (
/evidence, same time)Container up 20:47:32Z, running 0.1.65. Two sweeps completed after boot; the one started at
20:53:10Z has been in flight 37+ minutes against ~1.0 s normal passes.
Consequence
With one slot and one unreapable occupant, no issue can ever be promoted out of
queued, so noissue is ever dispatched — matching ~13 h of zero dispatch. The 90-minute
DEFAULT_READINESS_RECONCILE_TIMEOUT_MSre-arms the sweep, so this self-recovers into the samewedge rather than surfacing as a failure.
Proposed fix
Three changes, smallest first:
Make an agent-less in-flight lifecycle reapable. Drop the
record.agents.size === 0skipfrom
#sweepHeldAgentDeadlines(factory.ts:5254) and from#scheduleHeldAgentDeadline(
factory.ts:5208), and give a row that reached a slot-occupying phase with zero placed agentsits own, much shorter deadline (
heldSinceAtMsis unset for these — useupdatedAtMsas theclock). A
dispatchingrow with no agents is definitionally stuck: nothing else will ever move it.Bound the retry loop.
#scheduleDispatchLifecycleRetrymust carry a deadline or anattempt ceiling for
DispatchLifecycleCapacityErrorspecifically, and must escalate — counter +a repeated (rate-limited, not once-only) warning naming the key that holds the slot — instead of
going permanently silent after the first log. As written, "queued for batch capacity" is
indistinguishable from "healthy and idle" on every operator surface.
Surface batch occupancy. Put
activeDispatchLifecycleCount/batchSizeand the key(s)occupying the slot on the heartbeat and
/evidence. A full batch with a dead occupant iscurrently invisible:
readinessReconcilestays green-ish,consecutiveFailuresstays 0, and theonly symptom is a 50× read amplification on the state document.
Separately worth deciding:
batchSizedefaulting to 1 for a deployment expected to run severalissues concurrently turns any single wedged row into a total outage. Even with (1) fixed, a default
of 1 means one bad row stops everything for a whole
agentHoldTimeoutMs.Caveat on the evidence
wrangler taildoes not carry request bodies and container stdout is not reachable(factory#295), so the record key inside the 12,836-byte PATCH is identified by size-matching it
against the 60 s lease-renewal burst, not read directly. Naming that exact issue key needs either
container stdout or a read of the state document — say the word and I'll pull it.
Evidence gathered read-only:
wrangler tail+ authenticatedGET /evidence. No restarts,redeploys, merges or deploys were performed.