Skip to content

fix(cli): partial failures set the exit code and the headline (CLI-6) - #747

Draft
padak wants to merge 1 commit into
mainfrom
claude/issue-745-partial-failure-exit-code
Draft

fix(cli): partial failures set the exit code and the headline (CLI-6)#747
padak wants to merge 1 commit into
mainfrom
claude/issue-745-partial-failure-exit-code

Conversation

@padak

@padak padak commented Sep 8, 2026

Copy link
Copy Markdown
Member

What

Commands that survive a per-item failure collected it into the result and then printed a fixed green Success: line and exited 0 anyway. A sync clone where every config failed reported Success ... 0 created and exit 0; a sync push where half the configs failed reported success too. The failures showed only as warnings underneath, so nothing in the exit code distinguished a clean run from a total failure -- a person had to read the created count and count the warning lines, and a script could not branch on it at all.

Why

Per-item resilience is deliberate: one bad config must not abort a whole push. The defect is that the resilience never reached the reporting. This makes the outcome legible in both places a caller looks:

  • exit code 1 whenever at least one item failed -- the convention storage delete-table, storage file-tag and storage describe-migrate already used, now applied where the audit found it missing;
  • the human headline states the failure instead of claiming success: Failed: Pushed: 3 created, 0 updated, 0 deleted, 1 failed.

--json output is unchanged and is emitted before the non-zero exit, so a JSON caller still receives the complete payload including the per-item error detail. Exit 1 here never means "no output".

Commands changed

Command Failure signal
sync push (single + --all-projects) errors[] / summary.failed
sync clone errors[]
sync pull / sync diff with --all-projects summary.failed
org setup projects_failed
project invite --from-csv failed

A clean run is untouched: no failures still means the green success line and exit 0. no_changes and --dry-run results are not failures.

Scope decision: read-only fan-outs excluded

billing credits, job list, schedule list, notification list and the other multi-project reads document per-project degradation as intended behavior ("per-project failures degrade individually, the run never aborts"). Failing the whole read because one project is unreachable would break existing callers, so they still exit 0 and report the per-project error in errors. This is stated explicitly in the new gotchas.md entry so an agent knows which array to check.

The audit also confirmed the bulk storage commands (delete-table, truncate-table, delete-column, delete-bucket, file-delete, file-tag, snapshot-delete, describe-batch, describe-migrate) were already correct -- no change needed there.

Implementation note

sync push's inline human rendering moved into _render_push_result because its early returns for no_changes / dry_run would otherwise bypass the exit call. Behavior for those two statuses is byte-identical.

commands/sync.py grows by 23 code lines and stays over the 800-line soft ceiling it was already over before this PR (1156 -> 1179). Splitting it is a much larger refactor than this bug fix warrants; flagging it rather than bundling it.

How it was tested

tests/test_partial_failure_exit_code.py -- 16 tests covering every changed command, both directions (failure -> exit 1, clean -> exit 0), the no_changes / --dry-run non-failure cases, and that --json still emits the full payload alongside the non-zero exit.

Verified the tests are not vacuous: with src/ reverted to main, 9 of the 16 fail; the other 7 are the clean-run control cases that must keep passing.

make check passes (6585 passed, 12 skipped).

Docs

Per convention #17: gotchas.md (new entry, (since vNEXT)), commands-reference.md (four entries, incl. correcting the now-wrong "Exit 0 even with failed > 0 -- inspect the JSON" line), CLAUDE.md command list, and context.py AGENT_CONTEXT exit-code section. No command was added, renamed or removed.

Fixes #745

Commands that survive a per-item failure collected it into the result and
then printed a fixed green "Success:" line and exited 0 anyway. A `sync
clone` where every config failed reported `Success ... 0 created` and exit
0; a `sync push` where half the configs failed reported success too. The
failures showed only as warnings underneath, so nothing in the exit code
distinguished a clean run from a total failure and no script could branch
on it.

Add `exit_on_item_failures()` to the command helpers and apply it to the
commands the audit found still missing it. The exit code follows the
convention the bulk storage commands already used (any per-item failure is
a general error, exit 1):

- `sync push` (single project and `--all-projects`)
- `sync clone`
- `sync pull` / `sync diff` with `--all-projects`
- `org setup` (`projects_failed`)
- `project invite --from-csv` (`failed`)

The human headline now states the failure instead of claiming success --
`Failed: Pushed: 3 created, 0 updated, 0 deleted, 1 failed` -- so the
failed count is in the main line rather than only in the warnings below
it. `sync push`'s inline human rendering moved into `_render_push_result`
because its early `return`s for `no_changes` / `dry_run` would otherwise
bypass the exit call.

`--json` output is unchanged and is emitted BEFORE the non-zero exit, so a
JSON caller still receives the complete payload including the per-item
error detail.

Read-only multi-project fan-outs (`billing credits`, `job list`, `schedule
list`, `notification list`) are deliberately excluded: they document
per-project degradation as intended behavior, and failing the whole read
because one project is unreachable would break existing callers.
@linear-code

linear-code Bot commented Sep 8, 2026

Copy link
Copy Markdown

CLI-6

@soustruh

soustruh commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Dear Claude, without reviewing this PR any further, I'd just like to highlight this one thing – exit_on_item_failures raises typer.Exit itself, while the existing map_error_to_exit_code is a pure mapper that returns the code and lets the caller raise. Two exit-code helpers side by side with opposite shapes is inconsistent. Consider returning the code (int | None) and keeping the raise at the call site, matching the existing convention.

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.

Commands report success and exit 0 even when per-item operations fail

2 participants