fix(sync): exit non-zero when single-module sync reports Overall status Failed (#1979)#1982
Conversation
… status Failed (#1979) Reproduces issue #1979: pdd sync <basename> prints 'Overall status: Failed' but exits 0, so CI, shell && chains, and agentic child runners read a failed sync as success. Red: 4 failure-path tests fail (exit_code == 0), 2 success-path tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…us Failed (#1979) The single-module path of the sync command returned the cost-tracking tuple (str(result), total_cost, model_name) unconditionally, so a sync whose aggregated result had overall_success == False printed 'Overall status: Failed' but exited 0. CI, shell && chains, and the agentic child runners (which key off the exit code) read the failed sync as success; the runner already grew a sticky stdout scrape as a workaround. Follow the #1677 / dispatch-helper convention in the same command: raise click.exceptions.Exit(1) on failure, placed after the evidence-manifest write so failure evidence is still recorded. Also widen the re-raise clause to (click.Abort, click.exceptions.Exit) — the existing broad 'except Exception' would otherwise swallow the Exit (it subclasses RuntimeError) and turn it back into exit 0, matching the guard the agentic/global dispatch helpers already carry. The 'Overall status:' stdout contract is unchanged (sync_main untouched); successful runs, including successful --dry-run, still exit 0. Fixes #1979 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pre-fix investigation summaryConvention followed (all in
Sibling hunt results:
Interaction checks:
|
Red test output (TDD step 1 — tests committed and pushed in 3db1db6 before the fix)Command: All 4 failure-path tests red — |
E2E evidence (real console entrypoint, post-fix)Minimal temp project: Failure run (generate step deterministically fails: no API keys, network blackholed via unreachable proxy)The Success run (--dry-run reporting-only, same project) |
Codex adversarial review — PR #1982 @ 1437061Verdict: DON'T MERGE P1 (blocking)
P2 (non-blocking)Notes
(Posted by the orchestrator on behalf of |
Implementer response to Codex reviewFinding: raising Plan: keep the non-zero exit (the raise must stay in the command — the server's |
… exiting non-zero (#1979) Codex review of PR #1982 (P1): raising click.exceptions.Exit(1) makes track_cost capture the exception and skip the --output-cost / PDD_OUTPUT_COST_PATH row (it only writes when exception_raised is None). The agentic runner sets PDD_OUTPUT_COST_PATH for child syncs and parses the row to accumulate cost and enforce --budget, so failed attempts must keep recording their cost. Red: test_cli_sync_failure_still_writes_cost_csv_row fails (exit is non-zero but no CSV row is written). The companion success-path guard (exactly one row, no double-write) passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ts non-zero (#1979) Codex review of PR #1982 (P1): the Exit(1) raised for overall_success == False was captured by @track_cost as exception_raised, and the wrapper only wrote the --output-cost / PDD_OUTPUT_COST_PATH row when no exception was raised — so exactly the failed child attempts lost their cost row, breaking the agentic runner's cost accumulation and --budget enforcement (pdd/agentic_sync_runner.py sets PDD_OUTPUT_COST_PATH per child and parses the row). Mechanism chosen: stash the (str(result), total_cost, model_name) tuple on ctx.obj under track_cost.EXIT_COST_RESULT_KEY right before raising, and teach the wrapper to write the row from that stash when the captured exception is an intentional click.exceptions.Exit. The alternative — not raising in the command and exiting from a group-level result hook — was rejected because the server's click_executor invokes commands directly via ctx.invoke (bypassing any group hook) and child processes get their exit code from click standalone mode, so only a raise inside the command covers both paths. Behavior preserved: - success path writes exactly ONE row (stash is only set on the failure raise; the wrapper pops it unconditionally so it can never leak into a later tracked command, mirroring the attempted_models handling) - #1677 AmbiguousModuleError and the agentic/global dispatch failure raises carry no stash, so they keep skipping the row as before - crashes / other exceptions keep skipping the row as before Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implementer response — P1 fixedFinding: Exit(1) skipped the Mechanism chosen (option b from the plan above): the sync command stashes Preserved: success path writes exactly one row (guard test); #1677 Red (test pushed first,
|
Codex adversarial review — PR #1982 @ f640b57Verdict: MERGE P1 (blocking)
P2 (non-blocking)
Notes
(Round-2 re-review; posted by the orchestrator on behalf of |
Implementer response to Codex review (round 2, 2 P2)P2#1: failed sync leaks core-dump P2#2: |
…sync Exit; make cost-CSV tests hermetic (#1979) Codex round-2 review of PR #1982 (2 P2): P2#1 red test: a failed sync driven through cli.main(standalone_mode=False) with --core-dump must leave sys.stdout/sys.stderr as the original streams. The non-zero branch of PDDCLI.invoke's 'except click.exceptions.Exit' re-raises without _restore_captured_streams(ctx), so embedded top-level invocations (server executor uses ctx.invoke in-process) leak OutputCapture. Red: AssertionError 'failed sync leaked the core-dump OutputCapture onto sys.stdout'. P2#2 (test-only fix): _enable_cost_csv() deletes PYTEST_CURRENT_TEST process-wide, which re-enables onboarding shell detection (subprocess 'ps') — the two cost-CSV tests failed in sandboxes unless PDD_SUPPRESS_SETUP_REMINDER was set externally. Set it via monkeypatch in the helper; verified with env -u PDD_SUPPRESS_SETUP_REMINDER pytest (2 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ro Exit (#1979) Codex round-2 review of PR #1982 (P2#1): the 'except click.exceptions.Exit' branch in PDDCLI.invoke re-raised intentional non-zero exits (failed sync, #1677 ambiguous module, checkup --validate-arch-includes) WITHOUT restoring the --core-dump OutputCapture wrappers. One-shot CLI processes die anyway, but embedded top-level invocations — cli.main(standalone_mode=False) and the in-process server executor (ctx.invoke) — were left with sys.stdout/sys.stderr still wrapped for the rest of the process. Call the existing _restore_captured_streams(ctx) helper (written for exactly this, and already used by the ctx.exit(0) early-exit paths and the duplicate-guard re-raises) before the re-raise. The SystemExit branch above already restores inline and is left untouched; exit_code == 0 Exits keep propagating through Click's normal cleanup as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implementer response — round-2 P2s fixedP2#1 (core-dump stream capture leak on non-zero Exit) — Status: FIXED (test commit Red (pre-fix, commit The test drives a failed sync through Green (after P2#2 (cost-CSV tests not hermetic after deleting PYTEST_CURRENT_TEST) — Status: FIXED (same test commit Nothing consciously left unfixed from the round-2 review. Note for reviewers: 5 pre-existing failures in |
Codex adversarial review — round 3 (final) @ 90d655cVerdict: MERGE — no findings. Loop summary: round 1 found 1 P1 (cost-CSV row lost on failure exit) → fixed via ctx-stash + track_cost Exit handling; round 2 verdict MERGE with 2 P2s (stream-capture leak on in-process Exit, non-hermetic cost tests) → both fixed; round 3 clean. Merging into (Posted by the orchestrator on behalf of |
263b492
into
epic/agentic-sync-default-readiness
Summary
Single-module
pdd sync <basename>printedOverall status: Failedin the final summary panel but exited 0, so CI gates, shell&&chains, and the agentic child runners (which key off the exit code) read a failed sync as success. Fixes #1979.Root cause
pdd/sync_main.pyaggregates per-language results, prints theOverall status:panel, and returnsoverall_successinside the aggregated dict. Thesynccommand inpdd/commands/maintenance.pythen didreturn str(result), total_cost, model_nameunconditionally — the return value of a click command in standalone mode does not set the exit code, so the process exited 0. Downstream tooling had already grown a workaround (the runner's sticky stdout scrape ofOverall status: ... Failed,pdd/agentic_sync_runner.py:2806).Fix
Follow the established convention in the same command (#1677
AmbiguousModuleErrorhandler; the agentic/global dispatch helpers_run_agentic_sync_dispatch/_run_global_sync_dispatchalready do exactly this on failure):raise click.exceptions.Exit(1)whenoverall_success == False.except click.Aborttoexcept (click.Abort, click.exceptions.Exit)—ExitsubclassesRuntimeError, so the existing broadexcept Exceptionhandler would otherwise swallow the new raise and turn it back into exit 0 (the dispatch helpers already carry this exact guard).Deliberately unchanged:
Overall status:stdout contract (pdd/sync_main.pyuntouched) — the runner's sticky capture andpdd/server/jobs.py:684scrape keep working.success = exit_code == 0and then downgrades on failure markers, so a child that now exits 1 and prints the marker still yields a single failure result — no double-fire.pdd/server/click_executor.pymaps a raisedExittoexit_code=1via its generic handler, so the server path reports failure correctly too.track_costskips the CSV row when the wrapped command raises — this is the pre-existing, accepted behavior for the pdd sync should resolve modules by canonical path, not ambiguous short basenames #1677 handler and both dispatch helpers'Exit(1)failure paths; the raise here is placed after the evidence-manifest write so--evidencefailure runs still record their manifest.Sibling findings (noted, not changed here)
_run_agentic_sync_dispatchand_run_global_sync_dispatchalreadyraise click.exceptions.Exit(1)on failure — the single-module path was the only sync path missing it.except Exceptionhandler insync(and the same shape in many other commands) callshandle_error(...)and thenreturn None, i.e. an unexpected crash also exits 0.handle_erroronly prints/records. That is a repo-wide convention across command modules, not the one-line mistake fixed here, so it is out of scope for this PR — worth its own issue.grep -rn overall_success pdd/commands/shows no other command consuming a success flag without exiting on it.Test plan
New tests:
tests/test_issue_1979_sync_exit_code.py(CliRunner against the real CLI group,sync_mainstubbed to return the real aggregated-dict shape):test_cli_sync_failed_overall_status_exits_nonzero[default|quiet]— failed sync exits non-zero, incl.--quiettest_cli_sync_successful_result_exits_zerotest_cli_sync_dry_run_success_exits_zerotest_cli_sync_dry_run_failure_exits_nonzerotest_cli_sync_failure_still_writes_evidence_manifest— the raise happens after the evidence writeAll 4 failure-path tests were committed red first (see the
test(sync):commit and the red-output PR comment), then went green with the fix. Also rantests/test_issue_1677_canonical_module_resolution.py,tests/test_commands_maintenance.py,tests/test_gate_failed_sync.py,tests/test_jobs_sync_failure_detection.py,tests/test_issue_1714_sync_stall.py,tests/test_force_local_issue_sync.py— all pass.python3 -m pylint pdd/commands/maintenance.pyintroduces no new warnings (remaining W0718s are pre-existing handlers).E2E (real console entrypoint)
Minimal temp project (one
prompts/hello_python.prompt+.pddrc), network blackholed via an unreachable proxy so the generate step deterministically fails:Overall status: Failed, exit code 1 (was 0 before the fix).python3 -m pdd.cli --local --no-core-dump sync hello --dry-run; echo $?on the same project reports and exits 0.Full transcript in the PR comments.
Part of epic PR #1981.
🤖 Generated with Claude Code