Skip to content

fix(isolate): keep the child startup diagnostic on a failed start - #329

Merged
HouMinXi merged 59 commits into
mainfrom
fix/31-child-startup-diagnostic
Nov 1, 2026
Merged

HouMinXi merged 59 commits into
mainfrom
fix/31-child-startup-diagnostic

Conversation

@HouMinXi

@HouMinXi HouMinXi commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

What

When the sandbox child failed before exec, it wrote b"error" to the status pipe and exited 127. On a placement failure the parent never read the pipe. The #31 qualification stop therefore recorded only payload process was not placed in the run cgroup, and the child errno was lost.

This PR covers the #31 acceptance item "preserve a bounded child startup diagnostic, placement/gate status ... on every failure". It does not cover the timing half.

Startup diagnostic:

  • The child records the step it was in when it stopped: setup, place (writing its pid to cgroup.procs), gate, stdio or exec. It writes error stage=<step> type=<exception> errno=<n|none>, capped at 200 bytes.
  • If building that message raises (for example MemoryError), the child writes error stage=unknown instead, then _exit(127) as before.
  • The child reads the gate once. Before, it looped on empty reads, so a gate closed without the go byte (the parent failed or died) left it spinning at full CPU. EOF now raises EOFError. The child reports error stage=gate type=EOFError errno=none and exits 127.
  • The parent records the child (_process, payload_pid) right after fork(), before it closes its copies of the child's fds. If one of those closes fails, the failed start still kills and reaps the child.
  • Only bytes 0x20-0x7e are kept. Anything else becomes ?, both when the child encodes and when the parent decodes, so control characters and non-ASCII bytes never reach the error text.
  • On a placement failure the message includes the child's exit status (exit N, signal N for a negative return code, or still running at deadline) plus the diagnostic. The pipe is read without blocking. When nothing is buffered, the message says no startup diagnostic.
  • An exec failure reports the same diagnostic: payload failed to exec bubblewrap (error stage=exec ...). The read asks for 200 - len(status) bytes each time, so it never reads past the cap.

File descriptors:

  • A failed start closes every pipe and cgroup.procs descriptor the parent still holds, each exactly once. On main the placement-failure path leaked gate_w and status_r. Opening cgroup.procs failing, or fork() failing, leaked 4-5 descriptors (the two pipe pairs and, for a fork failure, the cgroup.procs fd). All three paths are fixed and tested.

Cgroup cleanup:

  • _remove_cgroup used to return silently when rmdir still failed at its 10 s deadline (EBUSY with live tasks, ENOTEMPTY, EACCES), leaving the run cgroup behind with no record. It now sets Supervisor.cleanup_error (printable, at most 200 characters, with the last errno) and raises CgroupCleanupError, a subclass of IsolationError. The exception message is <cleanup_error>: <cgroup path>. It is printable and capped at 400 characters, and its __cause__ is the last rmdir error. The error type and errno come before the path, so a long path cannot push them past the cap.
  • cleanup_error is reset at the start of each removal, so an old failure does not survive a later successful removal.
  • A cgroup that is already gone still counts as removed, with no error and no retry (fix(isolate): treat an already-removed run cgroup as a successful removal #320). rmdir is tried at least once before the deadline is checked.
  • teardown() forces removal (rewrites cgroup.kill between retries). If an earlier removal already ran out its deadline (a failed start), teardown makes one more attempt instead of waiting another 10 s. It is marked done only after the cgroup is gone, so a second teardown retries instead of returning silently.
  • On a failed start, the start error stays the raised exception. A cleanup failure is attached to it as a note. Notes go through isolate.attach_note(), which drops any failure while adding the note, the same as ledger.py. It also skips a note the exception already carries. Without that, a start failure whose cgroup could not be removed got the same cleanup note twice: once from Supervisor.start() and again from _run_sandboxed's teardown.
  • verify_isolation_support() used to ignore a probe cgroup it could not remove. It now raises IsolationUnavailable(reason="probe_cleanup_failed") with a bounded, printable detail (probe cgroup was not removed (type=... errno=...): <path>, the path last for the same reason), or adds that detail as a note when another probe error is already on its way out. A probe cgroup that is already gone (FileNotFoundError) counts as removed and reports nothing.
  • MutmutAdapter.probe() reports probe_cleanup_failed as probe-cleanup-failed in phase cleanup with retryable=True. Missing isolation stays isolation-unavailable in phase probe with retryable=False.

The mutmut engine (python_mutmut._run_sandboxed, also used by the gremlins, cargo-mutants, Stryker and patch-corpus adapters):

  • If the command fails to start or is interrupted, that error propagates, including KeyboardInterrupt, SystemExit and CancelledError. A CgroupCleanupError from teardown is attached as a note instead of replacing it. Before this change the cleanup error replaced it, and the original was only __context__.
  • The supervisor thread is joined in a finally on both paths. memory.peak is read inside the guarded block, so an interrupt during that read still tears down and joins.
  • If the command finished but its cgroup was left behind, the run fails closed: CgroupCleanupError is raised with receipt, exit_code and timed_out set, so the result of the command is not lost. I chose this over returning the exit code, because a cgroup left behind means the host is not in the state the next run assumes.
  • MutmutAdapter.run() turns that error into a TargetResult with run_state=INCOMPLETE, reason_code="cgroup-cleanup-failed" and Cleanup(state=INCOMPLETE, owned_group_empty=False, errors=(<cleanup error>,)). The command's receipt is kept, and so is the baseline if it had already run. A cleanup-failure.json artifact records the message, exit code and timeout flag. Before this change run() always reported owned_group_empty=True.

Other adapters:

  • Every sandbox failure result is now built by base.sandbox_failure_result(). It takes no earlier infrastructure errors. The only caller that passed any (mutmut) always passed an empty list at that point, so that parameter is gone. The mutmut, gremlins, Stryker, cargo-mutants and patch-corpus adapters return it from run() for both CgroupCleanupError and IsolationUnavailable, instead of letting either escape.
  • Gremlins, Stryker and cargo-mutants collect receipts, artifacts and the baseline in a RunEvidence object owned by run(). A failure on the second or third command keeps every earlier receipt and the passed baseline. Patch-corpus and mutmut already kept theirs.
  • IsolationUnavailable from Supervisor.start() gives run_state=INCOMPLETE with the isolation error's code and an isolation-failure.json artifact (message, reason, cleanup failure). Supervisor.start() re-checks isolation before each command, so the error is reported in phase baseline if no baseline was recorded yet and mutation after that, not probe. A probe cgroup left behind stays a cleanup error. If the run cgroup was also left behind after the start failure, _run_sandboxed records that on the exception, and the result reports it as a retryable cgroup-cleanup-failed error with owned_group_empty=False. Otherwise cleanup is COMPLETE.
  • The gremlins probe runs gremlins --version in the sandbox. A cleanup failure there returns UNAVAILABLE_ISOLATION with a retryable cgroup-cleanup-failed error. Missing isolation there now returns UNAVAILABLE_ISOLATION too, instead of raising out of mutation_dispatch. The probe workspace is a TemporaryDirectory; before, each call left a forge-gremlins-probe-* directory in the temp dir. The Stryker and cargo-mutants probes do not use the sandbox.

The existing message prefixes are unchanged. The placement check, gate order and limits are also unchanged.

Refs #31

Tests

tests/test_isolate_startup_diagnostic.py has 51 test functions (67 cases). The integration tests drive the real Supervisor.start() fork, gate and placement path on a host without cgroups or bwrap. The cgroup root is a plain directory and cgroup.procs is a regular file, so the real child writes its pid there and the placement check finds it. Faults are injected through a proxy installed as isolate.os. It forwards to the real os, and the overrides act only in the child unless a test says otherwise.

  • Each test has a 15-second limit, enforced by SIGALRM in the main thread (CI has no pytest-timeout). A regression that blocks a read or loops forever fails that test and does not hang the CI step. Tests that start a real child stop it through a helper: it joins the supervisor thread, kills a child still stuck before exec, and reaps it, so no thread or process outlives a failed test.
  • One test per child stage, each asserting the exact message: place, exec, setup, gate, stdio.
  • A gate closed without the go byte: the child reports stage=gate type=EOFError errno=none and exits 127.
  • The parent's first os.close after fork() fails: the start error is raised, the child is killed (-SIGKILL) and reaped, and every fd is closed.
  • The stage=unknown fallback when the diagnostic formatter raises MemoryError or KeyboardInterrupt.
  • On the exec path: non-ASCII bytes become ?. With each parent read capped at 150 bytes, a 300-byte diagnostic makes the parent ask for 200 and then 50 bytes, and it keeps exactly 200. No sleep.
  • The placement deadline with the child still blocked on the gate.
  • cgroup.procs open failure, fork() failure, and every parent fd closed exactly once on the placement and exec failure paths.
  • A parent os.close that fails during start cleanup: the placement error is still raised and every fd is still closed.
  • A non-blocking read that fails with EBADF raises instead of reading as "no startup diagnostic". After a diagnostic read the pipe is non-blocking, checked with os.get_blocking.
  • _error_detail prints errno=none for an errno that is not an int.
  • Start failure with the real _remove_cgroup, a fake rmdir (ENOTEMPTY) and a fake clock: the placement error is raised with the cleanup note, one attempt is made, teardown makes one more and raises, then succeeds once the cgroup goes, and a third teardown does nothing.
  • Removal unit tests with a fake clock: EBUSY, ENOTEMPTY and EACCES at the deadline; the 400-character printable message and its __cause__; with a 600-character path, the message still starts with the full detail, including errno=16; a stale cleanup_error cleared by a later success; forced and unforced cgroup.kill rewrites; teardown arguments (force=True, timeout=10, then timeout=0).
  • attach_note() when __notes__ is not a list, when reading it raises a BaseException that is not an Exception (the call returns normally, reads __notes__ once and does not try to add the note), and when the same note is attached twice (kept once). Two notes that differ only in the errno at the end are both kept, and a note already in the list is skipped wherever it sits (X, Y, X, Y gives [X, Y]). Only an equal note is skipped: a note that contains another, sits inside one, or shares its end with one is still added, in either order (cleanup failed: /x and cleanup failed, errno=16 and rmdir errno=16). Notes are compared in full: two 401-character notes that differ only in the first or the last character are both kept, as are notes that differ only in case or in a trailing space, and first, second, first gives [first, second], so a repeated note keeps its first place. attach_note does not cut notes, so the long notes are stored whole. Notes are compared by value, not identity: a second first built at runtime ("".join(["fir", "st"]), checked to be a different object) is skipped.
  • Probe cleanup: a probe cgroup left behind raises probe_cleanup_failed with a 200-character bound (a 250-character root loses only the end of its path), is a note on a "lacks memory.max" error, and a clean probe stays silent. A probe cgroup already gone is silent, and adds no note to a "lacks memory.max" error.

tests/test_python_mutmut_cleanup.py (new, 19 cases) uses a fake Supervisor and SupervisorThread:

  • an in-flight RuntimeError, KeyboardInterrupt, SystemExit or CancelledError with a cleanup failure: the same error object is raised, with the cleanup message as its note, and the thread is joined;
  • the note cannot be added: the original is still raised;
  • a start failure that already carries the cleanup note: the note appears once;
  • teardown raising some other error: the thread is still joined;
  • a finished command with a cleanup failure: CgroupCleanupError with the receipt, exit code 7 and timed_out=False;
  • run() with a cleanup failure after the baseline and after mutmut: INCOMPLETE, owned_group_empty=False, the cleanup message in cleanup.errors and infrastructure_errors with retryable=True, the exact cleanup-failure.json contents (message, exit code, timed_out), that artifact listed in native_artifacts, the receipts kept, and the passed baseline kept in the second case;
  • KeyboardInterrupt while reading memory.peak: teardown runs once and the thread is joined;
  • the probe: probe_cleanup_failed is a retryable cleanup error, other isolation failures are not retryable;
  • IsolationUnavailable on the baseline command: phase baseline, baseline UNKNOWN, no receipts;
  • IsolationUnavailable on the mutmut command after a passed baseline: phase mutation, INCOMPLETE, the baseline receipt and the baseline kept, cleanup COMPLETE; and the same with the run cgroup also left behind: owned_group_empty=False and both errors reported.

tests/test_mutation_adapter_cleanup.py (new, 28 cases) runs each adapter's real run() against a fake sandbox:

  • gremlins (baseline, inventory, outcomes), Stryker (baseline, stryker) and cargo-mutants (baseline, mutants), failing at each command with a cgroup left behind: INCOMPLETE, every earlier receipt plus the failing one, the PASSED baseline once it has run, its artifact, and cleanup-failure.json;
  • each adapter with IsolationUnavailable at each command, in phase baseline on the first command and mutation after it, and with the run cgroup also left behind;
  • the gremlins probe: a cleanup failure, missing isolation (unavailable and probe_cleanup_failed), a run cgroup left behind after a start failure, and no forge-gremlins-probe-* directory left after any of four outcomes;
  • patch-corpus on a corpus entry with a cleanup failure or missing isolation (phase mutation): the passed baseline, the receipts and both artifacts kept;
  • patch-corpus with a sandbox failure on the baseline command: baseline UNKNOWN, only the failure artifact;
  • patch-corpus failing on the second of two entries: the first entry's receipt and event artifact are kept;
  • in every failing run() test above: the result's target_id, adapter_version, tool_version and extractor version, owned_mounts_removed, the target_id of every error, the probe-cleanup and isolation error messages, and the failure artifact's digest and byte count checked against the published file.

tests/test_mutation_isolate.py::test_remove_cgroup_force_rewrites_kill expects CgroupCleanupError, because its cgroup never empties.

Mutation check: the previous description said 33 of 33 mutants were killed. That held for my set, but review ran 48 mutants against 672db92 and 4 survived: retryable=False on the cleanup error, exit_code or timed_out dropped from cleanup-failure.json, and the artifact not added to native_artifacts. One more (attach_note catching IsolationError) was equivalent. The test changes above kill those 4.

For 926132a I cleared __pycache__, checked that a deliberate break fails first, then applied 91 hand-written mutants one at a time to isolate.py, python_mutmut.py, base.py and the four other adapters, running the four test files above against each. All 91 were killed. Besides the earlier set (updated for the renamed helper), they cover: dropping the IsolationUnavailable catch in each run() and in the gremlins probe; not recording the cleanup failure on the exception or ignoring it in the result; not recording a baseline, receipt or artifact in each adapter, or not passing them to the result; and leaking the probe temp dir. For ec5189f I ran 8 more mutants on the phase change, after a deliberate break failed first: dropping it, swapping baseline and mutation, inverting the baseline check, always baseline, always mutation, applying it to the cleanup phase too (two forms), and never recording the isolation error as a cleanup error. All 8 were killed. Review then found 11 surviving mutants at ec5189f in base.py and patch_corpus.py. For 8419350 I ran 19 mutants plus a deliberate break that failed first, from a separate copy of the tree with __pycache__ cleared and PYTHONDONTWRITEBYTECODE=1. They were: the evidence digest and byte count; the result's target_id, adapter_version, tool_version and extractor version; owned_mounts_removed=False; target_id and message in each of the three error helpers; deleting baseline = None in patch-corpus run(); dropping the event artifact append; and four on the new attach_note check (removed, inverted, return made pass, default None). All 19 were killed. An earlier form of the check (isinstance(notes, list) and note in notes) had an equivalent mutant, so the check is now note in getattr(exc, "__notes__", ()). A non-list __notes__ makes add_note fail anyway, and that failure is dropped. Cross-check then found two mutants of that check that survived at 8419350: comparing only the first 8 characters, and comparing only the last note. For 12065ea I added tests for both and reran 7 mutants of the check after a deliberate break failed first: those two, first note only, check removed, inverted, return made pass, and default None. All 7 were killed. Review then found a third survivor at 12065ea: matching any note that contains the new one (note in n). For 991651b I added a test that attaches two notes where one contains the other or shares its end, in both orders, and reran 11 mutants of the check after a deliberate break failed first: contains, contained in (n in note), last 3 characters only, last 8 characters only, first 8 characters only, last note only, first note only, check removed, inverted, return made pass, and default None. All 11 were killed. Cross-check then found 7 more mutants that survived at 991651b: comparing only the last 16 or 400 characters, only the first 111 or 399 characters, ignoring case, ignoring surrounding space, and moving a repeated note to the end. For 3340a58 I added a test for those cases. Before the commit, the 7 survived against 991651b's tests. With the new test, I reran them with the earlier 11 after a deliberate break failed first, and all 18 were killed. Cross-check then found one more survivor at 3340a58: comparing by identity (n is note), because every test note was a literal or the same object reused. For ac73acf I added a test with an equal note built at runtime. Before the commit, the mutant survived against 3340a58's tests. With the new test, I reran it with the earlier 18 after a deliberate break failed first, and all 19 were killed.

Review of ac73acf found that two mutants were caught only by hanging: removing os.set_blocking from the diagnostic read, and >= to > on the _remove_cgroup deadline. For 5d5f164 I ran 10 mutants from a separate git archive copy with __pycache__ cleared, PYTHONDONTWRITEBYTECODE=1, Python 3.12 and pytest 9.1.1, no -x and nothing deselected. A deliberate break (stage = "gat") failed first. All 10 were killed:

Mutant Result
procs_fd left out of parent_fds killed, 19 failed
attach_note catching Exception instead of BaseException killed, 1 failed
os.set_blocking removed killed, 3 failed (two by the 15 s limit, one by the new get_blocking check)
_remove_cgroup deadline >= to > killed, 6 failed, no hang (1.5 s)
isinstance(code, int) to code is not None killed, 1 failed
gate EOF back to the spin loop killed by the 15 s limit
gate EOF ignored killed, 1 failed
_process recorded after the closes again killed, 1 failed
probe message with the path first killed, 3 failed
run cgroup message with the path first killed, 6 failed

No mutant left a process behind. The infra_errors mutant from review no longer applies, because the parameter is gone.

The previous rounds' 52 mutants on the diagnostic, fd and decode code are unchanged. The one equivalent mutant there (the exec read loop as while True) still applies.

Commands run at 5d5f164:

  • pytest tests/test_isolate_startup_diagnostic.py tests/test_mutation_isolate.py tests/test_python_mutmut_cleanup.py tests/test_mutation_adapter_cleanup.py: 133 passed, 8 skipped (those need a real cgroup/bwrap host), on Python 3.14 and on Python 3.12. By file: 67, 19 (+8 skipped), 19 and 28.

  • pytest tests/test_mutation*.py tests/test_isolate*.py tests/test_python_mutmut_cleanup.py: 2458 passed, 25 skipped, 1 failed. The failure is test_mutation_ledger_ci_controls.py::test_ci_null_baseline_deadline_is_a_config_error_before_any_baseline, which fails the same way with main's isolate.py on this box.

  • pytest tests/ -k "isolate or sandbox or mutation_engine or mutmut": 183 passed, 16 skipped, 1 failed. The failure is test_cli_integration.py::TestSandboxWarning::test_sandbox_warning_emitted (no toolchain detected on this box), which also fails on main.

  • ruff check, ruff format --check (ruff 0.17.3) on every touched file, and scripts/lint_changed.py --base origin/main: clean.

  • Full suite: CI is the reference. The Linux tests run for 5d5f164 (run 40917406244) ran from 13:02 to 13:44 EDT and passed: real ownership and cancellation regressions 568 passed; cgroup-backed mutation sandbox 32 passed; full service-free selection 28982 passed, 81 skipped, 68 deselected; local process integration 11 passed, 7 deselected. CodeQL and the changed-file lint also passed. The run for ac73acf (run 40907382613) passed: the full service-free selection had 28977 passed, 81 skipped, 68 deselected; the cgroup-backed mutation sandbox step had 32 passed; the local process integration step had 11 passed, 7 deselected; the real ownership and cancellation regressions had 568 passed. The run for 3340a58 (run 40906340594) was cancelled at 12:01 EDT. I do not quote box counts for the full suite: on this box 61 tests fail the same way at 9c1cc62 and at this branch (no toolchain detected, and the reporting_product_findings frozen-hash checks), which CI does not show.

Not run by me: anything on a real cgroup v2 + bwrap host. The CI step above is the only run on one.

Residual risk

  • The exec-path read waits for EOF. If another thread forks while the status write end is open, that fork holds a copy and the read blocks until the copy closes. The placement path reads without blocking.
  • If the child is killed outright (not an exception), it writes nothing. On the placement path that gives no startup diagnostic. On the exec path, an empty pipe reads as a successful exec, and the failure shows up later as the payload's exit status.
  • A placement failure in Supervisor.start() raises a plain IsolationError, which no adapter's run() catches. It still escapes run(), and the receipts, baseline and artifacts gathered before it are lost. This predates the PR and is not fixed here: reporting it needs its own error code and phase.
  • The diagnostic is formatted in the child after fork() from a threaded process, as the existing child code already did. The child only builds a short string, writes and calls os._exit.
  • Review reproduced about 15k inodes under .planning/ (15,229 in its worktree), written by test_invocation_owner and test_invocation_audit. That predates this PR and is not addressed here. My subset runs above do not include those tests, and this clone has no .planning/ afterwards.

#31 work left after this PR

  • A pre-launch topology and migration-permission check: that a normal user can move a process into the run cgroup.
  • Separate startup and cleanup timings on failure (the other half of the diagnostic acceptance item).
  • Live qualification on a delegated cgroup v2 + bwrap host.
  • Reporting NOT RUN separately from PASS.

This PR does not qualify Linux execution.

No behavior change. machine.py is not format-clean on main, and the
Lint changed Python files gate runs ruff format --check on every
touched file, so the formatting lands as its own commit ahead of the
#262 change.
…#262)

The identical-round stall counted every unpromoted CONFIRMED L0 finding
as fix budget left. The LOCAL autofix loop skips MUTANT, E2E_CHECK,
FIXVAL and INFRA findings, so they never spend budget or get promoted,
and a map frozen only by them ran to max_total_rounds.

The predicate now treats a fingerprint as unable to move the map when
the current findings carry it and every finding with that fingerprint
has a skipped source. Both sites share one _AUTOFIX_SKIPPED_SOURCES set.
A forked sandbox child that failed before exec wrote b"error" and exited
127, and on a placement failure the parent never read even that. The
recorded #31 qualification stop therefore said only "payload process was
not placed in the run cgroup", with no child errno.

The child now records which step it was in (setup, place, gate, stdio,
exec), the exception type and errno, bounded to 200 ASCII bytes. A
placement failure reports the child exit code, or that it was still
running at the deadline, plus that diagnostic, read without blocking. An
exec failure carries the same diagnostic. A failed start also closes the
pipe and cgroup.procs descriptors the parent still held.

Refs #31
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

HouMinXi and others added 26 commits October 29, 2026 06:07
Production L0 runners emit source "L0" only, so the skipped-source rule is a
defensive alignment with the autofix skip set for injected or future L0
producers. Say so in the docstring and test module, and name the set instead
of repeating its members.

Add two cases: an unknown source still holds off the stall (the skip set is
an allow-list), and one fingerprint carrying two skipped sources stalls.

Refs #262
#197 notes that (dev, ino, ctime) stamps cannot see a change undone
within one kernel clock tick, and that this also applies to the #103
snapshot races. Say so at the three stamp comparisons in
_mutation_workspace.py: the cache file snapshot, the cache directory
snapshot and the configuration read. Comments only.

Refs #197
…ot (#262)

The stall predicate read source sets from the merged state, which keeps
one finding per fingerprint. A raw [fp-x/L0, fp-x/MUTANT] pair collapsed
to the MUTANT finding, so the map stalled while fp-x still had fix
budget left.

Each round snapshot now carries l0_autofix_skipped: the L0 fingerprints
whose every raw finding has a source the autofix loop skips. The
predicate reads only the latest snapshot's list. A missing or malformed
field proves no skip, so every unpromoted L0 fingerprint holds the stall
off, as on main.

Tests build rounds through the real merge and snapshot code, use a
NO_CHANGE autofixer so budget assertions can fail, and add an end-to-end
case for the duplicate pair.
…ilures

Refs #31.

- Child: if formatting the diagnostic itself raises, still write
  "error stage=unknown" before _exit(127).
- Diagnostics keep only bytes 0x20-0x7e (others become '?') on both the
  child encode and the parent decode; exits below zero read "signal N".
- The exec status read asks for LIMIT - len(status) bytes instead of
  reading the full limit and slicing.
- _remove_cgroup raises CgroupCleanupError (and sets cleanup_error) when
  rmdir still fails with EBUSY, ENOTEMPTY or EACCES at the 10 s deadline.
  An absent cgroup still counts as removed (#320). A failed start keeps
  its own error and carries the cleanup failure as a note.
- Tests patch isolate.os through a scoped proxy instead of the global
  os.open, cover every child stage, the fallback, fd ownership on each
  failure path, byte bounds, and the cleanup deadline with a fake clock.
The pending-promotion guard counted any L0 fingerprint with a spent fix
budget, including one the autofix loop now skips. A fingerprint that spent
its budget as L0 and then arrived only from a skipped source is never
promoted, so it held the stall off until max_total_rounds. The skip proof
is now read before the guard and the guard ignores skipped fingerprints.

Tests pin the remaining review findings: promotion at exactly the limit
(>= vs >), attempts above the limit after a resume with a lower
max_fix_attempts (>= vs ==), a sorted skip field over 14 fingerprints,
malformed str/tuple/set/dict values failing closed, the skip proof coming
from the projected row rather than a trailing pending or dispositionless
host row, and a resume from a state.json written before the field.

Refs #262
Refs #31

python_mutmut _run_sandboxed: a CgroupCleanupError from teardown no
longer replaces the error in flight (including KeyboardInterrupt,
SystemExit and CancelledError); it is attached as a guarded note and
the original is re-raised. The supervisor thread is joined in a finally
on both paths. When the command finished but its cgroup was left
behind, the run fails closed: CgroupCleanupError is raised with the
receipt, exit code and timeout flag attached, and run() turns it into
an INCOMPLETE TargetResult whose Cleanup record has
owned_group_empty=False and the cleanup message in errors.

isolate:
- attach_note() adds a note and drops any failure while doing so.
- The CgroupCleanupError message is printable and capped at 400
  characters, and chains the last rmdir error as __cause__.
- _remove_cgroup resets cleanup_error at the start, so a stale failure
  does not survive a later successful removal.
- teardown forces removal, makes one attempt (timeout 0) when an
  earlier removal already ran out its deadline, and marks itself done
  only after the cgroup is gone, so it can be retried.
- verify_isolation_support reports a probe cgroup it could not remove
  (reason probe_cleanup_failed), or notes it on an error in flight.

Tests cover each of these, plus a read error that is not EAGAIN, a
close failure during start cleanup, the real _remove_cgroup on the
start path, the child fallback under KeyboardInterrupt, and the exec
read asking for 200 then 50 bytes without a sleep.
)

Review follow-up, comments only (the module AST is unchanged):
- The file snapshot check misses a same-size write; size changes are
  seen.
- The directory check misses a create+remove only in the same tick as
  the directory's previous change.
- _read_config misses a same-tick rewrite only when expected is None;
  with expected set, the sha256 comparison catches changed bytes.
- All three say Linux 6.13+ multigrain timestamps close the window,
  in the wording conventions.py uses.
- The tick is given as 1-4 ms everywhere in the file.
- verify_isolation_support: a probe cgroup that is already gone is not a
  cleanup failure (FileNotFoundError is ignored, as in _remove_cgroup).
- Mutmut probe: a probe cgroup left behind is reported as a retryable
  cleanup error (probe-cleanup-failed), not as missing isolation.
- Move the cleanup-failure result into base.cleanup_failed_result and use
  it from the gremlins, Stryker, cargo-mutants and patch-corpus run(), and
  from the gremlins version probe, so a run cgroup left behind gives an
  INCOMPLETE result instead of an uncaught CgroupCleanupError.
- _run_sandboxed reads memory.peak inside the guarded block, so an
  interrupt there still tears down the supervisor and joins its thread.
- Tests assert the retryable flag, the cleanup-failure.json contents and
  that the artifact is listed in native_artifacts.

Refs #31
The tick is 1 ms at HZ=1000, 4 ms at HZ=250 and 10 ms at HZ=100, so
the four comments in _mutation_workspace.py now say 1-10 ms depending
on CONFIG_HZ. Comments only; the module AST is unchanged.
The skip proof was read from the latest projected row only, so an L0
fingerprint the autofix loop handled earlier in the frozen window counted
as skipped. L0/MUTANT alternation stalled at round 3 with 2 of 3 attempts
spent, and MUTANT/L0 at round 2 with 1 of 3, where main promotes at
rounds 6-7.

A fingerprint now counts as skipped only when all three window rows list
it in l0_autofix_skipped. A missing or non-list field in any of them
proves no skip.

Tests: alternating sources in both orders promote; each window row must
prove the skip; rows before the window do not matter; L0x3 then MUTANT
stalls at round 5; MUTANTx3 then L0 still stalls at round 2 (known
limit). The resume test now asserts round 5, which a fail-open reading of
the missing field would turn into round 3.

Refs #262
…apter

- The gremlins, Stryker and cargo-mutants adapters now collect receipts,
  artifacts and the baseline in a RunEvidence object owned by run(). A
  sandbox failure on a later command keeps every earlier receipt and the
  passed baseline instead of only the failing receipt.
- IsolationUnavailable raised while starting a sandboxed command becomes an
  INCOMPLETE result in the mutmut, gremlins, Stryker, cargo-mutants and
  patch-corpus run() paths, and UNAVAILABLE_ISOLATION in the gremlins probe.
  If the run cgroup was also left behind, that is reported as a cleanup
  error. Both cases go through base.sandbox_failure_result(), which replaces
  cleanup_failed_result().
- The gremlins probe uses a TemporaryDirectory, so it no longer leaves a
  forge-gremlins-probe-* directory behind on each call.

Refs #31
Supervisor.start checks isolation again before each command, so an
IsolationUnavailable reaching sandbox_failure_result happened on a run
command, not in the adapter's probe. Report it in phase "baseline" when no
baseline was recorded yet and "mutation" after it. A probe cgroup left
behind stays a "cleanup" error, and the probes still report "probe".

Refs #31
_write_private_config and _retain_config_backup read with expected None
but compare sha256 against the bytes they just wrote, so a same-tick
rewrite there is caught. Only the first capture in acquire has no
reference bytes. Comment only; the AST is unchanged.
When Supervisor.start fails and its cgroup cannot be removed, the start
error carries the cleanup note, and the mutmut adapter's teardown then
added the same note again. attach_note now skips a note that is already
on the exception.

The adapter cleanup tests now check every field sandbox_failure_result
fills: target id, adapter and tool versions, extractor version,
owned_mounts_removed, the target id and message of each error, and the
digest and byte count of the failure evidence against the published
file. Two new patch-corpus tests cover a sandbox failure on the
baseline command and a failure on a later corpus entry, where the
earlier entry's receipt and event artifact must be kept.

Refs #31
fix(machine): skipped-source L0 findings no longer hold off the identical-round stall
A start failure and its teardown can fail on the same cgroup with
different errno values, so their notes share a long prefix and must
both be kept. A note added before the last one must not be added again.

Refs #31
Only an equal note is skipped. A note that contains another, is contained
in one, or shares a suffix with one is still added, in either order.

Refs #31
Document the same-tick ctime limit in mutation workspace checks (#197)
Notes that differ in one character after a long shared prefix or before
a long shared suffix, in case, or in trailing space are all kept, and a
repeated note keeps its first place.

Refs #31
The second note has the same text as the first but is a separate string
built at runtime, and it is skipped.

Refs #31
Record clean_credit_action=preserved for Q2=(iii) receipt rows only
via credit-side skip of _reset_clean_credit. Prior earned window
survives; no new earn; still non-PASS. _fixpoint_reached product
RESET rules untouched. Closed-set + earned_history treat preserved
like interrupted/evidence_rejected.
… held)

- H1: failed-attempt path preserves only when every round phase returned;
  predicate also requires all phases returned.
- H2: _host_receipt_active() is the first predicate guard; non-receipt
  local runs keep the M6 reset exactly (regression test).
- Gate path: preserve only when every gate error is the attempted-evidence
  rejection; otherwise wipe + reset (closes literal-damage + coverage
  softening found while probing).
- M1: validator requires all phases returned, no protocol failures, and
  (iii)-exact dispositions (CONFIRMED schema/coverage cause present in
  l1_fingerprints; others limited to mutation infra / UNCERTAIN receipt
  audit). Negative tests carry match=.
- M2: per-guard StateMachine tests, recurring CYCLE_RESTART survival,
  schema + coverage near-miss (incl. file), coverage on gate path.
- M3: match= everywhere, seeded windows, factory-built fixture check.
- L1: mutation-survivor ladder routed through the predicate. L2: CYCLE_RESTART
  log no longer says "restarting" when credit is preserved. L5/L6 docstrings.
- M4: held fail-closed (recurring P3 + new schema resets); pinned by test,
  pending user decision.

verify.py unchanged.
- M4 (user decision, plan F Q7 / P section 2.2): preserve iff the unchanged
  fixpoint, re-evaluated without the (iii) schema/coverage findings, is
  CLEAN. Recurring sub-threshold P3 + schema/coverage now preserves; P0-P2,
  density, reversions, new CONFIRMED and semantic UNCERTAIN keep the reset.
  _fixpoint_reached is not modified (_fixpoint_without swaps findings and
  restores them).
- M4 loader: a preserved row may carry an open product CONFIRMED only when
  it recurs as the fixpoint sees it (seen before, not FIXED in the previous
  projected row, not first-confirmed after a protocol observation; synthetic
  acquisition markers projected out). Severity is not persisted, so P0-P2
  refusal stays in the machine counterfactual. H1/H2/M1 guards unchanged.
- L-B: gate errors are read before the CYCLE_RESTART log; a non-attempted
  gate error clears the preserve decision first, so the log always names
  the ledger action.
- L-A: pin audit-unavailable (+ receipt-write I/O) beside attempted evidence
  wipes credit, as on M6 (deliberate narrowing; T6 preserved).
- L-C: run-level coverage of the survivor-ladder preserved arm, plus the
  default (P2) survivor reset.

verify.py unchanged.
…ed-window wipe (L-3), M4 docstring (N-2)

Tests only; no src change vs ee7a258. All greens not independently reproduced.
…panion strip

Per plan-trio c3-impl-spec-joint-v1.md (sha256 bc577a5a...cdc90).
Predicate: _schema_coverage_credit_preserve removes UNTRUSTED companions of a
single parsed schema-fail response (rules 1-5, D2) from the counterfactual.
Writer: _finish_host_attempt omits those fps from a preserved row's
dispositions (mechanism C). No loader change; state.py untouched.
Narrow scope (single-response schema failure) is user-question-pending.
Tests: spec section 4 unit + run-level; M4 :707 test renamed to coverage.
HouMinXi and others added 26 commits October 29, 2026 14:39
Move _validate_preserved_dispositions, _recurring_confirmed,
_project_product_row, _PRESERVED_III_FP and their private helpers
_RECEIPT_AUDIT_FP and _MUTATION_INFRA_FP verbatim (ast.dump identical)
into the one new module src/code_forge/state_preserved.py. state.py
imports and re-exports them (958 lines, under 1000).

state_preserved.py does not import state at module level; its single
state dependency, is_host_round, is a late-bound wrapper that resolves
state.is_host_round at call time. No test monkeypatches the moved names.
Behavior unchanged.
…g receipt-write errors once

CR3R-L1: when the main path already finished the round as `preserved`
(receipt gate or active path) and a later step raises, the failure handler
now turns the row back into the M6 reset before any early return: credit 0,
earned window wiped, stripped C3 companion fingerprints restored to the
row's dispositions. The original exception still propagates.

CR2R2-L1: the failure handler logs only receipt-write errors that the
TimeoutBreaker branch or the receipt gate finding has not already logged,
so infra_errors and the CLI show one line.

Tests: two new fix6 cases (state save OSError after preserve, gate record
error after preserve) in the reset and healthy-round tests, the C3
companion restore, the failure-record save error never replacing the
primary (CR3R I-4), and one-entry/one-line checks for both logging paths.
…roject_product_row (J-L1, J-N1, I-9)

- test: an OSError on each state save after a `preserved` settle (receipt
  gate, 3-survivor stop, active fixpoint; b33b72c :2289/:2314/:2356) ends
  as reset with credit 0, and one healthy round then earns credit 1, window [4].
- test: the gate's `receipt: <error>` entry plus a failed state save at the
  gate logs the receipt-write error exactly once (J-N1 b).
- docstrings of the fix6 failed-attempt tests match the fix7 behavior.
- state_preserved rebinds `_project_product_row` to a late-bound shim over
  `state._project_product_row` (state re-exports the moved definition), so a
  patch of state reaches the split-out validator (joint I-9).
…credit preserved" (L-B)

A failure after the active path settled `preserved` on CYCLE_RESTART left
this attempt's "clean credit preserved" line beside the reset row. The
demotion now rewrites that line to the restart line, so the log names the
action the ledger records. The two log strings are module constants.
Test: the post-settle crash test gains the active CYCLE_RESTART save site.
fix7 demoted any preserved row at state.round on failure. A Ctrl-C between
rounds, or a CorruptedStateError in _begin_host_attempt, turned the previous
round's saved preserve into reset/0/[]. Record _settled_preserve_round when
_finish_host_attempt settles a preserve, clear it at every loop start and at
_begin_host_attempt entry, and demote only that round.

Tests: interrupt/CorruptedStateError between rounds keeps the prior preserve on
disk and in memory; an early crash after resume (begin, receipt diff; CR3 I-6)
changes neither state.json nor memory.
The real definition returns to state.py with its 1d5c6c0 body; state_preserved
keeps only the call-time shim (like is_host_round). No duplicate def, no F811.
Patching state._project_product_row still reaches the preserved validator
(I-9), and the function pickles again.
…-attempt bookings

- A demotion now always takes the failure path's save (CR2G P12): the
  not-observed early return no longer applies once a row was demoted.
- _booked_receipt_write_errors resets with _last_receipt_write_errors when each
  attempt's L1 starts (CR2G I4), so a second run() logs its own error.
- Tests: same-attempt resets after the saved preserve (real #114 breaker,
  Ctrl-C in the hold check, stall and MAX_TOTAL_ROUNDS ESCALATED saves);
  earned row kept after a post-settle failure (CR2F7-L2 P11); pre-attempt
  preflight Ctrl-C after resume changes nothing; demotion reaches disk; a
  second run's identical receipt error is recorded once.
With the finished round's phase status left as returned, the failed path saved
at 311077f too, so the test was green there. Now red at 311077f (row stays
preserved on disk) and green at fix8.
A second run() on the same instance that failed before its first
attempt (preflight, receipt diff, continuation index) still held the
guard from the previous run(), so it demoted the row that run had
settled preserved and saved the reset. run() now clears the guard
before it loads prior state.

Tests: the same-instance second run() interrupted at each of the three
points keeps round 2's row, credit and window on disk and in memory
(red on 072e3bb); a direct `_begin_host_attempt` call with a forced
stale guard leaves the preserve alone (pins the begin-entry clear).
…ts attempt

Joint review R1 on 072e3bb (J-L1, J-N1, J-N2):
- the same-instance second run() case gains a preflight TimeoutBreaker;
- E2: run 1's demotion save fails (disk keeps preserved, credit 2); the
  next run() on the same instance, interrupted before its attempt,
  leaves disk preserved with credit 2 and window [1, 2];
- E3: A settles round 2 preserved, the ledger is rolled back and B
  settles round 2 earned (credit 3) or preserved (credit 2); A's second
  run(), interrupted before its attempt, leaves B's row unchanged;
- test_fix8_demotion_always_reaches_disk now fails inside the attempt
  that settled the preserve, after the final save, with its observations
  dropped; it no longer relies on a guard left over from a finished run().
…ht (CR2F8-L1)

R2 P13a/P13b on 072e3bb: run 1 returns ESCALATED (coverage) or FAIL
(schema gate) with round 2 preserved; run 2 on the same instance fails in
the startup preflight (Ctrl-C or RuntimeError). Round 2 stays preserved
with credit 2 and window [1, 2], reloaded from disk and in memory.
A second run() on the same instance that failed before its first
attempt still saw the previous run's phase findings, phase status and
receipt-write errors. Its failed path then logged the earlier run's
receipt-write error a second time and saved a FAIL verdict.

Move every per-run, per-attempt and per-round attribute into one
helper, _reset_run_state(), called from __post_init__ and as the first
step of run(). It replaces the scattered run() entry resets, including
the clear of the settled-preserve guard. Advisories, their buffer and
the mutation review source stay instance-scoped, as before.

Comment and docstring now say the demotion applies within the same
run(), before the next attempt begins.
Add the secondary-interrupt rerun case, a P21/P13 test that each
receipt-write error is logged once and the ledger and verdict reload
unchanged, and a generic test that run 2 on the same instance saves the
same state.json as a new instance doing the same run 2.
The run-entry reset also cleared the written and clean-window receipt
cycles, which a reused instance relies on to attest and to detect a stale
window across run() calls (tests/test_provider_capacity_reporting.py).
Keep both for the life of the instance, as on main.
Differential tests: a second run() on a reused instance must save what a
new instance saves, after run 1 stops in each of 14 LOCAL ways and 5 CI
ways, and a gate edited between the runs must be pinned afresh. Kills the
keep-reset mutants for the four round accumulators and the untracked-gate
pinned flag. No production change.
…correct reset docstring (fix12)

- J-N1: run()-level CI test that kills the _trusted_config_refusal reset.
- J-N6: double-fault run 1, then a same-instance run 2 stopped at
  _receipt_diff; kills the _host_attempt_round reset (red before fix10).
- J-N2: correct the fix11 CI differential docstring (run 2 FAILs on the
  untracked gate) and add a CI case with an outside trusted gate root
  whose runs reach the measurement (run 1 PASSes).
- J-N3 / N-1: _reset_run_state docstring limited to per-run, per-attempt,
  per-round and L2-phase state; names what is kept across runs.

No production logic change.
Align Check 8 with the existing closed-disposition allowlist. Preserve absent-basis compatibility while treating malformed or unknown authority as unresolved. Keep original receipt snapshots and evidence-only behavior unchanged.

Co-authored-by: Codex <codex@openai.com>
Project optional SARIF assertion metadata through the existing outcome validator and accepted report boundary for immediate and background results. Preserve findings, receipt audit, raw output and tuple compatibility.

Partially addresses #330 section 1; remaining reporting and outlet work stays open.
Local subprocess runs did not retain the optional assertion execution result in a dedicated artifact. Publish validated metadata beside state without changing the primary verdict or output contract. Invalidate stale metadata only after acquiring the lock and replace it atomically after advice, keeping interruptions and file errors separate from primary state.

Signed-off-by: Codex <codex@openai.com>
Normally returned assertion advice could lose execution metadata when its result marker was missing or inconsistent with the findings. Record a fixed reporting failure after preserving existing finding output, while leaving callback and fatal errors, primary verdicts, and receipts unchanged.

Signed-off-by: Codex <codex@openai.com>
* Reject quote repairs that invent a reply key (#264)

_loads_model_json repairs unescaped inner quotes. An excerpt that quotes a
dict literal such as cfg = {"a": "b", "c": "d"} repairs into a finding with
a new key "c" and an excerpt cut at "b. The parse succeeds and the evidence
is silently wrong.

Callers now pass the keys their reply may carry, per level (ReplyKeys).
When a parse needed repair and the result carries a key the caller did not
list, the repair is discarded and the original JSONDecodeError is raised.
An api backend then spends its existing JSON-correction retry; the cli
inner-result parse falls back to the raw string, which the caller's
validation rejects. A parse that needed no repair is never checked.

Wired: L1 review passes (factories._run_pass), subagent spawn and the
test-assertion review (cli), the runtime axis and the real falsifier.

Refs #264

* Format files touched by the #264 change with ruff 0.17.3

Format only; the AST of every file is unchanged. One message string in
cli.py is split into two literals to stay within 105 columns. Needed
because the lint gate runs ruff format --check on each touched file.

Refs #264

* Update call-shape tests for the reply_keys keyword (#264)

* Format two test files touched by the #264 change with ruff 0.17.3

* fix(llm_invoke): check reply keys on bare CLI stdout (#264)

When a CLI or proxy prints the model's JSON with no type:result
envelope, _invoke_cli kept the parse it made without reply_keys, so a
quote repair that invented a key went through even when the caller
passed keys. Parse that stdout again under the caller's keys and keep
the raw text on refusal, as the inner-result path already does.

Tests: bare stdout with and without keys; one test per site that
threads reply_keys (continuation partial and combined parse, wider
retry, _invoke_api into both, llm_invoke into excerpt repair, excerpt
repair into _invoke_api); review item lists reject an extra key; a
repaired falsify reply may carry receipt, a runtime reply surfaces,
a finding excerpt.

* fix(llm_invoke): close three reply-key gaps (#264)

- A refused repair now raises ReplyKeyRejected, a JSONDecodeError at the
  original error. The API parse, the continuation parses and the wider
  retry no longer fall back to _extract_json_from_text for it. That scan
  could return an object quoted inside an excerpt, such as an empty
  review, and drop the real findings with no retry.
- A CLI envelope (or the last stream event) whose result is an object
  is checked when the envelope parse itself needed quote repair. An
  unlisted key keeps the raw stdout, as a string result does.
- _unknown_reply_key checks each object in a top-level list, since
  runtime._parse_llm_response unwraps a one-object list. A bare CLI
  stdout list is parsed again under the caller's keys.

* fix(llm_invoke): stop scanning a failed quote repair for an object (#264)

A quote repair that fails, or that the #216 quoted-key guard refuses,
raised a plain JSONDecodeError, so _invoke_api scanned the text for any
embedded object. An excerpt quoting x = {"findings": [], ...} then came
back as a clean review and the real finding was lost after one call,
with or without reply keys.

Every failure after a repair was tried now raises RepairRefused, a
JSONDecodeError subclass that ReplyKeyRejected derives from. After it,
_fallback_reply tries only the reply's own leading object, so a cut
falsify reply is still salvaged; after a key refusal it tries nothing.
A failure with no repair tried keeps the full scan.

Adds the CLI tests the cross-check asked for: a repaired envelope with
only known keys keeps its result, and a raw newline is not a repair.

* refactor(llm_invoke): drop a leading_only check the scan guard already covers (#264)

* fix(llm_invoke): skip an object a later brace shows was enclosed (#264)

The embedded-object scan now skips an object followed by a '}' that
nothing after it opens. That covers a prose-led reply, an earlier syntax
error and a leading object closed early by a quoted '"}]'. The falsify
salvage refuses a repeated key and a verdict that is not the first key.

* test(llm_invoke): pin the brace count behind the enclosed-object skip (#264)

* test(reply_keys): check each caller's keys by calling it (#264)

The wiring tests searched each caller's source for "reply_keys=...".
A call that passed reply_keys=None with the old text left in a comment
still matched. Each test now patches llm_invoke, drives the caller, and
asserts every call received that caller's key table (by identity): the
L1 provider, the subagent spawn, the test-assertion review, the runtime
runner and the real falsifier.

* fix(llm_invoke): keep a stream result event out of the bare-stdout re-parse (#264)

When a CLI stream array's last result event has no "result" member, the
event falls through to the bare-stdout branch. With reply_keys set, that
branch parsed the whole stdout again and returned the stream array
instead of the event, which main returns. Re-parse only when the parsed
value is stdout itself, not an event taken from it. Test with and
without keys.

* fix(llm_invoke): end the scan at a '{' that fails inside a string (#264)

Replaces the unmatched-'}' count, which code opening a brace after a
quoted object could balance, and which refused a valid reply followed
by prose quoting a '}' (a regression against main).

- A '{' whose decode fails after reaching a '"' ends the scan, after the
  falsify salvage is tried; later '{' may sit inside its strings.
- A decoded object followed by '}' or '"' is skipped.
- An empty unknown key now counts as unknown (is not None).
- ReplyKeys.items is read-only and left out of the hash.

* fix(llm_invoke): refuse a reply inside enclosing JSON, duplicate keys and 3.12 trailing commas (#264)

- _json_continues_after: a key-matching object is not the reply when the
  text after it closes a string or object it sits in, including a stray
  quote later followed by '}' or ']' (open braces in the quoted code).
- _error_is_at_quote: ',}' and ',]' are structure on Python 3.12 too.
- _loads_model_json: with reply_keys, refuse duplicate keys; drop the
  unreachable final raise.

* style(tests): wrap a long test string (#264)

* test(llm_invoke): pin trailing commas, the quote-first tail and ',]' (#264)

* test(llm_invoke): pin the CLI empty-key check and ',]' after a string (#264)

* style(tests): ruff format test_llm_invoke.py (#264)

Format only. The next commit touches this file, and the lint gate runs
ruff format --check on whole touched files.

* refactor(daemon_state): build the static-rules list with extend (#264)

Clears ruff PERF401 on this file, which the next commit touches; the lint
gate runs ruff check on whole touched files. Same list, same order.

* fix(llm_invoke): refuse cut excerpts-first replies, key canary and daemon (#264)

- _text_after_refuses replaces _json_continues_after. After the object
  that opens a non-list reply, ',', ']', ')', ';', '=', '\', '//' and
  curly quotes count as continued code, not prose. A later whole object
  with the reply's keys also refuses the first: either could be the reply.
- A '{' that opens the reply and fails to decode ends the scan, so a
  Python dict or JS object reply does not yield the JSON review it quotes.
- _continue_truncated counts a partial as complete only when it parses
  whole. The fallback scan read a cut excerpts-first reply as an empty
  review and spent no continuation.
- The inline canary review passes REVIEW_REPLY_KEYS, and the daemon-state
  questions pass their own key tables.

A cut tail that starts with a word or a digit still reads as prose after
a stop; the docstring and tests pin that limit. Two contract tests change:
two matching objects now return None, and a reply that opens with '{a'
returns None (F2 keeps its intent behind a prose prefix).

* test(llm_invoke): each leading code mark and the daemon item keys (#264)

Adds the right curly quote, ')' and '=' tails to the cut excerpts-first
cases, and a repaired daemon conflict item with an invented key.

* test(daemon_state): Q1 refuses a repaired reply with an unknown key (#264)

* docs(llm_invoke): name the stop-cut tails rule 2 reads as prose (#264)

The limit is any first character outside the refused set, not only a
word or a digit. Pins '(', a list item and a single quote as well.

* fix(llm_invoke): continue a length-cut reply even when the partial parses (#264)

A partial cut by the output cap can parse whole when a quoted
`"}], "findings": []}` closed the reply early, with the real findings
after the cut. Always spend one continuation; an empty continuation
returns the partial.

Refs #264

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(llm_invoke): measured stop-cut rule; scan refuses duplicate keys and literals (#264)

- The text after a scanned reply object must start like prose or
  markdown (str.isspace whitespace), instead of refusing a fixed set of
  code marks. Chosen on a corpus of 57 cut tails and 32 valid trailing
  texts in three places: P1 lost 30/171 (was 156/171), valid replies
  refused 12/104 (was 14/104). The rule also applies after a list's
  object and a prose-led object, skips whole objects that follow, and
  refuses text opening more brackets than it closes or holding a string
  closing into JSON.
- The scan decoder refuses a duplicate key, as _loads_model_json does.
- After prose, a failed Python dict or JS object literal ends the scan
  at the first object with the reply's keys inside its braces.
- A reply holding a '{' gets the JSON-correction retry even after prose,
  so a refusal costs one call rather than a failed review.
- The canary review gets its own top-level keys; Q2Q3 checks the top
  level only, as on main, and the cost is documented.
- Tests pin each _fallback_reply site against a full scan.

Refs #264

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(llm_invoke): offset the prose-led punctuation check; tests from mutation (#264)

Mutation found that after a prose-led object the check of the text after
its punctuation started at an offset into the slice, not the text, so
`Here: {...}; = 1` was kept. Measure from the object's end. The corpus
now loses 29/171 cut tails (was 30) and still refuses 12/104 valid
replies.

The leading-quote check is dropped: no quote starts prose, so the other
rules already refuse it. New tests pin a duplicate key followed by a
reply, literals whose quoted review is followed by prose, a spaced
sentence mark, list punctuation, and the continuation site against a
full scan.

Refs #264

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* test(per_pass_usage): continue the length cut before checking its usage (#264)

A length-cut reply is now always continued, even when the partial
parses, so the usage test hands the continuation the rest of the reply
instead of a complete partial. The transport fixture takes the reply
text as an option.

Refs #264

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* style(canary_gen): mark the dispatch fallback's broad catch as intended (#264)

The file is now in this change, so lint_changed lints all of it. The
catch is pre-existing and deliberate: any dispatch failure falls back to
DELEGATED.

Refs #264

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* style(tests): ruff format three test files this change touches (#264)

Format only, no behaviour change. They were unformatted on main and
lint_changed checks whole changed files.

Refs #264

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(llm_invoke): refuse duplicate reply keys on every path; bound the scan (#264)

- _invoke_cli raises LLMInvokeError on a refused reply (duplicate key,
  invented key), in envelope, stream, object, string and bare forms, and
  parses stdout once.
- loads_reply_json refuses duplicate keys and deep nesting; the reviewer
  JSON validator, canary review, factories, receipt and the canary
  provider in cli.py re-parse reply text through it.
- A duplicate key gets its own correction message and spends the API
  correction retry; a repeat fails closed.
- With reply keys only a leading or list object is taken.
- Non-ASCII prose is a whitelist; a dict or object literal skips its
  strings when counting braces.
- 256 KB and repair-count caps; RecursionError fails closed.
- Echoed or restated continuations keep the partial; '$' no longer ends
  a sentence; '. Then' and '.keys()' are refused.
- An object that opens a list must be followed by ']' or another item.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* test: reply refusals on the CLI, API and re-parse paths, and a CLI e2e (#264)

test_reply_refusal_e2e.py reproduces the escaped-newline CLI attack that
returned PASS at 071a9ea; it now fails closed.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* test: commit the stop-cut reply tail corpus and its measurement script (#264)

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* test: kill the three surviving round 9 mutants (#264)

The canary provider's re-parse, the brace skip inside strings in a failed
literal, and a '.' ending a prose word only before whitespace or the end
each survived the mutation run. Each now has a test that fails under it.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix: drop the json imports that loads_reply_json left unused (#264)

canary_gen and reviewer_json now parse replies with loads_reply_json, so
their json imports were unused (ruff F401), which the lint gate flags.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* style: ruff format receipt.py, format only (#264)

receipt.py was not formatted on main. This branch now touches it, and the
lint gate runs ruff format --check on whole touched files.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* test: keep the embedded raw-retention case on a reply prose may follow (#264)

Since round 9 a caller with reply keys refuses prose before the reply and
sends it to the correction retry (F2). The embedded style here led with
prose and ran with one attempt, so all six cases failed. Prose now only
follows the reply, which still keeps raw text that is not the parsed JSON;
the prose-led refusal is covered in test_reply_refusals.py.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* style: ruff format test_writer_schema_raw_retention.py, format only (#264)

The file was not formatted on main. This branch now touches it, and the
lint gate runs ruff format --check on whole touched files.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Codex <codex@openai.com>

HouMinXi commented Nov 1, 2026

Copy link
Copy Markdown
Owner Author

Fixed in e4deb22 (ordinary integration of current main; no force push).

Mixed isolation-verification + probe-directory cleanup failures now retain the original cause and a separate typed, bounded cleanup error in capability reports, adapter results, and failure artifacts. Payload-group ownership remains distinct from probe ownership; exception notes are not parsed. Legacy artifact shape is unchanged when no typed probe failure exists.

Validation: three sequential five-lens local review cycles with no unresolved findings (manual requirements lens; no external-model qualification claim). 164 focused tests passed / 8 native-host skips; 826 adjacent passed / 3 skips / 11 process-cancellation failures reproduced on unchanged main. Six legacy envelope/artifact parity cases passed; omission control produced 13 expected failures. No test weakening or containment changes.

Exact-head hosted CI is running: Linux tests 41288675833, CodeQL 41288675837, lint 41288675825. Native/ownership qualification remains pending those results.

@HouMinXi
HouMinXi merged commit 2995c41 into main Nov 1, 2026
4 checks passed
@HouMinXi
HouMinXi deleted the fix/31-child-startup-diagnostic branch November 1, 2026 09:39
HouMinXi pushed a commit that referenced this pull request Nov 1, 2026
Brings in the fixpoint state-invariance tests (#337), the fixval JUnit
evidence rule (#331) and the isolate startup diagnostic (#329). Both
sides appended tests at the end of
tests/test_review_interruption_source_controls.py; the resolution keeps
both blocks unchanged. The touched test files and the neighbouring
machine, state and verify suites pass on the merged tree.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Claude-Session: https://claude.ai/code/session_01Rf2jBZrtS24e7qpWUBy3uo
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.

2 participants