Skip to content

fix(dispatch-capacity): degrade state when a placed slot outlasts agentHoldTimeoutMs (#419) - #423

Merged
kjgbot merged 2 commits into
mainfrom
fix/419-occupied-slot-max-hold
Sep 1, 2026
Merged

fix(dispatch-capacity): degrade state when a placed slot outlasts agentHoldTimeoutMs (#419)#423
kjgbot merged 2 commits into
mainfrom
fix/419-occupied-slot-max-hold

Conversation

@kjgbot

@kjgbotkjgbot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#419.

The defect

dispatchCapacity had two hold bounds surfaced and neither applied to the failure that actually happens.

  • waitWarnMs (30 min) concerns queue WAIT time and never fires with waiting: 0.
  • agentlessHoldTimeoutMs (30 min) bounds a slot held WITHOUT a placed agent. A slot with placedAgents: 1 was exempt.

The reaper for occupied slots did exist (agentHoldTimeoutMs, 4 h default) but was never exposed next to the agentless one and never fed the state derivation. So an operator reading /healthz saw dispatchCapacity.state: "healthy" while two occupants had held their slots 13.5 hours — 27× the agentless timeout, 3.4× the placed-agent timeout — with batchSize: 2 and both slots consumed. Total dispatch stop, green subsystem.

The fix

  1. Publish agentHoldTimeoutMs on FactoryDispatchCapacityStatus and FactoryPublicDispatchCapacityHealth so a reader can check slotHeldForMs / heldForMs against both reap deadlines.
  2. Add the wedge shape the state derivation was missing: occupiedOccupants (aggregate) and per-occupant pastOccupiedDeadline. Mirrors the discipline of agentlessOccupants / pastReapDeadline. Anchored on heldForMs (the clock the reaper uses) so a placement whose spawn took hours is not confused with a placement that outran its deadline. >=, not >: agrees with the reaper's nowMs < dueAtMs skip boundary.
  3. Extend deriveDispatchCapacityState / dispatchCapacityState to degrade to stalled when either wedge shape is present, and thread the same signal through normalizePublicHealth so an over-the-wire record without the aggregate count still projects the wedge (mirror of the fix(factory): arm the never-placed reaper independently of startup fleet health (#315) #318 discipline).
  4. dispatchCapacity was already in DISPATCH_GATING_SUBSYSTEMS and the top-level status computation already checks it. The state change flips status: "ok" → "degraded" and adds "dispatchCapacity" to degradedSubsystems. ok (container-liveness) is deliberately left alone — recycling a wedged Factory destroys the durable evidence of the wedge and re-imports the lock into the replacement (the Dispatch is permanently wedged: one agent-less lifecycle holds the only batch slot and cannot be reaped, so every queued row spins at 1 Hz forever #303 doc-comment discipline).

Reclamation across a restart

Already survives: heldSinceAtMs rides on the durable lifecycle, inFlightRecordFromLifecycle re-derives it on rehydration, and the finally block at the end of the adopt path calls #rescheduleHeldAgentDeadlineSweep even if a broker call above it threw. The existing agentHoldTimeoutMs reaper (#sweepHeldAgentDeadlines) fires immediately post-boot for any record already past deadline (#412 comment thread confirms the 2026-08-31T11:14Z restart cleared the stranded slots). This PR is the missing observational half: without it, the reaper's absence in a running daemon is invisible on /healthz.

Ablation

Three tests in src/orchestrator/public-health.test.ts:

✓ dispatch capacity health (#303) > degrades when a placed-agent occupant is past agentHoldTimeoutMs (#419)
✓ dispatch capacity health (#303) > leaves a placed-agent occupant within agentHoldTimeoutMs untouched (#419)
✓ dispatch capacity health (#303) > normalizes a placed-agent wedge over the wire without the aggregate count (#419)

Full suite: 750/750 pass across orchestrator/public-health, orchestrator/factory, config/schema, cli/diagnose.

Overlap with related issues

Test plan

🤖 Generated with Claude Code

…ntHoldTimeoutMs (#419)
Before this, `dispatchCapacity` had two hold bounds surfaced and neither
applied to the failure that actually happened. `waitWarnMs` concerns
queue WAIT time and never fires with `waiting: 0`; `agentlessHoldTimeoutMs`
bounds a slot held WITHOUT a placed agent, so a slot with
`placedAgents: 1` was exempt. The reaper for occupied slots did exist
(`agentHoldTimeoutMs`, 4h default) but was never exposed next to the
agentless one and never fed the state derivation — so an operator reading
/healthz saw `dispatchCapacity.state: "healthy"` while two occupants had
held their slots 13.5 hours (27x the agentless timeout, 3.4x the placed
one).
Changes:
- Publish `agentHoldTimeoutMs` on `FactoryDispatchCapacityStatus` and
`FactoryPublicDispatchCapacityHealth` so a reader can check
`slotHeldForMs`/`heldForMs` against BOTH reap deadlines, not just the
agentless one.
- Add `occupiedOccupants` (aggregate count) and per-occupant
`pastOccupiedDeadline`, mirroring the discipline of `agentlessOccupants`
and `pastReapDeadline`. Anchors on `heldForMs` — the clock the reaper
uses — so a placement whose spawn took hours is not confused with a
placement that outran its deadline. `>=`, not `>`: agrees with the
reaper's `nowMs < dueAtMs` skip boundary.
- Extend `deriveDispatchCapacityState` / `dispatchCapacityState` to
degrade to `stalled` when either wedge shape is present, and thread
the same signal through `normalizePublicHealth` so a record that
arrives over the wire without the aggregate count still projects the
wedge (mirror of the #318 discipline for the agentless shape).
- Ablation tests: must-fire (13.5h placed-agent occupant degrades state,
populates `occupiedOccupants`, reaches `status: 'degraded'` and
`degradedSubsystems` without moving `ok`), must-not-fire (a placed-
agent occupant one millisecond inside the deadline stays healthy), and
a wire-record variant that proves an old producer without the count
still degrades.
Reclamation itself already survives a restart: `heldSinceAtMs` is
carried on the durable lifecycle and `inFlightRecordFromLifecycle`
re-derives it on rehydration, then the finally-block at the end of the
adopt path re-arms `#rescheduleHeldAgentDeadlineSweep`. The existing
`agentHoldTimeoutMs` reaper (`#sweepHeldAgentDeadlines`) fires
immediately after boot for any record already past deadline. This PR is
the missing observational half: without it, the reaper's absence in a
running daemon is invisible on /healthz.
Related but distinct: #331 (orphan-reaper `dispatchCallActive` guard can
never fire — separate composed-key bug in `#dispatchInFlight` lookup).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 022020e6-9490-420e-ad06-3554bddee387


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head c4b032d18ec89eb87f8c633ace1f31c81fc7dbf4.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadsrc/orchestrator/public-health.test.ts
Comment threadsrc/types.ts Outdated
Two review threads on #423 (cubic-dev-ai P3):
- src/orchestrator/public-health.test.ts: the placed-wedge tests covered
4h-1ms (must-not-fire) and 13.5h (must-fire) but not the exact
`>=` boundary. Add an at-deadline case that mirrors the existing
agentless boundary test, so a regression to `>` on the shared
reap-instant convention would surface.
- src/types.ts: the `agentHoldTimeoutMs` doc guided readers toward
`slotHeldForMs`, which is the wrong clock for the placed shape
and would let a slow-spawn slot read as an occupied wedge. Call out
`heldForMs` explicitly, and why the two clocks are distinct.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head f81046735e6bef78f7ac392659cafdbaf9f64911.

@kjgbot
kjgbot merged commit 3db0052 into mainSep 1, 2026
7 checks passed
@kjgbot
kjgbot deleted the fix/419-occupied-slot-max-hold branch September 1, 2026 07:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatchCapacity reports healthy while both slots are held 13.5h by offline agents: no max hold for an occupied slot

1 participant

@kjgbot