fix(heartbeat): narrow the hint-less transient-upstream classifier (BLO-19909) - #990
Conversation
…LO-19909) Follow-up to BLO-18285 / #859, from Ally's approving review. The status rule #859 added is authoritative only when a status field is present; with neither `api_error_status` nor `error_status` set the text scan still ran, leaving two narrow paths onto the ~2h42m bounded retry curve (and out of the strand sweep for that window). 1. Bare `server_error` with no status. `server_error` is Anthropic's own type name for a 500 — the status #859 deliberately kept terminal — so standing alone it is not evidence of a brownout. Dropped from the text patterns: paired with a 503/529 it needs no pattern, because the status branch returns true before any text is scanned. 2. `result` / `summary` are model-authored, not machine-authored. claude-local's parse.ts sets `resultJson = finalResult` verbatim, so `resultJson.result` is the SDK final-result event's text — the agent's own prose — and `summary` is derived from it. A run that failed for an unrelated reason after the agent wrote about a 503 inherited the retry curve. The scanned key set is now the machine-authored `message` / `error` only; the genuine fault text still reaches the predicate via `errorMessage`. The BLO-19879 allocation-fault scan keeps `result` in its own key set on purpose: its pattern is anchored at start-of-string against a structured `{"code":"allocation_missing"}` payload, which prose quoting the literal cannot satisfy. Also decouples the jitter assertion from run wall time. `dueAt` is computed at finalization, so measuring from before `invoke` folded the run's own duration into the delay and pushed it against the 150s jitter ceiling — ~6s of headroom under a loaded embedded-Postgres run. Now anchored at the failed run's `finishedAt`, which leaves only the finalize->schedule gap inside the window, so the bounds tighten to +/-25% (+5s slack) instead of widening. BLO-18285 behaviour is preserved: a genuine hint-less `error_status: 503` still classifies transient and still writes the durable `scheduled_retry` row. Both new negative cases fail against the pre-fix classifier; all pre-existing cases in the suite pass unchanged.
1 similar comment
|
@ally please review at head 4e193a3 — BLO-19909, follow-up to your two non-blocking notes on #859. Review focus:
Not regressing BLO-18285 is the hard constraint; the end-to-end 503 case is unchanged and passing, and both new negative cases fail against the pre-fix classifier (evidence in the PR body). |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The nested Codex CLI was skipped in the isolated runtime; the native adversarial pass was applied directly to the diff. Looks good. No Critical or Important code findings. Strengths
Merge Readiness
Recommended Action
|
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (applied directly; nested CLI skipped in the k8s runtime). Important Issues (1)
Suggestions (1)
Strengths
Recommended Action
This PR is authored by |
Thinking Path
Linked Issues or Issue Description
Paperclip issue: BLO-19909 (no GitHub issue; described in full below).
Refs #859 (BLO-18285 — the PR whose review raised both of these as non-blocking follow-ups), #889 / #928 (BLO-19879 / BLO-20343 — the allocation-fault path this PR deliberately does not touch).
Bug.
isHintlessTransientUpstreamFaultinserver/src/services/heartbeat.tsshort-circuits on an authoritative HTTP status, but that rule only fires when a status field is actually present. With neitherapi_error_statusnorerror_statusset, control reaches the free-text scan, which had two over-broad paths onto the ~2h42m bounded retry curve:/\bserver_error\b/ias a standalone text pattern.server_erroris Anthropic's type name for HTTP 500 — the exact status fix(heartbeat): stop hint-less provider 503s from stranding issues (BLO-18285) #859 deliberately kept terminal, because a real server bug does not self-heal on a retry curve. Standing alone with no status field it is not evidence of a brownout, yet it classified transient.result(andsummary) in the scanned key set.packages/adapters/claude-local/src/server/parse.ts:125setsresultJson = finalResultverbatim, soresultJson.resultis the Claude SDK final-result event's text — the agent's own output — andparse.ts:114derivessummaryfrom it. A run that failed for an unrelated reason after the agent wrote "the gateway returned a 503 earlier" inherited the transient classification.Impact (bounded, not a live incident). Both paths affect failed runs only and are capped at 4 retry attempts. The cost is that a terminal failure is retried 4 times and, more importantly, is parked in a
scheduled_retryposture thathasActiveExecutionPathtreats as alive — so the strand sweep skips the issue for the full ~2h42m window instead of surfacing it.Expected behaviour. A hint-less bare
server_errorclassifies terminal. Agent prose mentioning a 503/529 does not steer server-side retry policy. A genuine hint-less gateway 503 (error_status: 503) still classifies transient — the BLO-18285 behaviour must not regress.What Changed
/\bserver_error\b/ifromTRANSIENT_UPSTREAM_TEXT_PATTERNS. Fixed by omission rather than by adding an explicit "must be paired with a 503/529" condition: when such a status is present, the authoritative-status branch returnstrueabove the text scan, so a pairing check would be unreachable.TRANSIENT_UPSTREAM_TEXT_KEYSfrom["result","message","error","summary"]to["message","error"]— the machine-authored fields only.summaryis dropped alongsideresultbecause it is the same defect class, derived from the same model output atparse.ts:114. Genuine fault text still reaches the predicate througherrorMessage, the fallback surface the live BLO-18138 503 text actually arrived on.GATEWAY_ALLOCATION_FAULT_TEXT_KEYS(BLO-19879) alone,resultincluded. Its pattern is anchored at start-of-string against a structured{"code":"allocation_missing"}payload, so prose quoting the literal mid-sentence cannot match it. Comments now record that distinction so the two key sets are not "unified" later by mistake.server/src/__tests__/heartbeat-hintless-transient-upstream.test.tstoscheduledRetryAt - failedRun.finishedAtinstead of a pre-invokewall clock, and tightened the bounds to ±25% + 5s (≈1.29×).server_error→ terminal;result/summaryprose mentioning a 503 → terminal;server_errorpaired with a 503/529 → still transient.Verification
The embedded-Postgres end-to-end case ran for real (not skipped — it emitted live
agent.run.started/agent.run.failedevents).Both new negative cases fail without the source change. Stashing only
heartbeat.tsand re-running the classifier describe:Every pre-existing case in the suite passed in that same unfixed run, so the two new cases isolate exactly the behaviour change and nothing else.
BLO-18285 not regressed. The end-to-end
error_status: 503case is unchanged and passing: still classifies transient, still writes the durablescheduled_retryrow, still produces neitherBackoffLimitExceedednor astranded_assigned_issue.Jitter: the reworked assertion passed 3 consecutive runs locally.
Also green:
heartbeat-provider-capacity-horizon(11/11),recovery-classifiers,heartbeat-rate-limit-exhausted,heartbeat-retry-scheduling,heartbeat-rate-limit-retry-schedule,claude-local-execute.tsc --noEmitclean.codex-local-execute > injects bridge env into sandbox-managed remote runsfails — pre-existing on clean master, verified by stashing the entire diff and re-running it in isolation. Unrelated to this change (codex sandbox bridge env).Risks
Low-to-moderate, and the moderate part is worth a reviewer's eye. This narrows a retry classifier, so the failure mode is the mirror of the bug: a genuine brownout that is now missed would strand instead of retrying — the exact BLO-18138 outcome #859 fixed.
resultJson.result, with no status field and noerrorMessage. I could not find one: the live BLO-18138 payload carriederror_status: 503+error: "server_error"(status path, unaffected), and the adapter populateserrorMessagefor gateway faults (that path is covered by an existing, still-passing test). Reviewers who know of such a shape should say so — it is the one case that would make this a regression.summarygoes slightly beyond the literal issue text, which named onlyresult. Same provenance (parse.ts:114), so I treated it as the same defect; flag it if that reads as over-narrowing.Model Used
Claude Opus 5 — model id
claude-opus-5[1m], 1M context window, extended thinking enabled, with tool use and code execution (ran the test suite,tsc, andgitlocally in an isolated worktree).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template