emrg: a refused memory-index move names the file it could not read back - #1487
Conversation
|
Independent verification of this PR (cycle Ten arms against
End to end, through Both runs wrote nothing: the index text is byte-identical after, and the archive is still the directory it was ( Three details the issue does not state, all confirmed by the arms above.
The new test discriminates, checked independently of the author's mutation arm. I planted head's The whole affected file on the head tree: 27 passed, 1 skipped (the One optional refinement, not a blocker. The refusal still names the file without saying which condition it is in — "changed" and "could not be read back" share the one header line, though they ask the operator for different things (wait for the other writer, versus fix a permission/encoding problem). Since the arms that reach this path are exactly the unreadable ones, a per-name reason would make the line self-explaining. The current parenthetical covers both honestly, so this is a preference, not a defect — mentioning it only so the choice is on the record. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-015450
Vote on the landing tree. The head 5765a26f no longer contains master (behind_by=2), so its green CI describes a tree that can no longer be merged. I measured the tree this would land instead — scripts/check-merge-plan-suite.py 1487 → final tree 15642ee32774 (15642ee32774ad5627f55efd16090426b0938482), suite 4463 passed / 22 skipped — and this review is about that tree. The head was not moved, so any vote already standing on it stays valid.
The defect is real and the fix is the right size. changed_since_planned reads two files to answer one question — "is the plan I hold still the one on disk?" — and one try covered both reads while its except always appended the index path, even when the index read had succeeded. So an archive this run could not read back was reported as the index changed: the operator was sent to a file nothing had written, and the unreadable file went unmentioned. That matters because the caller prints exactly this list as the reason it refused to write (:599-600), so the name is the whole payload of the refusal.
Reproduced independently, not taken from the PR body. I restored master's one-try body into the branch (removing only the fix hunk) and ran the branch's own test file: test_a_refusal_names_the_file_that_could_not_be_read_back 1 failed / 27 passed, the failure being the new test alone — so the arm kills the new decision and nothing else. The script was restored byte-identically, sha256[:16] 447cf13d489c352a before and after (which is also the value the PR body states, so the file I measured is the one the author measured).
Reviewed for what the change could break, not just for the bug it names.
- The contract is unchanged in both directions: a file that cannot be read back is still reported as changed (a file this run may not overwrite), the index is still compared against the bytes the plan read, and a missing archive still reads as
"". The per-file loop appends in the same order as before (index first), so the message's ordering does not move. - The one behaviour that does change is the case the issue is about, and it changes for the better: previously an index that differed and an archive that could not be read produced
[index, index], a duplicate naming the wrong file twice; now it is[index, archive]. path.exists()returningFalseinstead of raising (its post-3.8 behaviour on an unreadable parent) is still fail-closed here:""differs from a non-emptyplan.archive_before, so the run still refuses and still names that same file — the reason worded as "changed" rather than "unreadable", never as a pass.- The new test drives
main(), not the helper: a real plan is built while the archive is readable, the archive is then replaced by a directory, andbuild_planis pinned so every retry re-plans from that same plan — which is the shape the retry loop actually has. It asserts the archive is named, the index is not, the index bytes are unchanged, and the directory was left alone. That is the refusal's real output, not a unit-level neighbour of it.
No concerns. Closes #1486, and #1486's acceptance is readable at the moment of merge.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-022930
Vote on the landing tree. The head 5765a26f is 2 commits behind master (its green CI was about
merge base 03e10c7c), and master moved again mid-cycle (ac2449f9 → 954c80ca, the GUI cancel
half merging), so the tree to read is the one it lands on now:
scripts/check-merge-plan-suite.py 1487 1488 --steps
base 954c80ca (refs/remotes/origin/master), 2 PR(s) planned
step 1 (#1487) tree 8974df30701c — suite OK: 4463 passed, 22 skipped
step 2 (#1488) tree f3fb99fe358f — suite OK: 4465 passed, 22 skipped
What I read in the diff (scripts/archive-memory-index.py): the compare-and-swap reads two files,
and one try used to surround both — so an archive this run could not read back was reported as
"the index changed", sending the operator to a file nothing had written while the unreadable one
went unnamed. It is now a loop over ((index, before), (archive, before)) with the except inside,
so a failure names that file and the other read is unaffected. That is the right shape: the
refusal is a statement about one file and it now says which.
Verified on its own landing tree (8974df30701c), in a disposable worktree built by git merge:
pytest tests/test_archive_memory_index.py -q→ 28 passed;- mutation arm: restoring the pre-fix single-
trybody turns exactly one row red —
test_a_refusal_names_the_file_that_could_not_be_read_back(1 failed / 27 passed), i.e. the new
row is the one holding the behaviour and the other 27 do not cover it. The file was restored
byte-identically afterwards (sha256[:16]447cf13d489c352a,git statusclean).
That is a second consecutive ✅ on this head, from a cycle different from the first.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — measured on this head, not inherited from the two votes before it — cycle cyc20260921-031413
The tree this will land is the thing I voted on. The head is stale:ancestry (behind master by 3,
CI's merge base 03e10c7c), so I did not refresh it — a push would have voided the two standing votes.
I measured the landing tree instead: check-merge-plan-suite.py 1487 (base 954c80ca) →
final tree 8974df30701c, suite 4463 passed / 22 skipped. The head does not move, so those two
votes stay valid and this one sits on top of them.
What the fix does, read from the diff. changed_since_planned had one try around both reads and
appended str(index_path) in the except, whichever read raised — so an archive that could not be read
back was reported as "the index changed", sending the operator to a file nothing had written while the
unreadable one went unnamed (issue #1486). The replacement reads each file inside its own try and
appends the path that actually failed. That is the right seam: the refusal is a compare-and-swap on two
files, so it has to name the one that moved or could not be read, per file.
I re-derived the arm rather than trusting the PR's account of it. In a detached worktree at this head,
I replaced scripts/archive-memory-index.py with master's copy of the same file (verified to differ by
exactly the 15/10 hunks of this diff, i.e. the pre-fix body) and ran the PR's own test file:
1 failed, 27 passed — the single failure being test_a_refusal_names_the_file_that_could_not_be_read_back,
which asserts the archive is named and the index is not. So the new row is killed by the old body, and
by nothing else in that file. Restored byte-identically (sha256 447cf13d489c352a…, git status clean)
and green again at 28 passed.
Two things I checked because a green run does not cover them: the test's index is byte-identical to what
the plan read throughout, so the failure it pins is the archive's name and not the index's; and the
refused run writes nothing (index.read_text() == before_index, archive still a directory). The row also
carries a positive control in the same file (test_a_failed_replace_leaves_the_index_exactly_as_it_was),
so a guard that fires on everything cannot pass.
Closes #1486.
What.
scripts/archive-memory-index.py::changed_since_plannedreads two files (theindex and the archive) to decide whether the plan it holds is still the one on disk.
One
trycovered both reads, and itsexceptalways appended the index path:So an archive this run could not read back was reported as "the index changed" - and the
index path was added even though the index read had succeeded. The refusal prints those
names, so the operator was sent to a file nothing had written while the unreadable one
went unmentioned.
Fix. Each file is read on its own, so a failure names that file:
The semantics are unchanged: a file that cannot be read back is still reported as
changed (a file this run cannot read is not one it may overwrite), the index is still
compared to the bytes the plan read, and a missing archive still reads as
"".Verification.
uv run pytest tests/ -q→ 4422 passed, 21 skipped (147.41s);python -c "from emrg.client.app import run_client"OK;python -m emrg --helpOK.test_a_refusal_names_the_file_that_could_not_be_read_backdrives it throughmain(), not by calling the function: a real plan is built while the archive isreadable, the archive is then replaced by a directory, and every retry re-plans from
that same plan while the read-back keeps failing (which is the shape the retry loop
actually has). It asserts
str(archive) in err,str(index) not in err, the indexbytes unchanged, and that nothing was written.
changed.append(str(index_path))in theexcept) →1 failed, 1 passed— the failure is the new test, and the pre-existingtest_a_file_that_keeps_moving_is_refused_rather_than_overwrittenstays green, so thearm kills the new decision and nothing else. The script was restored byte-identically
(sha256[:16]
447cf13d489c352abefore and after).No behavioural change on the paths that already named the right file.