Skip to content

fix(cli): exit() in a batch item fails that item, not the whole run (#607) - #690

Merged
sunholo-voight-kampff merged 1 commit into
devfrom
sprint/iter192-batch-exit-panic
Aug 13, 2026
Merged

fix(cli): exit() in a batch item fails that item, not the whole run (#607)#690
sunholo-voight-kampff merged 1 commit into
devfrom
sprint/iter192-batch-exit-panic

Conversation

@sunholo-voight-kampff

Copy link
Copy Markdown
Collaborator

Fixes #607.

The defect

ailang run --batch promises per-item isolation — it runs the entrypoint once per input, 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:145). The single-file run path recovers it (main_run_exec.go:552-568); 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 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

arm result
--batch + exit(1) in item 1 rc=2, panic: (*eval.EvalExitCode), stack through io.go:145run_helpers.go:656, [2/2] never runs
control — same batch, no exit() rc=0, both items run, Batch complete: 2/2 succeeded
control — single-file path, same exit(1) rc=1, clean, zero panic frames

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) with N != 0 fails 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] reports program 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 .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 (go build rc=0)

mutation reds
remove the recover (pre-fix state) leaked a Go panic, [2/2] never started
neuter the non-zero-exit arm want 1/2 succeeded, exit code = 0, want 1
neuter the exit(0)-is-success arm exit(0) item was not counted as a success
neuter the re-panic arm a non-exit panic was swallowed

Each 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/ailang is rc=0 — the defect ships entirely undetected without them.

Gates

make test rc=0 (zero failures) · fmt-check · vet · check-file-sizes · check-boundaries · check-changelog · check-skills all rc=0.

Note go build ./... is rc=1 on unmodified dev as well (cmd/wasm has no native main) — baselined, not caused by this change; the package-scoped build is rc=0 at base and patched.

🤖 Generated with Claude Code

…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>
@sonarqubecloud

Copy link
Copy Markdown

@sunholo-voight-kampff
sunholo-voight-kampff merged commit 7bad0e6 into dev Aug 13, 2026
21 checks passed
@sunholo-voight-kampff
sunholo-voight-kampff deleted the sprint/iter192-batch-exit-panic branch August 13, 2026 12:32
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[cli] Batch mode: a module calling exit() inside a ba...

1 participant