Repository navigation
Conversation
Fixes awslabs#638 A provider that cannot load a model answers the *transport* perfectly: the CLI prints the refusal, exits cleanly, and the terminal reaches COMPLETED. The refusal text then sits exactly where the model's answer belongs, so the step was reported `completed` and the workflow wrote the provider's error into a user-facing deliverable as if it were the answer. Nothing in a step's own self-report distinguishes "the model answered" from "the model refused to load", so the runtime now classifies it: - `services/provider_error_classifier.py` matches a fixed, ordered table of provider-error chrome. It is start-anchored (only the first non-empty line is tested) and length-bounded (`PROVIDER_ERROR_MAX_CHARS` = 512), so a long legitimate answer that merely quotes an error string is never misclassified. - `run_agent_step` classifies the extracted message BEFORE building its result and raises `StepExecutionError(kind="provider_error")`, which is what the substrate's "returns ONLY on success" contract already required. The terminal is left alive, mirroring the `kind="error"` crash path. - Because the step never settles COMPLETED, no replayable `output_json` is journalled — so a replay-safe resume RE-EXECUTES the step instead of serving the error text forever. The raw provider text stays retrievable on the failure. - `StepResult.error_kind` (additive) carries the kind onto the run result, so `cao workflow status` and the API distinguish a provider refusal from a worker crash and from a timeout. - The run-step route maps `provider_error` to 502 Bad Gateway, not 504: the upstream refused the call, so waiting longer cannot help. Tests: a red test proved the pre-fix path returned the refusal as a successful `AgentStepResult`; it now raises. Coverage pins the classifier's conservatism (quoting, length bound, anchoring), the normal success path, and the journal row (`failed` / `provider_error` / `output_json IS NULL`).
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The classifier has false-positive and missed-signature cases, truncates multiline refusal details, and does not fully satisfy the status-display requirement.
Review effort: Balanced
Findings: 4
Open (5)
Preserve full provider output in workflow exceptions · New Require provider chrome colon to avoid API Error false positives · New Match 429 status code boundary for rate-limit responses · New Display structured error kind in workflow status · New Add API coverage for provider_error 502 mapping · New
What changed in this PR
Classifies in-band provider refusals as workflow failures rather than successful outputs.
Changes:
- Adds bounded, signature-based provider-error classification.
- Propagates structured
provider_errorfailure metadata through workflow results. - Maps provider failures to HTTP 502 and adds regression tests.
| File | Description |
|---|---|
provider_error_classifier.py |
Defines provider-error signatures and guards. |
agent_step.py |
Raises structured errors for classified output. |
workflow_service.py |
Tracks and publishes step error kinds. |
workflow_runtime.py |
Adds StepResult.error_kind. |
api/main.py |
Exposes error kinds and maps provider errors to 502. |
test_provider_error_classifier.py |
Tests classifier positives and guards. |
test_agent_step.py |
Tests agent-step classification behavior. |
test_workflow_service.py |
Tests failure persistence and replay prevention. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Require API-error chrome, classify 429 variants, preserve bounded multiline provider detail on the exception, render error_kind in status, and cover the provider-error HTTP mapping. Add false-positive and multiline regressions. Signed-off-by: Harbor404 <2657212322@qq.com>
|
Addressed in dc58388:
Verification: |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #849 +/- ##
=======================================
Coverage ? 92.55%
=======================================
Files ? 242
Lines ? 40485
Branches ? 0
=======================================
Hits ? 37469
Misses ? 3016
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
The only failing check is the repository-wide Security Scan, which is also failing on |
|
Noting the The This PR touches only application code and tests:
Everything else on this PR is green (19 checks passing, including the Python matrix, lint, |
|
Latest head |
|
|
haofeif
left a comment
There was a problem hiding this comment.
Reviewed head ccc74133705cdee53504dd01c22f3ca4ea959461 against base bd86e5f4a8720d26eba16f14fa49e08ad3e5f3f5.
Two P2 findings. Ordinary successful answers can be reclassified as provider failures, and the new structured kind is lost on the Python-script settlement path despite the immediate HTTP response correctly reporting it. The earlier specific colon/429/detail fixes, CLI renderer change, and request-level 502 coverage are present; the remaining general-classification and script-plumbing issues are detailed inline.
Avoid classifying ordinary numeric or natural-language answers as provider failures, and carry StepExecutionError.kind through script settlement into the in-memory and durable step records. Signed-off-by: Harbor404 <2657212322@qq.com>
Signed-off-by: Harbor404 <2657212322@qq.com>
fanhongy
left a comment
There was a problem hiding this comment.
Review of PR #849 at 00012961bb36ec4faded9593e543823c5d3fefb8
Summary
The fix is in the right place. run_agent_step is the shared step substrate, so the YAML engine, /terminals/run-step and handoff all get it. It reuses the existing workflow_run_step.error_kind column, so there is no migration.
Since ccc74133, the script-tier settlement work from the open "carry the provider error kind" thread is complete. The kind now reaches:
- the in-memory step;
- the durable row;
- live and cold
GET /workflows/runs/{id}; - the
_finalizeresult.
The new tests fail against the ccc74133 source, so they do pin that behaviour.
Blocking problem: the head breaks an existing test that CI runs.
Other problems:
- The
match→fullmatchrewrite in00012961dropped theUnknown model: <id>refusal form. - Plausible one-line answers are still classified as refusals.
- Persisting
error_kindfor every script-tierStepExecutionErrorchanges the run-levelkindof successful script runs. - A test edit silently moved an existing assertion into the new test.
Findings
P1: The settle_step signature change breaks an existing pinned test, so CI's unit-test job will fail
src/cli_agent_orchestrator/services/workflow_journal.py:1167
The problem. The new error_kind parameter breaks test/services/test_journal_step_lifecycle.py::test_settle_step_signature_has_no_attempts_parameter_and_returns_bool (L495-517). That test pins list(inspect.signature(settle_step).parameters) to the seven base parameters.
Evidence.
- At head the test fails:
AssertionError: ... Left contains one more item: 'error_kind'. - On base
bd86e5f4it passes. ci.ymlruns all oftest/, so the job will go red.- The file is not one of the seven the PR touches, so the "328 passed" focused run never exercised it.
- No CI has run since
ccc74133, which predates the parameter.
Fix.
- Add
"error_kind"to the pinned list, keeping the"attempts" not in sig.parametersassertion. That assertion is the actual BR-6 intent of the test. - Document the new parameter in the
settle_stepdocstring.
P2: Unknown/Invalid/Unsupported model: <id> refusals are no longer classified
src/cli_agent_orchestrator/services/provider_error_classifier.py:72
The problem. The pattern model\s+(?:['"][^'"`\n]+['"`]|[:=].+)requires whitespace before the[:=]alternative. Withfullmatch, the natural colon forms all return None`:
Unknown model: gpt-5.6-terraInvalid model: gpt-5.6-terraUnsupported model: gpt-5.6-terra
Meanwhile, Unknown model : x and Unknown model = x do match.
History. Commit 39416e2 (model\b\s*[:'"]) classified all three colon forms; 0001296` regressed them.
Impact. When the classifier misses a refusal, the step settles COMPLETED with the refusal text as its replayable output. That is exactly the #638 failure. No test uses the colon form, so the suite stays green.
Fix. Use:
(?:Unknown|Unsupported|Invalid|Undefined) model(?:\s+['"`][^'"`\n]+['"`]|\s*[:=]\s*.+)
Also add "Unknown model: gpt-5.6-terra" (and the Invalid/Unsupported variants) to _REFUSALS.
P2: Plausible one-line answers are still classified as provider failures
src/cli_agent_orchestrator/services/provider_error_classifier.py:62 (and the rows at L76-L100)
This is what remains of the open "do not classify ordinary short answers" thread at this head. Two paths are still open:
- The
:.*rows.api_error(L62) andconnection_error(L100) end in:.*. Underfullmatchthat is identical to a prefix match, so any first line startingAPI Error:,APIError:orConnectionError:is classified. One example:API Error: none found - all 42 endpoints return 2xx. - The vocabulary rows. These accept complete one-line answers, for example:
Authentication failed.Rate limit exceeded.Quota exceeded.rate_limit = 100Model weights not found.Model validation is not supported.Unknown model 'gpt-4o-mini'
In neither path does provider take part in the verdict, because no row sets providers=.
Repro. I drove Authentication failed., Rate limit exceeded., Model weights not found. and the API Error: none found … line through the real run_agent_step with claude_code, mocking the terminal layer as TestInBandProviderError does. Each one raised StepExecutionError(kind="provider_error"), and the terminal was not torn down.
Impact. Take a worker asked to run an auth or rate-limit check and "answer in one line":
/terminals/run-stepreturns 502.- Every handoff returns
success=False. - The YAML engine retries up to 4 attempts. Each attempt creates a fresh terminal (
teardown=True) and leaves it alive, because this raise skips teardown.
Fix.
- Scope the vocabulary rows, via
providers=, to the adapters that actually print them. - Anchor
api_errorandconnection_erroron adapter chrome rather than on:.*. One way is to require the parenthesised model id or an HTTP status afterAPI Error. - Add the lines above to
_NON_REFUSALS.
P2: Successful script runs now report a failure kind from /workflows/runs/{id}/result
src/cli_agent_orchestrator/api/main.py:4558 (with src/cli_agent_orchestrator/services/script_runner.py:820 and :869)
The problem. The script tier now persists a durable error_kind for every StepExecutionError, including error and timeout, not only provider_error. The unchanged _resolve_error_kind (api/main.py:6521) reads that column before it looks at the run state.
So take a script that catches ShimHTTPError for one step and exits 0. That is the documented pattern in examples/workflow/workflow.py:114. Its run now resolves to a failure kind.
Repro. A scratch test used the real /terminals/run-step route:
- Step
s1raisesStepExecutionError(kind="error"). - Step
s2succeeds. - The run is set to
completedand the registry entry is popped. GET /workflows/runs/{id}/resultis called.
| Tree | state |
kind |
|---|---|---|
base bd86e5f4 |
completed |
None |
| head | completed |
'error' ('provider_error' for a refusal) |
Impact.
- The live blocking result for the same run comes from
_finalize(state=COMPLETED, kind=None), so the live and cold reads now disagree. cao workflow resultprintsKind: errorunderState: completed.- This contradicts the resolver's documented RP-4 rule: "no kind is ever fabricated for a completed/non-terminal run".
- The same ordering lets a failed row's kind replace
"cancelled"on a CANCELLED script run. - YAML
on_failure: continueruns could already reach this resolver state. This PR is what newly exposes the script tier.
Fix.
- In
_resolve_error_kind, branch on the run state first:- COMPLETED or non-terminal →
None; - CANCELLED →
"cancelled"; - FAILED → the durable kind, otherwise the inference.
- COMPLETED or non-terminal →
- Add a script-tier regression test for a completed run that contains a caught failed step.
P2: An existing test silently lost its attempts assertion
test/services/test_script_runner.py:1121
The problem. test_script_result_preserves_provider_error_kind was inserted above the last line of test_completion_creates_step_state_when_missing.
- On base, that test ends with
assert record.step_states["s1"].attempts == 1. - At head, the same line is the last line of the new test.
The missing-seed settle path is covered only by test_completion_creates_step_state_when_missing, and it no longer checks the attempt count. Both tests still pass, so the loss is invisible.
Fix. Restore the assertion at the end of test_completion_creates_step_state_when_missing (L1076-1088). The new test can keep its own copy.
P3: Comments and docstrings that no longer match the code
src/cli_agent_orchestrator/services/agent_step.py:809-811: saysreplay_single_step"need[s] the live pane". In factreplay_single_steptears downe.terminal_idon everyStepExecutionError(workflow_service.py:1830).src/cli_agent_orchestrator/services/agent_step.py:16-21and:574-577: the "Failure contract" andRaises:sections still list onlytimeoutanderror.src/cli_agent_orchestrator/services/provider_error_classifier.py:37: says "One start-anchored provider-error signature", but matching is nowfullmatch.src/cli_agent_orchestrator/services/script_runner.py:752: says "THE CALLBACK TAKES(terminal_id, error, last_message, response_status=None)", but it now also takeserror_kind.src/cli_agent_orchestrator/models/workflow_runtime.py:244-248: says "so no existing response shape changes". In factmodel_dump()now emitserror_kind: nullon every step of every/resultand--jsonbody, including successful runs.
Validation
All runs were local and read-only, against the head checkout, with uv run --no-sync and -p no:cacheprovider.
| Check | Result |
|---|---|
The PR's 7 test files: test_provider_error_classifier.py, test_agent_step.py, test_workflow_service.py, test_run_step.py, cli/commands/test_workflow.py, test_script_runner.py, test_settlement_rewire.py |
328 passed |
test/services/test_journal_step_lifecycle.py |
head: 1 failed, 29 passed (P1). Base bd86e5f4: the pinned test passes. |
pytest test/services test/api test/cli test/cao_workflow examples/workflow/tests/ -m "not e2e" |
2 failed, 6221 passed, 10 skipped. The failures are the P1 test and test_launch.py::test_launch_builtin_profile_resolves_role_defaults. The latter fails identically on base (a local agent-store override, so environmental). |
black --check and isort --check-only on the 15 changed files |
clean |
mypy src/ |
head 153 errors in 17 files. The error set is identical to base with line numbers stripped, so there are no new errors. |
| Classifier probe at head (scratch) | Colon-form model refusals are missed (P2). The one-line answers listed above are classified. |
run_agent_step false-positive repro (scratch, terminal layer mocked) |
4 of 4 one-line answers raise provider_error, with no teardown. |
Run-level kind repro through /terminals/run-step (scratch) |
Completed script run: base kind=None, head kind='error' or 'provider_error'. |
Not verified:
- Behaviour against a real provider CLI; all coverage, including mine, mocks the terminal layer.
- Windows.
- CI has not run on
39416e2or00012961.
Residual risk:
- The YAML journal
errorcolumn, the run-step 502detail.message, job state and handoff messages now carry up to 512 characters of unredacted worker or provider text. Only the script-tier journal row goes through_sanitise_error. - A refusal shape outside the table still falls back to the pre-fix behaviour.
…ence Keep the additive settle_step error_kind parameter compatible with the pinned signature, restore colon-form model refusals without classifying ordinary one-line answers, and make run-level kind state-first so completed script runs stay successful while per-step provider errors remain visible. Signed-off-by: Harbor404 <2657212322@qq.com>
|
Addressed in
Verification:
No force-push was used. |
There was a problem hiding this comment.
Re-reviewed d1a8b6cb503a1134e3d62473995e4fdf5cc5a74d against bd86e5f4a8720d26eba16f14fa49e08ad3e5f3f5. New feedback received during closeout was reconciled at the same head.
Two P2 findings:
- Assistant answers still become provider failures.
services/provider_error_classifier.py:64and:78accept arbitrary prose after a three-digit value or a model label. Actual Claude Code extraction ofAPI Error: 404 is the response for an unknown route.,API Error: 200 endpoints were audited; none failed., andUnknown model: a model type absent from the serializer registry.yields valid assistant text, but the realrun_agent_stepraisesprovider_errorafter a normally completed terminal. Provider scoping and punctuation do not establish error origin. Tracked in the existing classification thread; the new non-error-status comment is a subset of this issue. - Cold FAILED-script results misattribute the run's cause.
api/main.py:6551-6554promotes an earlier caught step's kind over the script's terminal outcome. After a caught provider refusal or readiness timeout, a real Python subprocess exiting 1 for an unrelated author-side exception produces a live_drive_processresult withkind="error"; the registry-cleared result instead saysprovider_errorortimeout. The no-prior-step and ordinary-error controls agree. This verifies the new run-cause thread. Preserve the terminal run verdict separately from retained step diagnostics, or establish that the selected step actually caused the run failure.
The original script-settlement P2 is fixed: the structured kind reaches state, journal, live/cold step results, inspection, and CLI rendering. Completed/cancelled run verdicts are also preserved. The pinned settlement signature, colon-form refusal coverage, restored attempt-count assertion, and directly discussed docstring corrections are present. The FAILED-run attribution above is a separate remaining boundary.
| # FAILED only: durable kind is authoritative when present. | ||
| durable = _durable_error_kind(steps) | ||
| if durable is not None: | ||
| return durable |
There was a problem hiding this comment.
Verified at d1a8b6cb503a1134e3d62473995e4fdf5cc5a74d through the real /terminals/run-step settlement and _drive_process, including a real Python subprocess that exits 1 with an unrelated author-side exception. An earlier caught provider_error step gives live run kind error but cold kind provider_error; an earlier caught readiness timeout gives live error but cold timeout. No-prior-step and ordinary-error controls keep error on both surfaces. The state-first change fixes COMPLETED/CANCELLED, not this FAILED-run cause mismatch. Included as a second P2 in the updated review.
There was a problem hiding this comment.
Fixed in de5bb288: workflow_run.kind is persisted as the run-level terminal verdict; _resolve_error_kind now prioritizes that run kind for FAILED rows and only falls back to the step error_kind for legacy rows. Added a regression where a caught provider_error (and likewise a caught timeout) precedes an unrelated nonzero script exit: live and cold results both report run-level error, while the retained step diagnostic remains available. Focused tests: 288 passed.
There was a problem hiding this comment.
Fixed in de5bb288e4cc03e30f83087d81b10f7bbc39c861. Added nullable run-level workflow_run.kind, persisted by _finalize for every terminal script verdict; FAILED cold reads now prefer that run-level kind over any retained step kind. Regression covers caught provider_error and caught readiness timeout followed by an unrelated nonzero exit: live and cold results both remain error, while the step keeps its original diagnostic kind. Verification: focused 488 passed; provider/services/API/CLI/examples 8596 passed, 19 skipped, 74 deselected, 1 xfailed; full CI-equivalent pytest 13049 passed, 45 skipped, 21 deselected, 1 xfailed; black/isort clean; mypy baseline unchanged. No force-push.
|
Latest head |
gutosantos82
left a comment
There was a problem hiding this comment.
PR Review: #849 — fix(workflow): classify in-band provider errors as step failures
Summary
Re-review at head de5bb288. The two fixes maintainers asked for are present and correct for the providers they target: run_agent_step now passes the raw adapter capture to classify_provider_error, and a match is rejected when the last rendered occurrence is owned by the assistant marker (claude_code ⏺/●, codex •/assistant:), so the three concrete claude_code prose examples from the open thread now complete normally; and a nullable run-level workflow_run.kind column, persisted by _finalize, makes _resolve_error_kind state-first so a caught provider_error step no longer misattributes an unrelated nonzero script exit on cold reads. The migration, journal plumbing, 502 mapping, CLI rendering, and safe fallbacks (None context, >512 chars, non-first-line) all check out, and the new behaviours are pinned by tests that fail on the previous head. One defect blocks: _provider_owns_error_line only knows markers for claude_code and codex, yet signature rows are also scoped to grok_cli, mcode, and kimi_cli; for those the guard can never set assistant_owned, so it returns found on any bare line — the exact false positive the maintainers flagged is still reachable for grok_cli, and the function's docstring says the opposite of what the code does. Request changes for that, and because a human CHANGES_REQUESTED is live at a head no maintainer has re-reviewed.
Blocking (must fix before merge)
- [correctness]
src/cli_agent_orchestrator/services/provider_error_classifier.py_ASSISTANT_MARKERS+_provider_owns_error_line(if marker is None: continuebranch) — Introduced._ASSISTANT_MARKERShas entries only forclaude_codeandcodex, but_ROWSscopesmodel_not_availableto grok_cli,auth_or_quotato grok_cli and mcode, andconnection_errorto kimi_cli. Whenmarker is Nonethe loopcontinues past the marker test,assistant_ownedcan never become True, and the function returnsfound and not assistant_owned→ effectivelyfound, which is always True because the extracted first line came from that same capture. The docstring asserts "No marker vocabulary for a provider means no positive ownership evidence, so the caller must not classify it" — the code does the reverse. Reachability is concrete: grok'sextract_last_message_from_script(providers/grok_cli.py:1288) renders answers as plain unmarked text, andclassify_provider_error('grok_cli', 'Unknown model: a model type absent from the serializer registry.', script_output='Unknown model: …\n>')returns a match; likewiseclassify_provider_error('grok_cli', 'Authentication failed: no credentials configured', script_output=<bare line>). So the ownership defence that resolves the open P2 for claude_code/codex does not reach 3 of the 5 signatured providers. kimi_cli already has_KIMI_RESPONSE_MARKER_RE = ^[•●]\s(providers/kimi_cli.py:783) that the classifier simply never registers, which suggests a wiring oversight rather than intent. Fix: either (a)if marker is None: return Falseto match the docstring (making grok/mcode/kimi rows inert until markers exist), or (b) register the real markers for those adapters (and add a test driving a marker-less provider throughclassify_provider_error(..., script_output=…)). Path-weighted (services/, sharedrun_agent_step/handoff path): blocking.
Important (should fix)
- [conventions]
src/cli_agent_orchestrator/services/terminal_service.py:3498get_output_context— Introduced.logger.debug("get_output_context: %s unavailable for %s: %s", terminal_id, exc)has three%splaceholders and two arguments; when the except path fires, logging emits a format error instead of the diagnostic. Drop one placeholder ("get_output_context: %s unavailable: %s"). - [consistency] PR description ↔ implementation — The body does not mention either load-bearing change at this head: the raw-capture ownership check (
_ASSISTANT_MARKERS,_provider_owns_error_line,terminal_service.get_output_context, thescript_output=argument) or the run-levelworkflow_run.kindcolumn (clients/database.pymigration,RunRow.kind,update_run_state, the state-first_resolve_error_kind). The "Changes" section still lists only "three independent guards" andproviders=; "Notes for Reviewer" says 348 insertions / a 121-line module / 162 lines of tests, but the diff is +1082/−58, the classifier is 212 lines, and 10 test files are touched. A maintainer reading the body cannot learn how their two conditions were addressed. Rewrite Summary/Changes/Notes to this head. - [consistency]
provider_error_classifier.py_ASSISTANT_MARKERSvsproviders/codex.py:45ASSISTANT_PREFIX_PATTERNandproviders/claude_code.py:104RESPONSE_MARKER_PATTERN— Introduced. The marker table is a second private copy of knowledge each adapter already owns; claude_code has already migrated its glyph once (⏺→●, GH #459) and nothing couples the two copies. The classifier's own docstring calls itself "the runtime-side companion ofBaseProvider.get_error_message" (providers/base.py:191, already overridden inkimi_cli.py:2227) yet never consults it, so kimi now has two mechanisms answering the same question. At minimum: import the providers' patterns (or add a test asserting equality); better, route ownership through the provider instance. - [correctness]
services/agent_step.py~L820 cross-pipeline equality —last_messagecomes fromget_output(LAST)(tmux capture-pane render + provider extraction) whilescript_outputcomes fromget_output_context(status-monitor history buffer);_provider_owns_error_linecomparesline == error_line/line[match.end():].strip() == error_lineacross these two renderings.strip_terminal_escapescollapses\r→\nand forward-cursor CSI to a single space, which need not reproduce capture-pane spacing. Divergence mostly biases to a safe miss, but a diverged marked line plus a bare duplicate elsewhere flips to a false positive. Every test hand-craftsscript_outputto contain the line verbatim, so this has no coverage; the body itself notes "not verified against a real CLI end-to-end". A real-adapter check for codex and claude_code would close this. - [conventions]
CHANGELOG.md## [Unreleased]— Still no entry (noted at the prior head). The repo hand-maintains### Fixedwith issue-cited bullets; this changes user-visible behaviour (refusal → FAILED step, 502 instead of 504 on run-step, newerror_kind/run-levelkindon/result,GET /workflows/runs/{id}andcao workflow status). Add a### Fixedbullet citing #638. - [tests]
test/services/test_provider_error_classifier.py— No test drives a marker-less in-table provider through the production 3-arg path (classify_provider_error(provider, output, script_output=…)); every grok_cli/mcode/kimi_cli test uses the 2-arg form, which is why the blocking defect is unpinned. Add one per provider (and the fixed expectation). Also carried forward:providers=scoping is tested only viaProviderErrorSignature.applies_todirectly; no test feeds the same string to an eligible and an ineligible provider throughclassify_provider_error, so dropping theapplies_tocall inside the classify loop would pass. - [correctness]
services/agent_step.py+ engine retry — Every family raises uniformkind="provider_error", so the engine retries deterministic refusals (unknown model, invalid key) like transient ones, relaunching a terminal per attempt and leaving each failed pane alive (matching the crash-path contract); a halt step with N retries can leave N live terminals. Theslugalready distinguishes transient from permanent but is discarded at the raise. Carry it (or aretryableflag). Acceptable as a follow-up.
Nits (optional)
- [conventions]
docs/api.md:262— Zero docs touched. The run-step section documentserror/error_kindand the 200/4xx mapping but not the newprovider_error→ 502 mapping, norprovider_erroras a kind value. One line keeps it in step (CODEBASE.md documentation-maintenance rule). - [consistency] "guards" count — PR body says three, the module docstring (L14–20) says four, and neither counts the ownership check, which is now the central false-positive defence. Pick one framing and include ownership.
- [consistency]
ProviderErrorMatch.line/.provider— Set inclassify_provider_error, read only by tests (production reads.slug/.detail/.kind). The prior-head flags onproviders/applies_to/.kindare now resolved. - [tests]
test/services/test_workflow_service.py— The two new in-memory clears (st.error_kind = Noneon the cancel/SKIPPED path:801and after a retried step settles:848) are executed but unasserted; removing either lets_build_result(:950) surface a stale kind with no test failing. - [tests] no-raw-context fallback — No dedicated test feeds a would-match output with
script_output=Noneand asserts the step COMPLETES (the documented degrade-to-today contract). - [tests] default provider —
kiro_cli(CAO's default) andq_clihave no negative assertion throughclassify_provider_error; cheap insurance against a future row forgetting to scope. - [correctness]
api/main.py_resolve_error_kind—if isinstance(run_kind, str): return run_kindreturns""ifrow.kindis ever an empty string. Unreachable today. - [consistency] scope — The 20-file spread is cohesive layering, not padding, but the run-level
kindcolumn fix is independent of the classifier fix and could stand alone. Maintainer's call.
Tests
517 passed at this head with the PR source on PYTHONPATH; the 6 failures (sqlite3.OperationalError: unable to open database file in test_get_run_status_200, test_cancel_run_unknown_404, and four resume tests) reproduce identically on the main baseline and are a sandbox DB-path artifact, not PR-caused. The new behaviours are genuinely pinned: test_provider_shaped_assistant_text_completes feeds API Error: 404 is the response for an unknown route. (matches [45]\d{2}) and asserts COMPLETED, which fails on the previous head; the migration test asserts the kind column; test_caught_step_kind_does_not_override_unrelated_run_failure asserts run-level error where the old column-first resolver returned provider_error. Of the four gaps noted at dc583882: prose negatives for the non-colon rows are closed (_NON_REFUSALS now has Model weights not found., Model validation is not supported., API Error: 200 endpoints…, …123 is not an HTTP status class.); /result steps[].error_kind is now asserted (test_completed_script_result_ignores_caught_step_failure_kind); providers= scoping through classify_provider_error is still open; clear-on-settle/clear-on-cancel is still unasserted. New gaps: marker-less in-table providers untested on the production 3-arg path (the blocking defect's root cause), no isolated no-raw-context fallback test, no kiro_cli/q_cli negative. Hygiene is good: classifier tests pure/sync, async tests under asyncio_mode=strict, terminal_service seams mocked (no real tmux), test_real_adapters_extract_and_preserve_ownership crosses the real Codex/ClaudeCode extraction boundary the maintainers' repro used.
Verification
Dynamic verification (verifier) did not return in time; not covered. Partial substitutes from the static reviewers: the tests reviewer ran the change-selected suites (517 passed, see Tests); the correctness and tests reviewers both executed the shipped classify_provider_error directly — ✓ VERIFIED None raw context never classifies, >512 chars never classifies, non-first-line never classifies, [PARTIAL RESPONSE] prefix never classifies, multi-turn last-occurrence-wins works for claude_code/codex; ✗ REFUTED the docstring claim that marker-less providers are not classified (classify_provider_error('grok_cli', 'Unknown model: a model type absent from the serializer registry.', script_output=…) → match; same for mcode auth prose); ✓ VERIFIED black --check / isort --check-only clean on the 10 changed source files; ✓ VERIFIED no ReDoS shape (all rows anchored/linear or bounded O(n²) on a ≤512-char first line) and the raw capture is used only transiently inside _provider_owns_error_line — never journaled, logged, or returned; what travels on the 502 and in the journal error column is the ≤512-char extracted detail, already surfaced on a successful step today.
Verdict
Request changes — the ownership guard that resolves the maintainers' false-positive concern for claude_code/codex is a no-op for grok_cli, mcode, and kimi_cli (code contradicts its docstring; the P2 example still classifies for grok_cli), and a human CHANGES_REQUESTED remains live at a head no maintainer has re-reviewed. Fix the marker-less branch, add the production-path tests, fix the logger.debug arg count, and refresh the PR body; the rest is approve-quality.
|
Re-reviewed the Blocking
Important
Nits / lower-risk corrections
Earlier resolved threads (multiline detail, 429 form, status rendering, API 502 coverage, script settlement, run-cause attribution) were rechecked at this head; no additional changes were needed. Verification on |
PR Review: #849 — fix(workflow): classify in-band provider errors as step failuresSummaryRe-review at head Important (should fix)
Nits (optional)
TestsChange-selected suites run with the PR source on VerificationDynamic verification (verifier) did not return before synthesis; not covered as a separate angle. Partial substitutes from the static reviewers: ✓ VERIFIED the change-selected suites pass with the PR source (see Tests), and the verifier's in-progress output showed VerdictApprove with nits — every finding from the previous review is resolved at |
# Conflicts: # CHANGELOG.md
|
Synced the branch with the latest
Focused verification on the merged head
|
haofeif
left a comment
There was a problem hiding this comment.
Re-review at 628f7cf1 (vs main@5828bd23). Requesting changes for one P1.
Since d27b441f the branch only picked up two merges from main. The only conflict was in CHANGELOG, where both the #638 and #493 entries were kept, so the PR's own patch is unchanged. CI was green at 6211337a; the fork workflows at this head are awaiting maintainer approval.
Still fixed from earlier rounds: short-answer false positives, the run-level kind persisted by _finalize and preferred by _resolve_error_kind, marker-less providers falling back to pre-#638 behaviour, and CHANGELOG/docs.
[P1] The ownership guard keeps the fix from firing on real Claude Code and Codex refusals. With script_output, classify_provider_error only classifies an error line that appears without the adapter's assistant marker. The real CLIs render these errors differently:
- Claude Code prints API errors as an assistant line:
⏺ API Error: 400 Claude Code 2.1.236 does not support this model…(anthropics/claude-code#91345) and● API Error: 400 invalid params…(anthropics/claude-code#92316). The sample in #638 has the Claude Code Bedrock shapeAPI Error (<model>): 400 …(cf. anthropics/claude-code#98298). The guard treats the marked line as an answer and returnsNone. The Claude extractor only returns text that follows the last⏺/●(claude_code.py:1392-1395), so in the raw capture the extracted error line carries the marker. For Claude Code, "extractable" and "unmarked" exclude each other by design. A hit would need the raw stream and capture-pane to render the same line differently. - Codex prints upstream refusals as
■ unexpected status 400 Bad Request: {…}(openai/codex#6933) or⚠️ stream error: unexpected status 400 …(openai/codex#4270). With no•after the user line, the extractor returns the text after that line (codex.py:1937-1940), and no_ROWSsignature matches it.
The tests pass because their fixtures render these errors the opposite way from the real CLIs (see the inline comments). This is not a regression, since a miss leaves the step COMPLETED as before. However, Fixes #638 would close the issue while it still reproduces on both targeted providers.
Suggested direction: decide ownership from a provider-native error signal instead of marker absence. For example, Claude Code flags these entries "isApiErrorMessage": true in its session JSONL (anthropics/claude-code#96033), and Codex error chrome has its own ■ / ⚠️ stream error prefix. Add a fixture captured from a real session of each CLI.
[P3] The run-step failure contract omits provider_error (inline on api/main.py). gutosantos82's re-review raised this too.
Claude Code renders API errors on its own response bullet (anthropics/claude-code#91345, #92316) and Codex renders upstream refusals on its own \u25a0 / \u26a0\ufe0f stream error chrome (openai/codex#6933, #4270). The marker-absence ownership guard missed both. Decide ownership from the provider-native error signal instead: Claude's marked API Error: chrome still classifies, Codex keeps the \u2022 marker as an answer, and the unexpected_status signature is scoped to Codex's own chrome. Add fixtures transcribed from the real CLI renderings and document provider_error in the run-step failure contract.
|
Re-review at
Verification (focused, no full run): 132 passed (classifier + agent_step); 432 passed (9-file set); 313 passed, 1 xfailed (extra API/provider files); black/isort clean; mypy clean on the classifier. No force-push. |
fanhongy
left a comment
There was a problem hiding this comment.
Independent automated review commissioned by the PR author and performed by a separate CAO review agent. These findings are the agent's assessment, not the author's manual self-review.
Review of #849 at 166e6b3
Summary. The provider_error plumbing is sound. The kind reaches workflow_run_step.error_kind, the in-memory step state, the live and cold /workflows/runs/{id} views, cao workflow status, and the 502 mapping. The state-first _resolve_error_kind and the new workflow_run.kind column are consistent and tested.
The two problems are in the latest commit's ownership change and in the teardown policy on the new failure path:
- An ordinary short Claude Code answer can now fail a step that passes on
main. - Every failed attempt leaves a live terminal behind.
There are no P1 findings: 2 × P2 and 2 × P3.
Findings
P2: Claude Code's marker exemption turns off the ownership guard for every claude-scoped row, so short ordinary answers fail the step
src/cli_agent_orchestrator/services/provider_error_classifier.py:155, applied at :198. The claude_code scopes are at :76 (api_error), :99 (colon-form model_not_available) and :126 (rate_limited).
166e6b3 adds _ERRORS_RENDERED_ON_ASSISTANT_MARKER = {"claude_code"}, so a ⏺/●-marked occurrence now counts as provider chrome.
ClaudeCodeProvider.extract_last_message_from_scriptonly returns text after the last⏺/●marker.- So for Claude Code, the raw-context check now only asks whether the extracted line appears in the capture. That is the same condition the true-positive path needs, so it always passes in practice.
- That leaves the regex rows as the only guard, and the exemption covers every row scoped to
claude_code, not justAPI Error: <status>. The commit's evidence (anthropics/claude-code#91345, #92316) supports only that one shape.
I reproduced this through the real Claude extractor and the production three-argument classifier. The answer was rendered as Claude's final ⏺ block after a ⏺ Bash(pytest -q) tool block:
| Final Claude answer | head 166e6b3 |
628f7cf / main |
|---|---|---|
429 Too Many Requests |
rate_limited |
answer |
API Error: 404 Not Found |
api_error |
answer |
Unknown model: `Invoice` isn't registered in admin.py, so I added it. |
model_not_available |
answer |
Rate limit exceeded, retry after 60 seconds. |
rate_limited |
answer |
The answer is 42. |
answer | answer |
Impact, in each caller:
run_agent_step: theUnknown model:answer raisesStepExecutionError(kind="provider_error").- YAML workflow (default retries): a step whose answer is
429 Too Many Requestsendsfailed/provider_errorafter 4 attempts, and the run isfailed. Onmainit completes on attempt 1. - MCP
handoffand/terminals/run-step: callers get a failure instead of the answer.
This reverses the priority the module itself states ("a MISSED classification degrades to today's behaviour; a false one would fail a step that really answered"), and #638's criterion 4. The extractor keeps only the last marker block, so short final blocks after tool calls are common, which widens the exposure.
The new test_marked_ordinary_answers_are_not_refusals does not catch this. None of its phrases matches any row, whatever the context, so it passes with or without the ownership guard.
Suggested fix:
- Key the exemption on the (provider, slug) pair, so it covers only
api_errorforclaude_code, the one shape with upstream evidence. - Then either drop
claude_codefromrate_limitedand the colon-formmodel_not_available, or keep the marker veto for those rows. - A real Claude
API Error: 4xxand an answer that starts the same way still look alike. To tell them apart, key on a Claude-native signal instead of the text. Options are the error styling in the raw stream beforestrip_terminal_escapes, orisApiErrorMessagein the session JSONL. - Add regression cases where a marked answer does match a row, such as the rows in the table above.
P2: provider_error ignores teardown=True, so each YAML retry leaves a healthy CLI terminal alive
The raise is at src/cli_agent_orchestrator/services/agent_step.py:817-824. The retry loop is at src/cli_agent_orchestrator/services/workflow_service.py:821-843.
The new branch raises without the if teardown and created_here: await _best_effort_teardown(...) that the success path runs a few lines later.
- The YAML drive loop calls
run_agent_step(teardown=True)with a fresh terminal on every attempt. - It overwrites
st.terminal_idon eachStepExecutionError. - The comment at
agent_step.py:811-816says "the drive loop retains it on the step for inspection". In fact only the last attempt's terminal is kept, and the earlier ones are unreferenced live provider CLIs.
I reproduced this with workflow_service.start_run (terminal layer mocked; real engine, run_agent_step and classifier; default retries; Claude answer 429 Too Many Requests):
- Head: run
failedafter 4 attempts. Terminalst1–t4were created and none deleted; onlyt4stays on the step. main: runcompleted.t1was created and deleted.
Why this matters:
- True refusals leak too. An unsupported model in an N-step fan-out leaves 4N idle CLI processes, not just the false positives above.
- Capped nodes block. On a node with
server.max_terminalsorCAO_MAX_TERMINALS, those tracked rows can block later terminal creation. - The pane isn't needed to recover the text. Unlike
error/timeout, the CLI here is healthy and idle, and the refusal text already travels on the exception (#638 criterion 2).
Suggested fix:
- Preferred: in the provider_error branch, call
_best_effort_teardown(terminal_id, registry)whenteardown and created_herebefore raising, and keepterminal_idon the exception for reporting. - If keeping a pane is required: tear down the previous attempt's terminal in
_run_stepbefore retrying, so at most one is kept per step, and correct the comment.
P3: Raw context is fetched on every successful step, including providers that no row can match
src/cli_agent_orchestrator/services/agent_step.py:817
get_output_context runs before the classifier on every successful step. It does a terminal-metadata DB read, plus a tmux capture-pane when the rolling buffer is empty. It runs even when classify_provider_error will return None at once without using the context:
kiro_cli(the default provider) and every other provider with no scoped rows;- any output over 512 characters.
Check first with the cheap two-argument call (or check provider eligibility and length), and fetch the context only for a candidate match.
P3: terminal_service.get_output_context has no direct test
src/cli_agent_orchestrator/services/terminal_service.py:3495
The "return None means do not classify" contract is what stops capture failures from turning into step failures. None of its four branches is tested: no metadata, rolling buffer, empty buffer falling back to backend history, and exception.
The new agent_step tests patch it with create=True. The existing happy-path tests in test_agent_step.py now run through the real, unpatched function, which reads the developer's real terminal database.
Add a unit test for the four branches, and patch the function in _patch_terminal_layer.
Validation
Everything below is at head 166e6b3, read-only. pytest temp files went outside the checkout, with --no-cov -p no:cacheprovider, and the checkout was clean afterwards.
Tests I ran:
- PR-touched and adjacent provider tests, 14 files: 1138 passed, 3 skipped, 1 xfailed. The files are classifier, agent_step, workflow_service, script_runner, settlement_rewire, journal_step_lifecycle, run_step API, workflow_runs API, CLI workflow and run migration, plus the kimi, minimax, claude_code and codex unit tests.
- Workflow, replay, journal, handoff, orchestration, terminal_service and
cao_workflowshim suites, plusexamples/workflow/tests: 804 passed, 1 skipped. - Format:
black --checkandisort --check-onlyon the 22 changed Python files are clean.
Probes I ran:
- the real Claude extractor plus the production classifier, at head and at
628f7cf(the table above); run_agent_stepwith a mocked terminal layer;- a
start_runterminal-count probe, on head and on base5828bd2.
Checked from the acquisition run:
- Full CI-equivalent suite at
166e6b3(with the.env-writing files ignored): 13313 passed, 10 failed.- Nine failures also fail on base
5828bd2and come from the local environment: live local CAO sessions, a local agent-store override, and the AF_UNIX path length. - The tenth is
test_fifo_reader.py::TestReaderThreadLifecycle::test_stop_right_after_writer_eof_does_not_leak, a FIFO open race (ENXIO) in a file this PR does not touch. I re-ran that file three times and it passed each time (46 passed).
- Nine failures also fail on base
- mypy
src/: the normalized error set is identical to base (151 errors in 16 files on both).
Not verified:
- real provider CLIs end to end;
- CI on
166e6b3, which has not run because the fork workflows are awaiting approval.
The PR body still describes head d27b441f and the earlier marker-absence guard.
Address fanhongy's second review of awslabs#849 (head 166e6b3). P2-1 (short-answer false positives): key the response-bullet exemption on the (provider, slug) PAIR so it covers only Claude Code's `api_error`, the one shape with upstream evidence (anthropics/claude-code#91345, #92316). Drop claude_code from the `rate_limited` row and the colon-form `model_not_available` row, which have no such evidence. A short answer that merely uses the `⏺`/`●` bullet now completes; only the narrow `API Error: <4xx/5xx>` chrome (and Codex's own `■` / `⚠️ stream error` chrome) still fails the step. P2-2 (leaked terminals): the provider_error branch now runs the same `if teardown and created_here: _best_effort_teardown(...)` as the success path before raising, keeping `terminal_id` on the exception for reporting. The CLI there is healthy and idle -- the refusal text already travels on the exception -- so unlike the crash/timeout paths there is nothing to inspect, and leaving it alive leaked one idle CLI per retried YAML attempt. P3: defer the raw-context fetch until the cheap two-argument classifier returns a candidate, and add direct unit tests for `get_output_context`'s four branches.
|
@fanhongy thanks — point-by-point at head Both reviews point the same way: stop inferring ownership from marker absence and key on the provider's own error signal. haofeif needed the real Claude/Codex refusals to fire; you needed ordinary short Claude answers to stop firing. Both are satisfied by narrowing Claude's signal to the one shape with upstream evidence, so nothing has to guess from the marker. P2-1 (short-answer false positives) — fixed.
On your step 3: an answer that is literally P2-2 (leaked terminals) — fixed.
P3-1 (context fetched on every step) — fixed. P3-2 ( Verification: 1150 passed, 3 skipped, 1 xfailed (classifier, agent_step, workflow_service, script_runner, settlement_rewire, journal_step_lifecycle, run_step API, workflow_runs API, CLI workflow, run migration, kimi, minimax, claude_code, codex); 546 passed (workflow/replay/journal, terminal_service, cao_workflow shim, examples). Thanks for the precise reproduction — the rendering table and the |
haofeif
left a comment
There was a problem hiding this comment.
Re-review at 5f0f7055 (merge-base 5828bd23): no P1/P2 remaining; one P3 inline.
Verified fixed — my review 5399562965
- P1 (ownership):
_ERRORS_RENDERED_ON_ASSISTANT_MARKERscopes the marker exemption to("claude_code", "api_error"), and the codex-onlyunexpected_statusrow matches Codex's own■/⚠️ stream errorchrome. Traced through the real Codex extractor: with no•after the user line the response starts at the next line, so those lines reach the classifier; a retry banner followed by a•answer stays an answer. - P1 (test):
test_real_cli_captures_are_classified_through_the_adaptersruns the realClaudeCodeProvider/CodexProviderextractors over the upstream renders (anthropics/claude-code#91345, anthropics/claude-code#92316, openai/codex#6933, openai/codex#4270); marked short answers and unchromed Codex text stay answers. - P3: the run-step description and docstring now document
provider_error→ 502.
Verified fixed — fanhongy's 5399933356: the Claude marker exemption no longer covers other rows (429 Too Many Requests etc. complete); a terminal created by the call is torn down on provider_error while reused terminals are untouched (YAML retry test: 3 created / 3 reclaimed); the raw context is fetched only for a candidate match; TestGetOutputContext pins all four branches.
Accepted residual (not re-raising): a Claude answer whose first line is literally API Error: <4xx/5xx> … (≤512 chars) is byte-identical to the real refusal and still classifies, as the author documented.
Evidence: at this head, the 11 PR-touched test files pass locally (526 passed), plus the kimi_cli/minimax_code provider suites (864 passed, 3 skipped).
Merge gate: CI, Secret Scan and cargo-deny at 5f0f7055 are action_required (fork), so no CI has run on this head; the branch is also behind main.
| "status code. `terminal_id` names the live terminal (read it as a field; " | ||
| "never regex-scrape `message`)." |
There was a problem hiding this comment.
[P3] terminal_id is no longer always a live terminal. Since 5f0f7055, run_agent_step tears down a terminal it created before raising kind="provider_error" (agent_step.py L833-834). With the default teardown=True, this 502's terminal_id names a terminal that is already deleted, yet the contract here still promises "the live terminal". The same promise is now stale in:
- the docstring below (L4197-4198, "so a caller can report/clean it up");
StepExecutionError(agent_step.pyL136) and theRaises:note (L579);replay_single_step(workflow_service.pyL1694 "leaves its worker ALIVE", L1805 "THE WORKER IS STILL RUNNING AT THIS POINT");- outside this diff,
utils/orchestration.pyL1052-1053 and_cleanup_remote_terminal(L356): "run_agent_steponly tears down on success".
Runtime behaviour is safe: remote cleanup treats the 404 as cleaned, and the replay's second teardown is best-effort and does not re-emit post_kill_terminal. Suggest stating that for provider_error a terminal created by the call is already torn down (unless teardown=False or reused), so terminal_id is for reporting only.
haofeif
left a comment
There was a problem hiding this comment.
Approving at 5f0f7055: every P1/P2 from my earlier reviews is verified fixed (details in review 5400426612). The remaining P3 (stale terminal_id "live terminal" wording) is non-blocking. CI has not run at this head yet because the fork workflows are awaiting maintainer approval.
|
Synced the branch with latest
Code side is approved by @haofeif at |
|
已 sync main,PR 改动仍就绪,head |


Summary
Fixes #638.
run_agent_steppreviously extractedlast_messageand returnedAgentStepResult(status=COMPLETED)without checking whether that text was aprovider refusal. Provider CLIs can exit cleanly after printing an error, so the
refusal sat in the answer slot and was published as the workflow deliverable.
This PR classifies provider-owned in-band errors before building the completed
result, propagates a structured
provider_errorkind through settlement andrun inspection, and maps the run-step HTTP response to 502 rather than 504.
Current implementation
Classifier safeguards.
services/provider_error_classifier.pynow appliesfive independent guards:
PROVIDER_ERROR_MAX_CHARSis out of scope;provider chrome, not a rendered assistant message.
The ownership patterns are imported from the adapters that perform extraction
(
claude_code,codex,kimi_cli, andmcode) rather than copied into theclassifier.
grok_clidoes not expose a response marker, so the productionthree-argument path deliberately degrades to a miss instead of guessing from
answer-shaped prose. The two-argument API remains available for callers that
already hold independently trusted provider chrome.
Settlement and observation.
StepExecutionError.kindreachesworkflow_run_step.error_kind, in-memory step state, live and cold/workflows/runs/{id}results, andcao workflow status. A nullable run-levelworkflow_run.kindrecords the terminal script verdict, so a caught providererror cannot be misattributed to an unrelated later nonzero script exit.
HTTP behavior.
/terminals/run-stepreturns 502 for both worker crashes(
error) and in-band provider refusals (provider_error), while timeouts remain504. Callers are expected to branch on
detail.kind.For
provider_error, a terminal created by this call is torn downbest-effort before the exception is raised when
teardown=True; reused terminalsor
teardown=Falseremain caller-owned.terminal_idis still returned forreporting, and the bounded provider detail remains retrievable on the exception.
Testing
At head
90e7623a:uv run python -m pytest \ test/services/test_provider_error_classifier.py \ test/services/test_agent_step.py \ test/services/test_workflow_service.py \ test/services/test_script_runner.py \ test/services/test_settlement_rewire.py \ test/services/test_journal_step_lifecycle.py \ test/services/test_terminal_service_coverage.py \ test/api/test_run_step.py \ test/api/test_workflow_runs.py \ test/cli/commands/test_workflow.py \ test/clients/test_workflow_run_migration.py -q # 526 passedThe focused set covers markerless-provider conservatism, marker-aware
provider/assistant ownership, provider scoping through the production entry
point, missing raw-context fallback, retry/cancel stale-kind clearing, the
run-level kind precedence fix, and the 502 API mapping.
Review notes
origin/mainby merge, not rebase.covered through the real adapter extraction seams plus mocked terminal layers.
for retry policy is left as a follow-up; this PR keeps the existing retry
behavior.
Current diff: 25 files, +1713/-71.