fix(major2): en skrivefeil i finally fortrenger ikke lenger stoppet i flukt

Funn d71a5d72 (MINOR, MISSING_ERROR_HANDLING, run.py:1202). Begge
finally-skriverne ligger i propageringsstien til nettopp det unntaket de er
bevis for: en OSError fra mkdir/write_text mens BudgetExceeded eller
ProposalReviewInputError er i flukt ERSTATTER den - og hverken
`except ProposalReviewInputError` (:3404) eller nekt-tuppelen (:3412) fanger
OSError, saa operatoeren fikk traceback og grunnen til at kjoeringen stoppet var
borte. Review-skriveren gaar paa HVER kjoering med reviewer; parse-skriveren
har samme form.

`_write_or_report` er EN kopi for begge kallstedene (koe-(p)): en regel om hva
en skriver faar gjoere med et unntak i flukt, kopiert, blir en regel anvendt paa
bare det ene. Vakten er BETINGET, aldri en blanket except - uten noe i flukt
finnes ingen stoppgrunn aa beskytte, og en kjoering som ikke fikk skrevet
utboksen maa si fra ved aa feile. Feilen SIES uansett, fordi et fravaerende
artefakt ellers leses som en kjoering uten noe aa registrere (T10/T11).

`in_flight` fanges eksplisitt (`except BaseException as stop: ... raise`), ikke
via `sys.exc_info()`, som ville lest et ytre except-lag hos en bibliotekkaller
som en flukt her.

MAALT mot HELE suiten, to mutasjoner, hver med sin egen signatur, kontroll
1367 passed / 5 skipped og golden `demo-transcript.stdout` BYTE-UENDRET
(`shasum -a 1` av INNHOLDET = ea8c534773acdbe41ae68f2c55724d69aaf8be4f):
MC vakten detached (2 roede - de to in-flight-armene) - MD svelg ubetinget
(1 roed - KONTROLL-armen alene, altsaa er betingelsen selv gatet).
Iron Law: begge in-flight-armene skrevet FOERST og maalt roede mot uendret
run.py; kontroll-armen var groenn foer fiksen, som er nettopp
diskrimineringen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 22:29:56 +02:00
commit 257f44f0cb
2 changed files with 136 additions and 9 deletions

View file

@ -672,6 +672,36 @@ def _features_of(proposal: SavingsProposal) -> ProposalFeatures:
)
def _write_or_report(
write: Callable[[], object], *, what: str, in_flight: BaseException | None
) -> None:
"""Run ONE ``finally`` writer so a disk error can never DISPLACE the run's stop reason.
Both artefacts below are written from a ``finally``, which is what makes them survive the run
that most needs them and is also what put them in the propagation path of the very exception
they are evidence for. An ``OSError`` raised there REPLACES the in-flight ``BudgetExceeded`` or
``ProposalReviewInputError``: neither the CLI's ``except ProposalReviewInputError`` nor its
refusal tuple catches ``OSError``, so the operator got a traceback and the reason the run
stopped was gone.
The guard is CONDITIONAL, never a blanket ``except``: with nothing in flight there is no stop
reason to protect, and a run that could not write its outbox must say so by failing
downgrading that to a clean return reports a success the run cannot evidence. Either way the
failure is SAID, because a missing artefact would otherwise read as a run that had nothing to
record, which is exactly the distinction T10/T11 exist to keep.
ONE helper for two call sites (-(p)): a rule about what a writer may do to an in-flight
exception, copied, is a rule that ends up applied to only one of them."""
import sys # deferred exactly as ``main()`` does — this module keeps ``sys`` off its top level
try:
write()
except OSError as exc:
print(f"run warning: could not write {what}: {exc}", file=sys.stderr)
if in_flight is None:
raise
def _default_factory(profile: Profile | str) -> Callable[[str], BaseChatClient]:
def factory(role: str) -> BaseChatClient:
return get_backend(profile).create_chat_client(model=resolve_model(profile, role))
@ -1176,11 +1206,17 @@ async def run_project(
coverage: tuple[ApproachOutcome, ...] = ()
evaluated: tuple[tuple[str, ValidatedProposal | Rejection], ...] = ()
in_flight: BaseException | None = None
try:
if mandate is None:
validator_outcome = await _evaluate(None)
else:
validator_outcome, coverage, evaluated = await _evaluate_mandate(mandate, _evaluate)
except BaseException as stop:
# Recorded and re-raised UNTOUCHED. This arm decides nothing about the exception itself —
# only what the two writers in the ``finally`` are allowed to do to it (``_write_or_report``).
in_flight = stop
raise
finally:
# ``finally``, not ``except BudgetExceeded``: the round ledger is today's known way out, but
# any exception leaving generation destroys the same evidence, and a per-exception-type list
@ -1188,10 +1224,14 @@ async def run_project(
# file's presence is the signal (a run whose replies all parse leaves the outbox unchanged).
if outbox_dir is not None and parse_failures:
assert run_id is not None # narrowed by the step-0 guard (no wall-clock default)
outbox.write_parse_failures(
outbox_dir,
run_id,
failures=[{"text": f.text, "error": f.error} for f in parse_failures],
_write_or_report(
lambda: outbox.write_parse_failures(
outbox_dir,
run_id,
failures=[{"text": f.text, "error": f.error} for f in parse_failures],
),
what=f"{run_id}-parse-failures.json",
in_flight=in_flight,
)
# Same ``finally``, different write rule: IFF a reviewer was given, including when the
# list is empty (D4). A reviewer-less run must leave the outbox byte-identical, while a
@ -1199,12 +1239,16 @@ async def run_project(
# state rather than one an operator infers from an absent file.
if outbox_dir is not None and proposal_reviewer is not None:
assert run_id is not None # narrowed by the step-0 guard (no wall-clock default)
outbox.write_proposal_reviews(
outbox_dir,
run_id,
payload=proposal_reviews_payload(
expert_reviews, key_of=lambda p: verdict_key(_features_of(p))
_write_or_report(
lambda: outbox.write_proposal_reviews(
outbox_dir,
run_id,
payload=proposal_reviews_payload(
expert_reviews, key_of=lambda p: verdict_key(_features_of(p))
),
),
what=f"{run_id}-proposal-reviews.json",
in_flight=in_flight,
)
proposal = validator_outcome.proposal