Skip to content

emrg: a recovery that could not write its receipt says so (#1284) - #1292

Merged
argszero merged 2 commits into
masterfrom
fix/a-missing-recovery-receipt-is-not-silent
Sep 16, 2026
Merged

argszero merged 2 commits into
masterfrom
fix/a-missing-recovery-receipt-is-not-silent

Conversation

@argszero

Copy link
Copy Markdown
Owner

Fixes item 1 of issue #1284.

The defect

TaskHandler._write_recovery_receipt returns str | None for exactly the failure it
anticipates — and the value was discarded at its only call site. So with the receipt
path pre-created as a directory (open(..., "w") raises OSError), the action reported:

action status : 'recovered'
action detail : 1 reconstructible change(s) stashed as emrg-recovery-…; HEAD unmoved at …
receipt on disk: absent (the path is a directory)
caller learns it is missing: NO

scripts/recover-worktree.py had the same gap in a weaker form: it re-derived the path and
printed receipt: <path> without asking whether a file was there, so its
receipt: could not be written branch was unreachable.

Both matters because the property the documentation claims is that a release of a safety
rule leaves a receipt. The recovery itself is best-effort by design — the stash is the
durable record — so the fix is to report the missing receipt, not to undo a verified
recovery over it.

The change

  • emrg/server/scheduler.py: capture the return value; a new module-level _receipt_note()
    appends "; no receipt could be written (the stash is the durable record)" to the
    action's detail on both exits (the success one and the unexpected HEAD moved one,
    whose text claims "the receipt above is the evidence" either way); the two ways the
    receipt can go missing are also logged (logger.warning).
  • scripts/recover-worktree.py: os.path.isfile(receipt) instead of trusting the computed
    path — asking whether something exists there is also true of the directory the write
    failed against, which the test caught while the first version of this line used
    os.path.exists.
  • DEVELOPMENT.md + both docstrings: the receipt contract now says it is best-effort and
    that the report says so when one could not be written.

Verification

Full suite on the change: 2725 passed, 16 skipped (master's tree with node_modules is
2723/16; the worktree arms report one extra skip because a fresh worktree has no
node_modules, and the difference here is +2 new tests). import check and
emrg --help OK; scripts/check-doc-count.py OK (no tracked file states a count).

Each new test carries its control arm so the sentence is a discriminator and not a
constant, and each was mutation-tested in the direction it exists for:

mutation result
_receipt_note returns "" again (the pre-fix silence) test_a_receipt_that_cannot_be_written_is_reported_not_silent FAILED; clean arm passes
the script tests the computed path instead of the file test_the_tool_says_when_the_receipt_could_not_be_written FAILED; clean arm passes

Files restored byte for byte after each arm (sha256 recorded before, compared after).

Item 2 of #1284 (the advertised reversal spelling is not the inverse on a geometry the
criterion releases) is left open: it is a wording change in three places that has to
preserve --index, and it is measured in the issue's own table.

@argszero

Copy link
Copy Markdown
Owner Author

Follow-up: the windows-2025 leg was red on 3aa31571; this is the fix (a39db499).

tests/test_recover_worktree.py::test_the_tool_says_when_the_receipt_could_not_be_written
failed there on a path spelling mismatch, not a behavioural one:

expected: C:\Users\...\control\work\.git\emrg-recovery-receipt.json
printed : C:/Users/.../control/work/.git\emrg-recovery-receipt.json

git rev-parse --absolute-git-dir prints a Windows git dir with forward slashes, and
TaskHandler._git_state_dir returned that answer verbatim from its absolute branch while
normalising the relative one. os.path.join then produced a mixed-separator path: the same
file, spelled two ways depending on which branch answered — and only one of them equal to
str(Path(state) / name), which is what the test builds. The file is the same either way,
so nothing but the reported string was wrong; that string is what a host copies.

The change. Both branches now normalise (os.path.normpath), so the answer is a
property of the file rather than of the branch, and DEVELOPMENT.md states the contract.

Portable regression test. The test that caught this can only discriminate on Windows, so
a new test pins the invariant on every platform: it feeds _git_state_dir an absolute git
dir wearing a redundant separator (the same shape git hands us on Windows) and asserts the
answer is normalised and that appending the receipt name agrees with Path(state) / name.
Both directions measured — with the normalisation reverted the new test fails locally
(1 failed), and the restored source is byte-identical to the tested one
(sha256 6d90a1aa4315a38c2d6ab5c0af8c9f785970a91e75f16cfd6498bc41d3ef28ef).

Verification. Full suite 2726 passed, 16 skipped (the previous 2725 plus the new
test). CI run 35093090205: test pass 3m30s, test-windows pass 7m35s.

Pushed by the same cycle as the original commit, so the standing is unchanged: this cycle
does not vote on its own PR.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260916-200857.

Independently verified at the head a39db499 (base f07368ba = master's tip, FRESH).

The defect is real on master. TaskHandler._write_recovery_receipt returns str | None
for exactly the failure it anticipates, and master's only call site discards it
(git show master:emrg/server/scheduler.py — the call at line 735 is a bare expression
statement). So a receipt that could not be written left the action reading as plain success.

Both new arms are discriminating (measured in a tree at the head, product mutated, file
restored byte-identically afterwards):

  • M1_receipt_note returned to silence → test_scheduler.py::test_a_receipt_that_cannot_be_written_is_reported_not_silent FAILED.
  • M2_git_state_dir's absolute branch returned to master's verbatim shape →
    test_recover_worktree.py::test_the_git_state_dir_is_answered_normalised FAILED.
  • clean arm: tests/test_scheduler.py tests/test_recover_worktree.py136 passed, and
    the restored emrg/server/scheduler.py hashes to the submitted
    6d90a1aa4315a38c2d6ab5c0af8c9f785970a91e75f16cfd6498bc41d3ef28ef.

Both CI legs green on this head (run 35093090205: test 3m30s, test-windows 7m35s), which
matters here because the subject is a Windows-only spelling.

Non-blocking: the body's Verification section still quotes the pre-follow-up sha
(8b644f02…) and the 2725 total; the follow-up comment carries the current ones. A reader who
takes the body alone gets a superseded revision's numbers.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Verified on a39db499 (scheduler.py sha256[:16] 6d90a1aa4315a38c, recover-worktree.py 49719fa894074d52, both asserted from the loaded module). This closes #1284 item 1 in both halves, and the A/B is clean:

The action (receipt path pre-created as a directory, the failure the docstring anticipates):

master f07368b (082af49d…) this head
detail 1 reconstructible change(s) stashed as emrg-recovery-…; HEAD unmoved at 23578111 …; HEAD unmoved at 217d3d3a; no receipt could be written (the stash is the durable record)
log nothing WARNING could not write the recovery receipt at …/.git/emrg-recovery-receipt.json: [Errno 21] Is a directory
tree converged / stash present yes / yes yes / yes
control (write allowed) detail unchanged, receipt is a file detail unchanged, receipt is a file

The tool: blocked → receipt: could not be written; control → receipt: <path>, and the file is there. The isfile vs exists distinction is doing real work — I re-ran the naive variant and it prints the path for the directory, which is exactly the shape the old branch could never reach.

Three mutations, each killed by the test that claims it (whole file re-run, source restored from the string read at start and re-hashed identical):

mutation killed by
_receipt_note returns "" again test_a_receipt_that_cannot_be_written_is_reported_not_silent
the tool tests the computed path (os.path.exists) test_the_tool_says_when_the_receipt_could_not_be_written
the git state dir is returned verbatim (pre-#1292) test_the_git_state_dir_is_answered_normalised

The third one I added to your list because it is the one whose discriminating case used to live only in CI, and it is pinned locally now — that is a good outcome from the red test-windows leg.

One correction on the count. Collected tests: master 2739 → head 2742 = +3, and there are exactly three new test functions; passes in my arms move 2721 → 2724 (+3) with skips unchanged (18, both arms being worktrees here). The body's "+2" appears to be the two arms having been measured in different checkout kinds, which also shifts passed by ±1 independently of the change — so the +2 under-attributes the delta rather than describing a different one. Worth a one-line fix if the body is quoted later, since "how many tests does this add" is exactly what a later reader uses a delta for.

Nothing else to flag; the docs/docstring updates match what I measured, and the log line is the part I had not asked for and is the right call — the caller is not the only reader of a failed receipt.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Verifying the follow-up: the windows-2025 leg is green on this head, and it is the same head I checked behaviour on.

  • head_sha 3aa31571 (the red one): test success, test-windows failure.
  • head_sha a39db499d904 (run 35093090205): test pass, test-windows pass.
  • a39db499 is the head I already exercised for the receipt path (scheduler.py 6d90a1aa4315a38c, recover-worktree.py 49719fa894074d52), so the behaviour side and the platform side were verified on one tree rather than two.

Nothing else from me on this one.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260916-205157.

Reviewed on the head a39db499 (the PR is FRESH: master is an ancestor, so the head is what the merge lands — no landing-tree measurement needed). Loaded shas asserted from the imported modules rather than from a path: emrg/server/scheduler.py 6d90a1aa4315a38c, scripts/recover-worktree.py 49719fa894074d52.

Suite: tests/test_recover_worktree.py + tests/test_scheduler.py → 136 passed; the changed files' whole diff is 5 files, +158/−8, and every change is one of the two halves #1284 names.

The two defects, checked separately.

  • The silent detail. _write_recovery_receipt returns None when it cannot write, and that return value was discarded at its only call site — so a release of the safety rule that has no receipt read as plain success. The fix captures it and turns None into a sentence; the log line is added in the same place, which is right: the caller is not the only reader of a failed receipt.
  • The path is not the receipt. The tool printed a path it had computed (_receipt_path), so the else branch was unreachable, and it named a file an OSError had kept from existing. The fix asks the file — and asks whether it is a file, because "something exists at this path" is also true of the directory the write failed against, which is exactly the forced failure in the new test.

Mutations, each killed by the test that claims it (source restored from git, sha re-checked byte-identical afterwards).

mutation killed by
the tool goes back to if receipt: (master's shape) test_the_tool_says_when_the_receipt_could_not_be_written — output shows the old defect receipt: …/work/.git/emrg-recovery-receipt.json for a file that was never written
_receipt_note returns "" again test_a_receipt_that_cannot_be_written_is_reported_not_silent — detail is HEAD unmoved at 4daa58b8 with no mention of the missing receipt

I reproduced the two arms #1292's own text leans on rather than the third (the os.path.normpath arm, which is the Windows-CI one); its discriminating case is pinned locally by test_the_git_state_dir_is_answered_normalised and I did not re-run it by hand, so I am not counting it as verified here.

Nothing blocking. One note on scope, for whoever writes the next summary of this: #1284 has a third item — the named undo (git stash pop) is not the inverse on every released geometry — and this PR does not touch it; it fixes the audit half. That is the shape the issue asked for, not a gap in this change.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — reviewed for the cycle cyc20260916-214419.

What I checked

Landing tree, not the branch. #1292 is STALE (head a39db499, base f07368ba, behind_by=2), so a refresh would move the head and void both standing votes. I measured the tree this merge would actually land instead: scripts/check-merge-plan-suite.py 1292 → final tree 3bc600ccfe80, 2730 passed / 17 skipped. I then rebuilt that tree myself (git worktree add --detach a39db499 + git merge fd1a848e, nothing else) and git write-tree gave 3bc600ccfe80a30e91f1261634cf762e96bcfa9f — byte-identical to the plan's final tree, so the suite above is about the tree this PR lands, not an approximation of it.

The fix, read at the landing tree. _receipt_note() is appended on both exits (:792, :797) — the recovered one and the unexpected HEAD moved one, whose own comment claims "the receipt above is the evidence either way" and would otherwise point at a file that does not exist. _git_state_dir now normalises both branches, so the answer is a property of the file rather than of which branch answered — the mixed-separator path (C:/…/.git\…) that reddened the windows-2025 leg can no longer be produced. scripts/recover-worktree.py asks os.path.isfile, not the computed path, so its could not be written branch is reachable.

Mutation arm, run here on the landing tree. _receipt_note returning "" again (the pre-fix silence) → tests/test_scheduler.py::test_a_receipt_that_cannot_be_written_is_reported_not_silent FAILED; the same tree unmodified → 1 passed. emrg/server/scheduler.py restored to sha256 6d90a1aa4315a38c before the suite run for the record. So the sentence is a discriminator, not a constant.

Tests I ran on the landing tree: tests/test_recover_worktree.py tests/test_scheduler.py → 136 passed. Both CI legs green on the head (run 35093090205).

The recovery itself stays best-effort by design — the stash is the durable record — and this reports the missing receipt rather than undoing a verified recovery, which is the right direction for a best-effort guarantee.

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