Skip to content

[3328 by Claude Opus 5.5] Honor nonzero Claude exits before accepting streamed success - #3329

Merged
integry merged 1 commit into
mainfrom
3328/claude-opus-5-5-honor-nonzero-claude-exit-20261023-0453-q8m
Oct 23, 2026
Merged

integry merged 1 commit into
mainfrom
3328/claude-opus-5-5-honor-nonzero-claude-exit-20261023-0453-q8m

Conversation

@propr-dev

@propr-dev propr-dev Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

AI Implementation Summary

Closes #3328

Branch: 3328/claude-opus-5-5-honor-nonzero-claude-exit-20261023-0453-q8m
Commits: ✅ Changes committed (3bae49f)


AI Processing Completed

Execution Details:

Summary:
A Claude run that exits nonzero (137, 143, 1) is now reported as failed even if it streamed a successful result line first. The regression fails on current main and passes with the fix, and all 14 related suites pass.

Root cause: parseStreamJsonOutput set success from the exit code, but processResultLine then overwrote it with !is_error. processDockerResult passed that along unchanged, so the PR follow-up finished as "completed" with no commit, and the retry never ran.

Fix

  • packages/core/src/claude/claudeHelpers.ts: after parsing, a known nonzero exit forces success = false. The result text, conversation log, token usage, session id and cost are all still kept. A new helper, hasKnownNonzeroExit, treats a missing (null) exit code as unknown, so those runs behave exactly as before.
  • packages/core/src/agents/impl/utils/dockerResultProcessor.ts:
    • When this case happens, the error is now Claude process exited with code <N> after reporting a successful result, followed by stderr if there is any. Without this, the agent's success summary would have been shown as the error.
    • The summary is still kept as the summary.
    • Timeout, cost-cap, watchdog and runtime-crash runs keep their existing termination reason and error message.
    • To stay under CI's zero-warning lint on changed core files, I moved the termination/error logic into a helper, resolveExecutionOutcome. The function was already over the complexity limit (26 against a max of 20) before my change. The behaviour for every other case is the same as before.

How the failure reaches the retry: success: false with no termination reason makes getPostExecutionDisposition return failed. The PR job then throws Agent execution failed: …, which is the normal failed-attempt path that the automatic retry already handles. I didn't change attempt counts, backoff, cancellation, permissions or the UI.

Regression test: test/claudeNonzeroExitResult.test.ts (8 tests, using the real parser and result processor)

  • Exits 137, 143 and 1 with a successful result line: the parser and the processor both report failure, the diagnostics are kept, and the PR disposition is failed.
  • The error message includes stderr when there is some.
  • Timeout plus exit 143 is still timeout / partial, and cost cap plus 137 is still cost_cap.
  • Exit 0 with a successful result still succeeds (complete); an error result fails with exit 0 or 137; a null exit keeps whatever the result line says.

Results

  • Against current main (the two source files swapped back temporarily): 4 of 8 tests fail. The 137, 143 and 1 cases fail on true !== false, and the stderr case gets an undefined error. The four control tests pass.
  • With the fix, through scripts/run-test-suite.mjs, all 14 files pass:
Suite Passed
claudeNonzeroExitResult 8/8
claude 8/8
claudeAnalysisOutcome 5/5
claudeContinuedAnswer 8/8
claudeExtensionEnvironment 1/1
claudeNativeGoal 20/20
claudeRateLimitPropagation 1/1
partialExecution 19/19
prCommentAutomaticRetry 17/17
prCommentTaskStateFinalizers 8/8
retryHandler 121/121
core sqliteRetry 46/46
agentToolPolicy 22/22
issueUsageLimitRetry 16/16
  • eslint --max-warnings 0 on the two changed core files and tsc --noEmit for packages/core are both clean.

There's no visible change, so I didn't create any previews.

Detailed Logs:

  • Conversation: 108 messages
  • Session: 0de238cb-463c-4a7b-9097-3068b89cec30

Log files stored at:

  • conversation: /usr/src/app/logs/executions/issue-3328-2026-10-23T05-00-00-629Z-conversation.json
  • output: /usr/src/app/logs/executions/issue-3328-2026-10-23T05-00-00-629Z-output.txt
Latest Conversation Messages
ASSISTANT: [content unavailable]

ASSISTANT: A Claude run that exits nonzero (137, 143, 1) is now reported as failed even if it streamed a successful result line first. The regression fails on current main and passes with the fix, and all 14 rel...


This PR was created automatically by ProPR after processing issue #3328.


💡 Need changes?

Comment on this PR to request refinements — the AI agent monitors comments and will update the implementation based on your feedback. Keep iterating until you're satisfied!

…ed even if it streamed a successful result line first. The regression fails on current main and passes with the fix, and all 14 related suites pass.

Root cause: `parseStreamJsonOutput` set success from the exit code, but `processResultLine` then overwrote it with `!is_error`. `processDockerResult` passed that along unchanged, so the PR follow-up finished as "completed" with no commit, and the retry never ran.

Fix
- `packages/core/src/claude/claudeHelpers.ts`: after parsing, a known nonzero exit forces `success = false`. The result text, conversation log, token usage, session id and cost are all still kept. A new helper, `hasKnownNonzeroExit`, treats a missing (null) exit code as unknown, so those runs behave exactly as before.
- `packages/core/src/agents/impl/utils/dockerResultProcessor.ts`:
  - When this case happens, the error is now `Claude process exited with code <N> after reporting a successful result`, followed by stderr if there is any. Without this, the agent's success summary would have been shown as the error.
  - The summary is still kept as the summary.
  - Timeout, cost-cap, watchdog and runtime-crash runs keep their existing termination reason and error message.
  - To stay under CI's zero-warning lint on changed core files, I moved the termination/error logic into a helper, `resolveExecutionOutcome`. The function was already over the complexity limit (26 against a max of 20) before my change. The behaviour for every other case is the same as before.

How the failure reaches the retry: `success: false` with no termination reason makes `getPostExecutionDisposition` return `failed`. The PR job then throws `Agent execution failed: …`, which is the normal failed-attempt path that the automatic retry already handles. I didn't change attempt counts, backoff, cancellation, permissions or the UI.

Regression test: `test/claudeNonzeroExitResult.test.ts` (8 tests, using the real parser and result processor)
- Exits 137, 143 and 1 with a successful result line: the parser and the processor both report failure, the diagnostics are kept, and the PR disposition is `failed`.
- The error message includes stderr when there is some.
- Timeout plus exit 143 is still `timeout` / `partial`, and cost cap plus 137 is still `cost_cap`.
- Exit 0 with a successful result still succeeds (`complete`); an error result fails with exit 0 or 137; a null exit keeps whatever the result line says.

Results
- Against current main (the two source files swapped back temporarily): 4 of 8 tests fail. The 137, 143 and 1 cases fail on `true !== false`, and the stderr case gets an `undefined` error. The four control tests pass.
- With the fix, through `scripts/run-test-suite.mjs`, all 14 files pass:

| Suite | Passed |
| --- | ---: |
| claudeNonzeroExitResult | 8/8 |
| claude | 8/8 |
| claudeAnalysisOutcome | 5/5 |
| claudeContinuedAnswer | 8/8 |
| claudeExtensionEnvironment | 1/1 |
| claudeNativeGoal | 20/20 |
| claudeRateLimitPropagation | 1/1 |
| partialExecution | 19/19 |
| prCommentAutomaticRetry | 17/17 |
| prCommentTaskStateFinalizers | 8/8 |
| retryHandler | 121/121 |
| core sqliteRetry | 46/46 |
| agentToolPolicy | 22/22 |
| issueUsageLimitRetry | 16/16 |

- `eslint --max-warnings 0` on the two changed core files and `tsc --noEmit` for `packages/core` are both clean.

There's no visible change, so I didn't create any previews.
@integry

integry commented Oct 23, 2026

Copy link
Copy Markdown
Owner

/review llm-codex-astra

Independent read-only review of exact head 3bae49f for #3328. Root live validation observed a real Claude container exit137 while its streamed result made the worker record success/completed without a commit or automatic retry. Root also reproduced known nonzero exits being overwritten in the real compiled parser and result processor.

Assess correctness of the known-nonzero exit boundary through the PR failure/retry disposition; preservation of terminal output/usage/summary diagnostics; unchanged valid zero-exit last-result ordering; explicit timeout, cost-cap, watchdog/runtime-crash and rate-limit policies; and useful error propagation. Check regression coverage and scope of the outcome-helper extraction. Do not change code, permissions, credentials, retry budgets or release artifacts. Root will independently validate this head and repeat the controlled live retry after integration.

@propr-dev

propr-dev Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex-astra

Overall Evaluation

Merge-ready within the supplied scope, conditional on pending checks passing. The fix enforces process failure after stream parsing and keeps the outcome-helper extraction focused.

✅ Nonzero exits remain failures — The final parser guard prevents streamed success from overriding a known nonzero exit. The result adapter preserves failure, and ordinary failures reach the PR post-processing throw that enables queue retry.

✅ Diagnostics remain intact — Terminal results, usage, session information, conversation history, and summaries survive the override. The processor supplies an exit-specific error and includes available stderr.

✅ Existing policies remain explicit — Zero-exit result ordering is unchanged. Timeout, cost-cap, watchdog, guarded runtime-crash recovery, and rate-limit exception handling retain their existing control paths.

This is a static review of the supplied diff and context; no commands or tests were run. The linked issue description is excerpted, so unseen requirements were not assessed. The supplied current checks show no failures and five pending checks.

Merge blockers

No merge blockers.

Suggestions

These are optional follow-ups and are not sent to /fix.

S1: 🟢 Cover result ordering and policy combinations

Extend test/claudeNonzeroExitResult.test.ts with multiple terminal results in both orders, asserting that zero exits retain last-result semantics while nonzero exits always fail. Cross-tests for watchdog stops and both rate-limit formats would further protect the parser’s final override and exception behavior. These are optional regression protections: static inspection shows those paths remain correct.

S2: 🟢 Confirm actual queue retry

The new regression reaches getPostExecutionDisposition, and the supplied consumer demonstrably throws for that disposition, but the test does not execute queue retry or verify persisted attempt history. The planned controlled live retry should confirm that an interrupted run records failure and starts another attempt when attempts remain. This is useful operational confirmation, not a demonstrated code blocker.

Score

The localized fix addresses the reported failure while preserving diagnostics and existing termination policies. Regression coverage is meaningful; pending checks and the planned live retry remain to complete validation.

Score: 9/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 26s
  • Tokens: 35,647 (35,085 in / 562 out)
  • Cost: $0.27

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix S1 S2

🤖 Review by ProPR

@integry
integry merged commit 60b959b into main Oct 23, 2026
52 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Honor nonzero Claude exits before accepting streamed success

1 participant