fix(cli): exit() in a batch item fails that item, not the whole run (#607) - #690
Merged
Merged
Conversation
…607) `ailang run --batch` promises per-item isolation — it counts failures and prints "Batch complete: X/Y succeeded". It did not deliver that when an item called exit(). exit() raises a *eval.EvalExitCode sentinel panic (internal/effects/io.go). The single-file run path recovers it (main_run_exec.go); executeBatchItem called executeModuleEntrypoint directly with no recover, so the sentinel unwound through the batch loop and out of main: rc=2, a raw Go stack shown to the user, and every remaining input silently skipped. Reported from a 2,500-file PDF batch job where one bad file aborted the run. The recurring guard-the-helper-miss-the-call-site shape: the recover already existed, one call site did not have it. exit(N) with N != 0 now fails that item and the loop continues; exit(0) is a successful item; non-exit panics are re-raised unchanged so a genuine crash stays loud. Batch mode had no regression tests at all. Five now, covering all four recover branches. The re-panic arm is unreachable from any .ail fixture (it needs a real Go crash inside the evaluator), so the recover logic is split into recoverBatchItemExit and unit-tested directly rather than shipping unguarded. Mutation drill, each mutant asserted LANDED (sha256) and BUILDS (rc=0): - remove the recover -> reds "leaked a Go panic" + "[2/2] never started" - neuter the non-zero arm -> reds the 1/2-succeeded count and rc - neuter the exit(0) arm -> reds the exit(0)-is-success arms - neuter the re-panic arm -> reds the swallowed-crash arm Inverse arm: with the recover removed and these tests skipped, the rest of cmd/ailang is rc=0 — the defect ships entirely undetected without them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
This was referenced Aug 13, 2026
sunholo-voight-kampff
added a commit
that referenced
this pull request
Aug 13, 2026
) * docs(mission): iteration 192 — #607 batch exit() panic fixed and landed; two adjacent gaps filed Gate 4 record for iteration 192. - Charter: m-batch-exit-panic retagged [NEXT] -> [LANDED] (PR #690, squash 7bad0e6, Gate 3b GREEN 21/21 pending=0, evaluator sonnet PASS 96/100 r1 zero blocking). - STATUS rotation: ITERATION 192 added, ITERATION 189 moved to the archive. Line-count invariant held (1984 -> 1984); archive destination asserted (189 present, control 188 present) per the iteration-190 rule. - Log entry 194; dashboard overwritten. Two adjacent gaps found by the evaluator, reproduced first-party, filed rather than bundled: #691 (exit() escapes as a raw panic into an embedding host — internal/embed has zero recover()) and #692 (batch mode never flushes Debug ghost-effect output). Written from a worktree off origin/dev: the main checkout is 2 ahead and dirty with a sibling session's observatory work (Critical Principle 0). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * skill(mission-control): post the verdict comment BEFORE closing — gh issue close --comment loses it Gate-5 skill fix. Two recorded frictions on the same surface, different mechanisms, both "gh reports success while the comment is lost": 1. Iteration 149 — an inline --comment body is markdown, markdown is made of backticks, and unquoted backticks trigger zsh command substitution. gh printed "Closed" on a comment whose evidence had been surgically removed. 2. Iteration 192 (this one) — on an ALREADY-CLOSED issue, gh issue close --comment prints only "! Issue ... is already closed", exits 0, and posts nothing. The fix recorded for (1) was --body-file. It does nothing for (2): the command short-circuits before it looks at the body. So the ORDER is the fix, not the flag — comment first, then close, then assert the comment count grew. (2) is not an edge case. A PR body carrying "Fixes #N" auto-closes the issue at merge, before the loop's own close step runs, so it is the normal path for any iteration that lands a fix by PR. Iteration 192 recovered its evidence only by re-reading the comment count instead of trusting rc=0. Saved in the main checkout as well as committed here, so the running skill (which resolves through the ~/.claude symlink to the main checkout's working tree) is current rather than waiting on a pull — verified byte-identical by sha256 across all three paths, and the only delta vs main HEAD is this 24-line insertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #607.
The defect
ailang run --batchpromises per-item isolation — it runs the entrypoint once per input, counts failures, and printsBatch complete: X/Y succeeded. It did not deliver that when an item calledexit().exit()raises a*eval.EvalExitCodesentinel panic (internal/effects/io.go:145). The single-file run path recovers it (main_run_exec.go:552-568);executeBatchItemcalledexecuteModuleEntrypointdirectly with no recover, so the sentinel unwound through the batch loop and out ofmain— rc=2, a raw Go stack shown to the user, and every remaining input silently skipped.Reported by a downstream consumer running a 2,500-file PDF batch job, where one bad file aborted the whole run.
This is the recurring guard-the-helper-miss-the-call-site shape: the recover already existed, one call site did not have it.
Reproduced first-party at HEAD, three arms
--batch+exit(1)in item 1panic: (*eval.EvalExitCode), stack throughio.go:145→run_helpers.go:656,[2/2]never runsexit()Batch complete: 2/2 succeededexit(1)Outcomes differ across the mechanism arm, so this is attributable to
exit()and specific to the batch path — not an environment artifact.The fix
exit(N)withN != 0fails that item (the loop reports it and continues);exit(0)counts as a success; non-exit panics are re-raised unchanged so a genuine crash stays loud.After:
[1/2]reportsprogram called exit(1),[2/2]runs,Batch complete: 1/2 succeeded, rc=1.Tests
Batch mode had no regression tests at all. Five now, covering all four recover branches plus a single-file control that distinguishes a batch-mode regression from one in the shared
exit()mechanism.The re-panic branch is unreachable from any
.ailfixture (it needs a real Go crash inside the evaluator), so the recover logic is split intorecoverBatchItemExitand unit-tested directly rather than shipping unguarded.Mutation drill — each mutant asserted LANDED (sha256) and BUILDS (
go buildrc=0)leaked a Go panic,[2/2] never startedwant 1/2 succeeded,exit code = 0, want 1exit(0)-is-success armexit(0) item was not counted as a successa non-exit panic was swallowedEach mutation reds only its own arm, and every restore was verified byte-identical by sha256.
Inverse arm: with the recover removed and these tests skipped, the rest of
cmd/ailangis rc=0 — the defect ships entirely undetected without them.Gates
make testrc=0 (zero failures) ·fmt-check·vet·check-file-sizes·check-boundaries·check-changelog·check-skillsall rc=0.Note
go build ./...is rc=1 on unmodifieddevas well (cmd/wasmhas no nativemain) — baselined, not caused by this change; the package-scoped build is rc=0 at base and patched.🤖 Generated with Claude Code