fix(providers): classify every model failure once - #2195
Conversation
63308bb to
be89b85
Compare
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>
bc2a134 to
313050b
Compare
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
6155952 to
c9a90fd
Compare
|
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
c9a90fd to
0ffae3d
Compare
Co-Authored-By: coreplane-switchboard[bot] <318072483+coreplane-switchboard[bot]@users.noreply.github.com>
0ffae3d to
a0b0021
Compare
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Parked for the morning after four fix rounds (313050b → 339efc7 → c9a90fd → a0b0021; 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. |
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
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)
causefrom provider JSON because providers control that body. The model-proxy bearer boundary is the only context that authenticates an internalprovider_failurecause; mandatory provider statuses otherwise classify first.Validation (7 criteria)
npx vitest runon 10 named touched/nearest files — 10 files, 589 tests passed at c9a90fd.npm test -w deploy/cloudflare-memory -- --run runLedger.test.ts— final run: 1 file, 62 tests passed.NODE_OPTIONS=--max-old-space-size=6144 npx tsc --noEmit -p tsconfig.jsonand the memory Worker tsconfig — both passed with no diagnostics.npx eslintandnpx prettier --checkon all changed TypeScript/files — both passed.npm run check:consistency— passed, including vocabulary, user-message, clock, specs, decisions, docs and hygiene checks.npm run check:pr-title -- "fix(providers): classify every model failure once"— passed.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