Skip to content

fix(providers): classify every model failure once - #2195

Merged
justinhelmer merged 5 commits into
mainfrom
fix/provider-failure-seam
Sep 22, 2026
Merged

justinhelmer merged 5 commits into
mainfrom
fix/provider-failure-seam

Conversation

@coreplane-switchboard

@coreplane-switchboard coreplane-switchboard Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Adds one typed provider-failure boundary across proxy, harness, intake, operator, and reflection calls. Recoverable outages preserve live turns while every requester surface receives a safe, disposition-correct sentence.

Why: Issue #2170 exposed classifiers that killed live work and leaked provider prose. Record 0075 establishes one boundary; this final fix round closes status spoofing, pre-run messaging, and duplicate Slack delivery.

Where to look

  1. Amended provider-failure decision Clarifies mandatory status precedence, trusted internal envelopes, and why requester wording depends on a live run.
  2. Disposition-aware requester rendering Only parked live work promises continuation; pre-run intake and operator failures say the request did not start. ⚠ Wrong surface selection can promise recovery for work that never began.
  3. Mandatory provider classification Authenticated envelopes stay typed while 402, 429, 5xx and transport failures outrank untrusted body signals. ⚠ A wrong precedence can end recoverable work or park a terminal call.
  4. Proxy retry and provider level Retries park-capable failures once, then reports the typed down cause and parks the run. ⚠ Incorrect retry or park decisions can duplicate calls or end live work.
  5. Follow-up turn hold Description, verdict, re-review and ordinary follow-up turns use the same bounded provider hold. ⚠ A missed settle transition can strand a post-loop turn.
  6. Slack delivery reconciliation Claims a stored failure atomically before checking Slack, releases rejected posts, and closes successful delivery. ⚠ A broken claim can lose or duplicate the requester-facing sentence.
  7. Durable delivery claim The state Worker serializes one receipt-keyed poster and permits recovery only after the bounded claim expires. ⚠ This is the cross-process exactly-once fence.

Feedback wanted: Please scrutinize trusted-envelope precedence and the claim/finish recovery boundary, especially ambiguous Slack posts and expired claimant takeover.

Risk: This PR spans 54 files and 2,689 changed lines. A bad classifier can end live work; a bad claim can lose or duplicate a Slack post. Revert the PR. The typed seam stayed cohesive; telemetry and config remain later plan units.

Verified: 589 focused root tests and 62 memory-Worker tests passed; scoped typechecks, lint, formatting and consistency gates passed. CI and final dual re-review remain gated.

Decisions (5)
  • Trust only the authenticated typed envelope. Rejected reading cause from provider JSON because providers control that body. The model-proxy bearer boundary is the only context that authenticates an internal provider_failure cause; mandatory provider statuses otherwise classify first.
  • Render by disposition. Rejected one continuation sentence for every park-capable cause because intake and operator run before recoverable work exists. The cause remains the wording owner, but only a parked live turn may promise continuation.
  • Persist delivery separately from the verdict. Rejected treating an intake decision receipt or a thread snapshot as proof of delivery. A durable atomic claim serializes posters; success closes delivery, rejection releases it, and a bounded claim recovers a dead poster.
  • Hold post-loop turns inside their bound. Rejected throwing recoverable follow-up failures after settle. Description, verdict, re-review and ordinary turns are live model calls and use the same bounded recovery as the initial loop.
  • Keep the accepted seam cohesive. The provider type, proxy, harness, doors, durable Slack delivery and their specs remain one reviewable failure boundary. Provider telemetry and deployment-time key refusal stay in later plan units rather than widening this rollback.
Validation (7 criteria)
Criterion Proof
Provider, intake, operator, Slack, ledger, proxy and harness regressions pass npx vitest run on 10 named touched/nearest files — 10 files, 589 tests passed at c9a90fd.
The durable Worker claim passes inside workerd npm test -w deploy/cloudflare-memory -- --run runLedger.test.ts — final run: 1 file, 62 tests passed.
Touched TypeScript graphs compile NODE_OPTIONS=--max-old-space-size=6144 npx tsc --noEmit -p tsconfig.json and the memory Worker tsconfig — both passed with no diagnostics.
Changed files pass lint and formatting npx eslint and npx prettier --check on all changed TypeScript/files — both passed.
Repository consistency checks pass npm run check:consistency — passed, including vocabulary, user-message, clock, specs, decisions, docs and hygiene checks.
The changelog title meets release vocabulary npm run check:pr-title -- "fix(providers): classify every model failure once" — passed.
Review regressions pin the three findings Tests cover contradicting 402/429 bodies, parked vs ending-safe wording, concurrent existing-receipt readers, and rejected-post catch-up delivery.
For agents

Final review-fix head c9a90fd is rebased on origin/main 9f6516d. The branch was force-with-lease updated exactly at fix/provider-failure-seam; no generated file was hand-edited. The first CI attempt exposed vocabulary and lint gates; both are folded into this commit and pass locally. F1–F3 are recorded fixed. CI and the final two re-review arms remain external acceptance gates.
Requested by @justinhelmer in slack:C0BRRHKFLCB

🤖 Generated with Claude Code

justinhelmer and others added 3 commits September 22, 2026 08:19
Co-Authored-By: coreplane-switchboard[bot] <318072483+coreplane-switchboard[bot]@users.noreply.github.com>
Co-Authored-By: coreplane-switchboard[bot] <318072483+coreplane-switchboard[bot]@users.noreply.github.com>
Co-authored-by: justinhelmer <1403438+justinhelmer@users.noreply.github.com>
Co-authored-by: coreplane-switchboard[bot] <318072483+coreplane-switchboard[bot]@users.noreply.github.com>
@coreplane-switchboard
coreplane-switchboard Bot force-pushed the fix/provider-failure-seam branch from bc2a134 to 313050b Compare September 22, 2026 08:22

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changes requested: Provider-down failures still end follow-up turns, and missing-key proxy diagnostics omit required operator context.

Warning

Changes requested · head 313050b · 2 findings: 1 major, 1 minor

Severity Finding Where
major F1 Spec contradiction — docs/reference/specs/model-proxy.md item 12a: follow-up provider-down turns end instead of parking src/core/harness/pi/harness.ts:2276
minor F2 Spec contradiction — docs/reference/specs/model-proxy.md item 12b: missing-key logs omit provider and variable src/channels/modelProxy.ts:763
Full review

F1: The new hold/retry flow only covers the initial loop. A provider-down failure during followUp() is assigned to turnFailure and thrown once the turn settles, so description, verdict, re-review, and ordinary follow-up turns can still end during a 402, 429, or transient outage. Route park-capable failures through the same lease-bounded hold/retry and provider-up release flow.

F2: The missing-key path logs only provider_key_missing and the run ID. Operators therefore cannot identify either the affected provider or its configured environment variable, despite item 12b requiring both. Log the constructed ProviderFailure diagnostic while keeping the requester response sanitized.

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changes requested: Two intake failure paths break the typed-cause and once-only rendering contracts.

Warning

Changes requested · head 313050b · 2 findings: 2 minor

Severity Finding Where
minor F1 Spec contradiction — docs/reference/specs/model-proxy.md item 12b: wrapped provider failures are reclassified as permanent src/core/provider.ts:320
minor F2 Spec contradiction — docs/reference/specs/routing-and-config.md item 27: a persisted failure can render zero times src/channels/slack.ts:945
Full review

F1: askStructured wraps a provider error in StructuredAskError after any prior malformed answer. providerFailureOf does not unwrap that error’s nested cause, so a later 402/rate-limit failure is reclassified from its original typed cause to permanent. Unwrap typed nested causes before falling back to classification, and add a regression covering malformed answer → typed provider failure.

F2: The intake receipt is persisted before Slack posts the failure sentence, but posting errors are swallowed and every later receipt: existing path suppresses delivery. If Slack rejects the first post or the process dies after insertion, the outage remains permanently invisible. Track delivery separately from the decision receipt and let catch-up retry or reconcile an undelivered failure.

Co-Authored-By: coreplane-switchboard[bot] <318072483+coreplane-switchboard[bot]@users.noreply.github.com>
@justinhelmer

Copy link
Copy Markdown
Contributor

Re-review requested at 339efc7 — both arms' findings at 313050b addressed: follow-up turns route park-capable provider failures (transient, rate-limited, credit-or-quota-exhausted) through the same lease-bounded hold/retry as the initial loop (model-proxy item 12a, major); the missing-key path logs the typed ProviderFailure diagnostic with provider and variable for operators while the requester sentence stays sanitized (item 12b); providerFailureOf unwraps a StructuredAskError's nested typed cause so a 402 after a malformed answer is not reclassified permanent (item 12b); intake failure-sentence delivery is tracked separately from the decision receipt so catch-up delivers an undelivered sentence once (routing-and-config item 27).

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changes requested: Two provider-failure paths can end or duplicate requests contrary to the typed disposition and first-writer contracts.

Warning

Changes requested · head 339efc7 · 2 findings: 1 major, 1 minor

Severity Finding Where
major F1 Untrusted body causes override mandatory HTTP 402/429 dispositions src/core/provider.ts:260
minor F2 Spec contradiction — slack-channel.md item 15: existing receipts can post the provider failure again src/channels/slack.ts:980
Full review

F1 — Major · High confidence: explicit is selected from the untrusted response body before HTTP status is considered. A 402 response containing { "cause": "permanent" } therefore ends the request instead of parking it as credit-or-quota-exhausted; the same problem affects 429. Give mandatory status classifications precedence, or trust explicit causes only from an authenticated internal typed envelope.

F2 — Minor · High confidence: gateThreadReply posts the failure sentence whenever providerFailure exists, including when decision.receipt === "existing". Concurrent processes can both read the thread before either post appears, causing duplicate messages even though item 15 requires only the first receipt writer to post. On the live path, render only for inserted, failed, or absent; leave existing-receipt recovery to catch-up reconciliation.

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changes requested: Two provider-failure paths violate the new recovery and once-only rendering contracts.

Warning

Changes requested · head 339efc7 · 2 findings: 1 major, 1 minor

Severity Finding Where
major F1 Spec contradiction — docs/reference/specs/model-proxy.md item 12b: no-lease failures promise automatic continuation src/core/provider.ts:167
minor F2 Spec contradiction — docs/reference/specs/slack-channel.md item 15: concurrent receipt readers can post the intake failure twice src/channels/slack.ts:542
Full review

F1 — The three park-capable renderings say the work “will continue,” but intake and operator failures occur before a run or lease exists. A transient or 402 during either door ends the request permanently while telling the requester to wait. Use an ending-safe rendering on no-lease surfaces, or provide actual durable recovery before promising continuation.

F2 — An existing provider-failure receipt is rendered after checking a non-atomic Slack thread snapshot. During a rollout, two generations can read the same receipt before either post lands, then both post the sentence. Add a durable delivery claim/receipt or another atomic idempotency mechanism so retries remain possible without concurrent duplicates.

@coreplane-switchboard
coreplane-switchboard Bot force-pushed the fix/provider-failure-seam branch 2 times, most recently from 6155952 to c9a90fd Compare September 22, 2026 09:44
@justinhelmer

Copy link
Copy Markdown
Contributor

Re-review requested at c9a90fd — the round-2 findings at 339efc7 addressed: mandatory status classifications (402, 429, 5xx/network) take precedence over any body-declared cause, and an explicit cause is trusted only from the internal typed envelope; no-lease door surfaces render an ending-safe sentence (the request did not start) and only a leased turn promises continuation (model-proxy item 12b); the failure sentence is delivered once through a durable delivery claim, live for inserted/failed/absent receipts and via catch-up for existing ones (slack-channel item 15). Also cleared the vocabulary check (no internal nouns on the user surface) and a lint error. CI green.

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changes requested: The typed cause boundary still trusts provider-controlled streamed errors as authenticated internal envelopes.

Warning

Changes requested · head c9a90fd · 1 finding: 1 major

Severity Finding Where
major F1 Spec contradiction — model-proxy.md item 12b: provider stream bodies are trusted as internal envelopes src/core/harness/pi/bridge.ts:342
Full review

F1 (major, high confidence): PiBridge sets trustedEnvelope: true for every pi error merely because traffic passed through the proxy. However, the proxy forwards successful SSE chunks unchanged, so an HTTP 200 provider stream can emit an error containing { type: "provider_failure", cause: ... }; the bridge then treats that provider-controlled cause as authenticated and may incorrectly park or end the run. Sanitize/classify streamed provider errors within the proxy, or carry an out-of-band authenticated marker that provider stream content cannot forge.

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

LGTM: The typed provider-failure seam, recovery paths, and durable intake delivery are coherent and match the updated behavioral contracts.

Note

Approved · head c9a90fd · no findings

Full review

No findings. The typed failure classification, park/end behavior, safe rendering, and durable intake delivery are consistent with the updated specs.

@github-actions github-actions 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.

Auto-approved: coreplane-switchboard[bot] reviewed this PR and posted an LGTM verdict (see its review). This repository opted in through its REVIEW_BOT_LOGIN and REVIEW_BOT_ID variables.

@coreplane-switchboard
coreplane-switchboard Bot force-pushed the fix/provider-failure-seam branch from c9a90fd to 0ffae3d Compare September 22, 2026 10:17
Co-Authored-By: coreplane-switchboard[bot] <318072483+coreplane-switchboard[bot]@users.noreply.github.com>
@coreplane-switchboard
coreplane-switchboard Bot force-pushed the fix/provider-failure-seam branch from 0ffae3d to a0b0021 Compare September 22, 2026 10:20

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changes requested: The intake timeout path still bypasses the new typed provider-failure seam and silently drops the outage.

Warning

Changes requested · head a0b0021 · 1 finding: 1 minor

Severity Finding Where
minor F1 Spec contradiction — model-proxy.md item 12b: intake timeouts remain silent instead of typed transient failures src/core/intake.ts:255
Full review

F1 (minor, high confidence) — AbortSignal.timeout(...) produces an AbortError or TimeoutError, but this branch returns an ordinary silent timeout without providerFailure. Slack therefore posts nothing, contradicting model-proxy item 12b’s requirement that every intake provider failure cross the typed seam and render the no-lease sentence. Classify the timeout as transient, preserve any structured attempts, and let the existing provider-failure delivery path post the safe sentence once.

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changes requested: A buffered provider-up control row can be replayed after a local follow-up retry succeeds.

Warning

Changes requested · head a0b0021 · 1 finding: 1 minor

Severity Finding Where
minor F1 Consume buffered provider-up rows when the local retry wins src/core/harness/pi/harness.ts:2380
Full review

F1: If a provider-up row arrives while a post-loop local retry is already in flight, the successful retry calls clearTurnProviderHold(), which discards turnReissueRows without advancing mirror.inboxConsumedSeq. After a restart, the durable row can be delivered again and prematurely release a later hold without a new provider-up transition. Mark the buffered rows consumed when the local retry wins, and add a regression covering provider-up during an in-flight local retry followed by restart.

@justinhelmer

Copy link
Copy Markdown
Contributor

Parked for the morning after four fix rounds (313050b339efc7c9a90fda0b0021; CI green at this head; the last two rounds carried one LGTM each). Round 4's two arms each left one minor: the intake timeout path still bypasses the typed provider-failure seam and drops the outage silently; a buffered provider-up control row can be replayed after a local follow-up retry has already succeeded. Findings converged 4 → 3 → 1 → 2 minors, so the shape is done and what is left is edge behavior. Two options: (a) merge this head (record 0075 + unit 1; one LGTM at c9a90fd's shape, approval carried) and file the two minors as follow-ups; or (b) one more round for the two minors, then two arms. No further rounds are posted tonight.

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