Skip to content

feat(health): publish why a dispatch attempt failed, as a bounded enum - #361

Merged
khaliqgant merged 3 commits into
mainfrom
lane/dispatch-failure-reasons
Aug 24, 2026
Merged

feat(health): publish why a dispatch attempt failed, as a bounded enum#361
khaliqgant merged 3 commits into
mainfrom
lane/dispatch-failure-reasons

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 24, 2026

Copy link
Copy Markdown
Member

Closes the last unknown in the #355 dispatch outage: dispatch-failed: 5, every sweep, with everything upstream green — and no way to see why.

What the surface says today

The 34-minute non-deferred sweep measured on the live container:

"candidates": 27, "dispatched": 0, "skipped": 27,
"skipReasons": { "not-ready": 21, "parked-dependency": 1, "dispatch-failed": 5 }

dispatch-failed: 5 is a bucket count. It says the sweep enumerated fine, selected five eligible units, attempted dispatch, and dispatch threw — five times, with fleetControlPlane closed, the fleet agent online and readinessReconcile healthy. It does not say what threw. The daemon knows; perItemDispatchSkipReason builds the sentence and hands it to the operator log, which is container stdout, which does not reach wrangler tail.

What this adds

The second level of the same breakdown, following #358 exactly:

field meaning
dispatchFailures the total — the same number skipReasons['dispatch-failed'] carries, published in its own right so it can be a zero
dispatchFailureReasons that total split by a closed 14-code vocabulary, zero counts omitted

New module src/orchestrator/dispatch-failure-reason.ts holds the vocabulary, the normaliser and the counter — the same three things sweep-skip-reason.ts holds.

The four hard constraints

1. Numbers only. dispatchFailureReasons is rebuilt key by key from this module's own copy of the list; an incoming string is never used as an object key. That is the leak vector #358 closed, and the same test shape guards it here — a breakdown keyed by AR-350 /linear/issues/AR-350__uuid.json and Error: connect ECONNREFUSED 10.0.0.4:443 publishes as { 'spawn-ack-timeout': 4, other: 5 }.

2. Zero is distinguishable from absent. dispatchFailures uses optionalCount, never counter(). Three readings, three shapes:

  • absent → no sweep has completed, or the producer predates the field
  • 0 → a sweep completed and no dispatch it attempted failed
  • n > 0 → with a breakdown that sums to n

skipReasons alone cannot express the middle one, because it drops zero buckets — which is why the total exists next to the breakdown rather than being inferred from it.

3. Unknown codes fold into other. So the parts still sum to dispatchFailures, and a reader comparing them detects a newer producer rather than concluding the counter is broken. Copied from factory-cloud#74.

4. No timeout or budget touched.

One deliberate departure from #358

dispatchFailures is not joined to the all-or-nothing candidates/dispatched/skipped trio. Requiring it would make a 0.1.72 daemon — the one in production right now — fail the guard and lose its whole sweep block, deleting the counters that are currently the only view of the outage. It is independently optional instead, and the test pins that: a record carrying the trio and not this field still publishes the trio.

The vocabulary

Named causes first, classified by type through the cause chain (contextualError and the control-plane guard both rethrow wrapped, so reading only the outermost error would lose every name):

relayfile-overloaded, live-state-changed, late-placement-released, lifecycle-terminal, lifecycle-owned-elsewhere, control-plane-open, timed-out, spawn-ack-timeout, fleet-identity-read-only, agent-registration-failed

Then the phase codes — unclassified-gate, unclassified-triage, unclassified-dispatch — and other.

The phase codes are not a failure of the vocabulary; they are its most useful answer when nothing named matches. A unit that failed in triage never reached the fleet. On a surface that carries no messages, the phase is the only thing left that still says who should look. #292's own tests now pin them, and the result is worth seeing: the TypeError: fetch failed case from #291 classifies as unclassified-triage, and the open-circuit dry-run case is unclassified-triage too — its injected fault is the "unrelated per-item fault", not the circuit. Those were conflated in one bucket before.

Five of the codes are matched by allowlisted error class name rather than instanceof, documented at the map: their classes live in relay-fleet-client.ts, and adding that import edge to factory.ts would pull the relay SDK into the orchestrator's module graph to buy precision over a code-controlled identifier. A rename there degrades one bucket to unclassified-dispatch; it cannot leak and it cannot break the sum.

Tests

src/orchestrator/dispatch-failure-reasons.test.ts drives the real writer — a live daemon whose real spawn really throws — rather than hand-setting failureCode on a report fixture, which would prove the projection copies a field it was handed and nothing about the sweep that has to produce it.

Ablated five ways, each caught by the assertion that names it and with the right failure message:

ablation caught by message
counter() instead of optionalCount (absent → 0) three different readings expected true to be false
zero dropped as uninteresting (0 → absent) CONTROL + 2 projection tests expected undefined to be +0
unknown codes dropped, not folded 2 key-rebuild tests expected { 'spawn-ack-timeout': 4 } to deeply equal { …, other: 5 }
skip site records no failureCode 3 e2e tests expected { other: 1 } to deeply equal { 'spawn-ack-timeout': 1 }
incoming string used as key 2 leak tests expected '…' not to contain 'AR-350'

The must-fire/must-not-fire pair is explicitly non-interchangeable — the CONTROL asserts each throws when aimed at the other sweep, so a pass is evidence about the sweep rather than about a constant.

Local verification

Four assertions in factory.test.ts needed updating: toContainEqual on report.skipped is exact, so the new failureCode key broke them. They now pin the codes, which is free coverage of the classifier from #292's own scenarios.

Also rendered in factory diagnose, on its own line and including the zero, for the same reason it is published at all.

Refs #355, #358


Summary by cubic

Publishes structured reasons for failed dispatch attempts on the health surface. Previously skipReasons['dispatch-failed'] was only a count; now we emit dispatchFailures (zero-capable total) and dispatchFailureReasons (bounded, counts-only breakdown). Also fixes classification to detect provider overloads even when wrapped by context, reducing “unclassified” cases.

  • Adds a closed 14-code vocabulary in src/orchestrator/dispatch-failure-reason.ts; classifies at the skip site with bounded cause-chain matching, including phase codes unclassified-gate/triage/dispatch and allowlisted error class names; walks wrapped relayfile overloads.
  • Prevents leaks by rebuilding breakdown keys from the local vocabulary and folding unknowns into other; counts only—no issue keys, paths, or messages cross.
  • Keeps backward compatibility: dispatchFailures is optional and independent of the candidates/dispatched/skipped trio; zero survives round-trips; breakdown is shown only when the total is present.
  • Writes the new totals in factory.ts, tracks attempt phase, records failureCode on dispatch-failed skips, aggregates via factoryDispatchFailureReasonCounts, and exports helpers from src/index.ts.
  • Extends the public projection (public-health.ts) to normalize totals and breakdowns (drops orphan/invalid data) and renders them in src/cli/diagnose.ts.
  • Updates types (types.ts) to include dispatchFailures, dispatchFailureReasons, and per-item failureCode; updates tests and adds end-to-end coverage, including wrapped overload classification.

Written for commit 082675c. Summary will update on new commits.

Review in cubic

`skipReasons` (#358) told us the live container skips 27 candidates every
sweep and that 5 of them are `dispatch-failed` — with the control-plane
breaker closed, the fleet agent online and `readinessReconcile` healthy.
That bucket is a count. It says the sweep got all the way to dispatching
and dispatch threw; it does not say what threw, and the message that
would say so goes to the daemon's stdout, which does not reach the
deployed container's operator.

This is the second level of the same breakdown, built the way #358 built
the first:

- `dispatchFailures` is the total, published as a zero once a sweep
  completes. `skipReasons` omits zero counts, so on that field alone "every
  dispatch succeeded" and "this producer has never heard of dispatch
  failures" are the same absence — and 0.1.72 is in production being
  exactly the second thing.
- `dispatchFailureReasons` splits it by a closed vocabulary. Counts only,
  keys rebuilt from this side's own list rather than taken from the record,
  unknown codes folded into `other` so the parts still sum.

The code is recorded at the skip site from the thrown value, never parsed
back out of the operator-facing `reason` — a reworded message would
silently empty a bucket. Classification follows the cause chain, because
`contextualError` and the control-plane guard both rethrow wrapped.

When nothing named matches, the *phase* that threw is the answer:
`unclassified-gate` / `-triage` / `-dispatch`. A unit that failed in
triage never reached the fleet, and on a surface carrying no messages the
phase is the only thing left that still says who should look. #292's own
tests now pin those codes: the `TypeError: fetch failed` case from #291 is
`unclassified-triage`, not a fleet fault.

`dispatchFailures` is deliberately NOT joined to the all-or-nothing
candidates/dispatched/skipped trio. Requiring it would drop a 0.1.72
daemon's whole sweep block — deleting the counters that are currently the
only view of the outage.

Tests drive the real writer: a live daemon whose real dispatch really
throws. Ablated five ways — absent coerced to zero, zero dropped as
uninteresting, unknown codes dropped instead of folded, the skip site not
classifying, and the incoming string used as an object key — each caught
by the assertion that names it.

Refs #355, #358

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head a52b7c63b5da43a77952f7b319974741ea14a3f6.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 50 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2145b2d0-a05e-437c-8b79-98980520efab

📥 Commits

Reviewing files that changed from the base of the PR and between 628bcc6 and 082675c.

📒 Files selected for processing (9)
  • src/cli/diagnose.ts
  • src/index.ts
  • src/orchestrator/dispatch-failure-reason.ts
  • src/orchestrator/dispatch-failure-reasons.test.ts
  • src/orchestrator/factory.test.ts
  • src/orchestrator/factory.ts
  • src/orchestrator/public-health.test.ts
  • src/orchestrator/public-health.ts
  • src/types.ts

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a52b7c63b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/orchestrator/factory.ts
@khaliqgant

Copy link
Copy Markdown
Member Author

Two things before this merges, and the first one is my doing.

1. Rebase — the conflict is mine

mergeable: dirty, and a server-side update-branch returns 422 merge conflict between base and head. That is because I merged two PRs under you after you branched:

main is now 628bcc61. I flagged this sequencing risk on #359 and then merged both anyway while you were mid-flight, so the conflict is on me, not you. Rebase onto current main — and expect the collision in public-health.ts and types.ts specifically.

Watch for one thing while resolving. #359 added enumerationCountsInvalid, a third state meaning "counts were supplied but rejected as invalid", deliberately distinct from "no enumeration happened". Do not let a rebase collapse it into an absent field. It exists because I spent 40 minutes tonight reading twelve candidates: 0 samples that meant "did not look", not "found nothing", and that distinction is the whole reason this family of fields is trustworthy.

2. The live P2 at factory.ts:20986 — and it bears directly on your deliverable

"Traverse causes when classifying relayfile overloads." A 429-shaped overload arriving through a wrapper like #spawnAgent's contextualError is only examined at the outer plain Error, unlike every subsequent classifier branch.

This is not cosmetic for this PR. The entire point of dispatchFailureReasons is that the bucket is trustworthy. A classifier that misses a wrapped cause silently files a real overload under the wrong code — and we would then read a confident breakdown that points at the wrong fault. That is strictly worse than the dispatch-failed: 5 bucket we have now, because a wrong answer with a number next to it stops people looking.

Fix it, or decline with reasoning if you believe the wrapper cannot occur on this path — but if you decline, say what guarantees the cause is never wrapped.

While you are there, make sure other genuinely absorbs anything unclassified rather than a branch swallowing it, so the parts still sum to the total. factory-cloud#74 does this well and it is what lets a reader detect a newer producer instead of seeing wrong numbers.

CI is already completed/success at a52b7c63, so the rebase and this thread are the only things outstanding.

Context on why this is the last piece: everything upstream is fixed and verified in production. The live container gives candidates: 27, dispatched: 0, skipped: 27 with skipReasons: { not-ready: 21, parked-dependency: 1, dispatch-failed: 5 }. Five eligible issues attempt dispatch and fail, every sweep, with the breaker closed and the fleet agent online. Your enum is what names that.

khaliqgant and others added 2 commits August 24, 2026 05:56
Review follow-up (P2, codex). The overload branch called
`relayfileOverload(error)`, which reads the flat error object and does not
walk `cause` — so a 429 arriving through `#spawnAgent`'s `contextualError`
was published as `unclassified-dispatch`. The observation is correct.

The suggested fix — traverse the cause chain here — would make this
surface worse, not better. `relayfileOverload` is the same predicate that
decides which counter increments, whether the unit counts toward the
pass-abort fuse, and whether the durable overload ratchet advances. A
health record reporting `relayfile-overloaded` for a unit the loop handled
as an unexplained fault would tell an operator the overload machinery was
engaged when it was not.

So the code now takes the loop's verdict as an argument instead of
re-deriving it, and cannot disagree with it. Widening `relayfileOverload`
itself is the real fix for the underlying gap; it changes shedding
behaviour, so it is not this PR's to make.

Two tests pin both directions: an unwrapped 429 (through triage, the one
dispatch path that does not wrap what it throws) publishes
`relayfile-overloaded`, and the same 429 through the spawn — where the
wrapper hides it from the loop too — publishes `unclassified-dispatch`.
Applying the suggested traversal fails the second one.

Refs #355

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 082675cbdebf610327ffcb986ac78dd0a1191041.

@khaliqgant

Copy link
Copy Markdown
Member Author

@coderabbitai review

Please review exact current head 082675c after the bounded wrapped-overload fix and additive main merge.

@khaliqgant

Copy link
Copy Markdown
Member Author

Exact head 082675c also merges current main additively: dispatch failure totals/breakdown travel with the retained enumerating sweep, lastEnumeratedAtMs dates that snapshot, discoveryDeferred remains independent for the most recent pass, and enumerationCountsInvalid remains distinct from a genuine no-enumeration deferral. Combined health/timing tests 70/70; build and feature-map 323 pass; diff check is clean.

@khaliqgant

Copy link
Copy Markdown
Member Author

@codex review

Please review exact current head 082675c after the bounded wrapped-overload fix and additive main merge.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

@khaliqgant I will review pull request #361 at commit 082675cbdebf610327ffcb986ac78dd0a1191041.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 082675cbde

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@khaliqgant

Copy link
Copy Markdown
Member Author

Merging. This is the last instrument of the incident and it is built to the standard the previous four set.

The P2 fix is better than what I asked for. I asked you to traverse wrapped causes; you traversed them bounded:

const RELAYFILE_OVERLOAD_CAUSE_DEPTH = 4
const relayfileOverload = (error, depth = 0) => {
  if (depth > RELAYFILE_OVERLOAD_CAUSE_DEPTH) return undefined
  ...
  return relayfileOverload(flat.cause, depth + 1)
}

Unbounded .cause walking is a hang on a cyclic chain, and a hang inside a classifier would have been especially cruel tonight — we would have replaced an opaque bucket with a wedge. The comment naming why every branch follows the chain (contextualError and the control-plane guard both rethrow wrapped) is the part that stops someone unwinding it later.

Tests I checked specifically:

  • classifies through the wrapper, so a rethrown cause does not lose its name — injects a real wrapped RelaySpawnAckTimeoutError, so it fails if the traversal is removed
  • names the cause of a failed dispatch, and the parts sum to the bucket that counted it — the parts-sum invariant, which is what lets a reader detect a newer producer instead of silently seeing wrong numbers
  • separates a failure in triage from one in dispatch, because they are different owners — right distinction, and the reasoning is the useful half

And you preserved enumerationCountsInvalid through the rebase. I flagged that specifically because a careless resolution collapses it into an absent field, and it is the third state that separates "counts supplied but rejected" from "no enumeration happened". Confirmed present in types.ts at your head.

Bar cleared: 5/5 green at 082675cb, mergeable: clean, zero live threads, base contains 628bcc61.

The rebase you had to do was my fault — I merged #341 and #359 under you after flagging the sequencing risk myself. Thanks for absorbing it cleanly.

Next: release, bump, deploy, then one /evidence read tells us why five eligible issues fail dispatch every sweep. That is the last unknown in an outage that started 2026-08-16.

@khaliqgant
khaliqgant merged commit 579e05e into main Aug 24, 2026
9 checks passed
@khaliqgant
khaliqgant deleted the lane/dispatch-failure-reasons branch August 24, 2026 04:06
Sign up for free to 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.

1 participant