From c4a8fe489628900a6cfa0553dda84b3cd2e0ec1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 21:25:20 +0000 Subject: [PATCH] Report exit 5, not 4, when nothing was gathered and the run did not finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit 4 is a claim about the CATALOGUE — "we asked, and the answer was nothing" — so handing it to a run that never reached the site tells a pipeline the listing is empty when nothing was read at all. Measured across the family on 2026-09-21 by CALLING each repo's finish_run rather than grepping for the fix: 20 repos still returned 4, two returned 5, and seven had already fixed it using EXIT_PARTIAL with written reasoning. Three camps, none aware of the other two. This unifies on 5, taking the rule from the exit-6 camp and the code from the exit-5 camp. The rule is `if not complete` rather than a named list of stop reasons: a list cannot cover a reason nobody has added to it yet, so a new stop_reason would fall silently through to "the catalogue is empty" — the defect all three camps set out to fix. The code is 5 rather than 6 because EXIT_PARTIAL means "some rows were gathered and the output is incomplete", and a run holding nothing writes no output, so a consumer reading the file on a 6 finds either nothing or the PREVIOUS run's good data that save() deliberately leaves in place. 5 promises no file, and the family contract already reserves it for a transport failure. Rows gathered and THEN a failure is still exit 6, unchanged. Verified by invoking finish_run after the patch and requiring 5, with the repo's own suite green. Checks that pinned the old answer were rewritten in the same commit. Co-Authored-By: Claude Opus 5 (1M context) --- output_writer.py | 2 +- smoke_test.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/output_writer.py b/output_writer.py index 352b93b..7082f8f 100644 --- a/output_writer.py +++ b/output_writer.py @@ -516,7 +516,7 @@ def finish_run(rows: Sequence[Any], out_prefix: str, fmt: str, # EXIT_NO_PRODUCTS. if blocked: return EXIT_BLOCKED - if stop_reason in FETCH_FAILURE_STOP_REASONS: + if not complete: print(f"[!] The page was never fetched ({stop_reason}) — this is " f"exit {EXIT_FETCH_FAILED}, NOT an empty result " f"(exit {EXIT_NO_PRODUCTS}). Nothing can be concluded about " diff --git a/smoke_test.py b/smoke_test.py index 50c91dc..40a12cf 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -1021,10 +1021,15 @@ def test_writers(): (dict(blocked=False, stop_reason="page_load_timeout", rows=[]), EXIT_FETCH_FAILED, None), # A named challenge still outranks a transport failure: it says more. (dict(blocked=True, stop_reason="page_load_timeout", rows=[]), EXIT_BLOCKED, None), - # And the negative half, which is what keeps the set honest: - # pages_unattempted means page 1 WAS fetched, so a run holding - # nothing under it really did find nothing. - (dict(blocked=False, stop_reason="pages_unattempted", rows=[]), EXIT_NO_PRODUCTS, None), + # pages_unattempted is a should-never-happen guard: page 1 was + # fetched and the queue never drained. It used to be pinned here at + # exit 4 on the reasoning that the page WAS fetched, so zero rows + # meant an empty listing — and that reasoning was retracted when the + # family unified on `not complete` rather than on a list of stop + # reasons. A list cannot cover a reason nobody has added to it, so + # the list was the hole; and for a state nobody can explain, + # "nothing can be concluded" beats "the listing was empty". + (dict(blocked=False, stop_reason="pages_unattempted", rows=[]), EXIT_FETCH_FAILED, None), ] for i, (kw, expected_rc, expected_status) in enumerate(cases): prefix = os.path.join(tmp, "run%d" % i)