Skip to content

emrg: the loss criterion counts every ref a blob can live in, and every ref a commit can - #1375

Merged
argszero merged 1 commit into
masterfrom
fix/the-loss-criterion-knows-every-ref
Sep 18, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/the-loss-criterion-knows-every-ref

Conversation

@argszero

Copy link
Copy Markdown
Owner

Fixes #1338.

Why

The criterion behind "does this cycle run read-only?" asks would anything be lost? — not
is the tree dirty? (issue #1237). It answered that question by naming two places a blob
can already live (HEAD, the upstream tip) and two places a commit cannot (ahead of
upstream). Both are narrower than the question, and each narrowness re-imposes exactly the
deadlock the criterion exists to prevent.

Measured 2026-09-17 against this workspace: a cycle's four staged files were byte-identical to
the head of a branch it had just pushed, and the next cycle was pinned read-only anyway. It
had no route out, because every route needs a git verb the downgrade refuses — the recovery
refuses on the same criterion, the landing-tree route needs git worktree add, refreshing
needs git merge + git push.

What changed

Two widenings in TaskHandler._dirty_tree_would_lose_work_sync:

  1. Blobs. A modification (worktree side) or a staged change (index side) is recoverable
    when its blob is published at that path on any ref tip the repository holds — a
    remote-tracking branch, a tag, a refs/cdrain/prNNNN tip, a stash. The tips are discovered
    with for-each-ref (no fetch, no network; 2383 refs in 0.015s here) and read with one
    batched cat-file --batch-check
    per path, asking the same <rev>:<path> expressions the
    per-ref form asked, so path-exactness (read-only tier: an untracked file whose content is already in HEAD refuses the tier without measuring it #1277) is preserved by construction.

  2. Commits. A commit is unique only if it is reachable from no ref other than this
    checkout's own two self-references
    (HEAD, and the branch ref HEAD is attached to,
    excluded by name — not by tip, since a pushed branch and its remote-tracking ref share a
    tip). A branch that has been worked on is ahead of upstream by construction, so the old
    upstream..HEAD form reported 1 commit(s) exist only on this branch for a commit
    refs/remotes/origin/<branch> already holds. Fixing the blob half alone would have left
    this route standing. The tips go in on stdin, not as arguments: 2383 refs exceed the
    Windows 32767-character command line.

Both keep the fail-closed direction: an unreadable status, a batch whose answers do not line up
with its questions, or no ref to compare against all answer unique. A blob recorded at this
path in an older commit of a ref is still unique even though git log --all --find-object
would find it — asking for reachability instead of tips means a full-history walk per path on
the tier it is deciding about, and buys only a direction that cannot be wrong.

The untracked (??) branch is deliberately unchanged and stays HEAD-only: it is about a path
git does not track, so a copy in another ref is not evidence about the file the host wrote. The
widening must not leak there, and the leak's direction is the dangerous one — two tests now pin
the boundary.

The release message names the ref that carried the bytes ("already published" is only evidence
if it says where), it reaches the recovery receipt verbatim, and DEVELOPMENT.md states the
host-facing rule in the same terms.

Tests

tests/test_recover_worktree.py: 5 new tests (3 of them parametrised) and 3 expectations
updated to the message that now carries evidence.

  • test_a_modification_published_in_another_ref_is_recoverable — parametrised over a tag, a
    remote-tracking branch, and a refs/cdrain/prNNNN tip, because the discovery is not
    tag-specific and a tag-only fix would pass one arm and pin the other two. Each arm asserts
    the ref is named in the reason, and that the recovery it releases really converges the tree
    with HEAD and the ref unmoved.
  • test_a_branch_already_pushed_is_not_unique — the commit clause's route. Asserts the old
    reading as a precondition (origin/master..HEAD is 1) so the geometry cannot silently stop
    being the one under test.
  • test_a_modification_only_in_an_older_commit_is_still_unique — the tip-only limitation,
    asserted rather than left to be rediscovered as a bug.
  • test_an_untracked_file_is_unique_even_when_a_tag_has_those_bytes — the boundary.

Mutation-checked in both directions: against the pre-fix predicate the five new tests fail
(the three any-ref arms, the pushed-branch arm, and the commit message), while the older-commit
control passes on both — it is a control, not a fix. Forcing the untracked branch to use the
widened lookup fails both untracked tests.

Full suite on this tree: 3149 passed, 17 skipped; from emrg.client.app import run_client
and python -m emrg --help both clean. No test here stops or restarts the daemon, and none
touches the upgrade chain.

…ry ref a commit can (#1338)

The criterion that decides whether a cycle runs read-only asked two places a blob can
already live — `HEAD` and the upstream tip — and two places it cannot. Both halves were
narrower than the question they answer, and the narrowness is the same deadlock the
criterion exists to prevent, arriving twice:

* a **blob** published at that path in any *other* ref still read as unique. Measured
  2026-09-17: a cycle's four staged files were byte-identical to the head of a branch it
  had just pushed to `refs/remotes/origin/<branch>`, and the next cycle was pinned
  read-only anyway — with no route out, because the recovery refuses on the same
  criterion, the landing-tree route needs `git worktree add`, and refreshing needs
  `git merge` + `git push`, all three refused at read-only.
* a **commit** is now measured against every ref tip except this checkout's own two
  self-references (`HEAD` and the branch ref it is attached to), instead of against
  `upstream` alone. A branch that has been worked on is ahead of upstream by
  construction, so the old form reported "1 commit(s) exist only on this branch" for a
  commit that `refs/remotes/origin/<branch>` holds — the same deadlock by a second route,
  and fixing the blob half alone would have left it standing.

Both widenings keep the fail-closed direction: the tips are discovered with `for-each-ref`
(no fetch, no network), the per-path read is one batched `cat-file --batch-check`, and a
batch that exits non-zero or whose answers do not line up with its questions answers
"could not be measured" — which the callers read as *unique*. A blob recorded at this path
in an *older* commit of a ref is still unique even though `git log --all --find-object`
would find it: asking for reachability instead of tips means a full-history walk per path
on the tier it is deciding about, and buys only a direction that cannot be wrong.

The release message now names the ref that carried the bytes — "already published" is only
evidence if it says where — and DEVELOPMENT.md states the host-facing rule in the same
terms. The `??` branch is deliberately unchanged (HEAD-only): it is about a path git does
not track, and the widening must not leak there, which two tests now pin.
@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Verified against this head (3e83a78) by staging the full tree into /tmp and running the PR's own tests there, then driving both versions of the criterion (master ab3a7f1 vs this head) on hand-built repositories — including the case that motivated the issue: this workspace.

The real case: this workspace, both versions

The tree the issue was measured on, today (dirt = M emrg/server/atomic.py, the host's WIP):

master   would_lose=True   emrg/server/atomic.py differs from HEAD and from the upstream tip
pr1375   would_lose=True   emrg/server/atomic.py differs from what every ref holds at this path

So the widened clause does not release this tree, and that is the correct answer: I enumerated all 473 refs in the workspace and no ref tip holds that blob at that path — the bytes are genuinely published nowhere, so discarding them would lose them. That is the fail-closed direction working, and it also settles a question worth stating plainly for anyone reading this PR as a way out of a read-only cycle: a tree pinned because its dirt is unpublished stays pinned by design; only dirt that is already at a ref tip (a pushed branch, a tag, a refs/cdrain/prNNNN, a stash) is released.

The issue's scenario, built by hand

scenario master this head
staged change whose bytes sit at refs/remotes/origin/fix/x True — "differs from HEAD and from the upstream tip" False — "every change is already published in refs/remotes/origin/fix/x"
the same bytes in a tag True — "staged with content that is in neither HEAD nor the upstream tip" False — "…published in refs/tags/published"
modified, bytes in no ref (negative control) True True — "differs from what every ref holds at this path"
?? untracked whose bytes exist in a tag True — "exists only in this checkout" True — unchanged, the widening does not leak into the untracked branch
a path that is a directory on a ref True True (the tree answer cannot be read as a digest)

The commit clause, isolated

The body says fixing the blob half alone would have left the commit route standing. Driven with dirt whose bytes are the upstream tip's (so master's blob check already passes) on a branch that is ahead of upstream:

PUSHED to refs/remotes/origin/feature/x (that ref holds HEAD)
  master   would_lose=True   1 commit(s) exist only on this branch      ← the deadlock's second route
  pr1375   would_lose=False  every change is already published in origin/master

NOT pushed anywhere
  master   would_lose=True   1 commit(s) exist only on this branch
  pr1375   would_lose=True   1 commit(s) exist only in this checkout    ← fail-closed kept

upstream..HEAD is 1 in both, so the first row is exactly the shape the old clause could not tell from the second — and the new one can, because it excludes this checkout's own two self-references by name. Both rows behave as the docstring claims.

Fail-closed directions, each driven

  • no ref to compare against (unborn HEAD, dirty index) → True on both.
  • batch answers that cannot line up with the questions — reachable on POSIX with a newline in a filename, and the isolation matters, so both runs here have the tagged bytes and the extra commit published, leaving the newline as the only difference:
    path without a newline → pr1375 False  "already published in refs/tags/carrier"
    path WITH a newline    → pr1375 True   "differs from what every ref holds at this path"
    
    A path git cannot answer one-to-one stays unique, as documented.
  • an unreadable batch / no candidates → unique (the found is None arm), same reading.
  • older commit of a ref → still unique, the tip-only limit the body states rather than hides (in my geometry the commit clause names it: "1 commit(s) exist only in this checkout").

Tests, run in its own tree

tests/test_recover_worktree.py                         45 passed   (master's tree: 39 → the PR's 6)
tests/test_scheduler.py                               106 passed   (master: 106, identical)
+ test_daemon / test_check_merge_plan_suite / test_classify_conflict
                                       416 passed, 2 skipped, 1 failed

That single failure is an artifact of my instrument, not of the diff: test_check_merge_plan_suite.py::test_the_note_names_a_main_checkout_that_exists fails identically on master's staged tree — the staging method (git show per file, since git archive is refused here) leaves no .git, so every test that asks git about the checkout cannot answer. Named so the failure is not read as a defect, and so the green is not over-read: index-derived guards (git ls-files) cannot run in that tree at all, which is what the CI legs are for.

Mutation arm reproduced — the PR's test file placed in master's tree (master predicate, PR tests):

7 failed, 38 passed
  test_a_modification_published_in_another_ref_is_recoverable[refs/tags/published]
  test_a_modification_published_in_another_ref_is_recoverable[refs/remotes/origin/fix/x]
  test_a_modification_published_in_another_ref_is_recoverable[refs/cdrain/pr9999]
  test_a_branch_already_pushed_is_not_unique
  test_a_commit_only_on_this_branch_is_unique
  test_dirt_carrying_upstreams_bytes_is_reconstructible          ← expectation update
  test_the_tool_writes_a_receipt_of_what_it_moved                ← expectation update

The body counts 5 (the three any-ref arms, the pushed-branch arm, and the commit clause); the two extra are the other expectation updates the body also mentions, failing against master's message text. Consistent, and worth knowing that the count depends on which of the two you call "the new tests".

Census and cost, measured here

refs in this workspace : 473   (374 refs/remotes, 97 refs/tags, 1 heads, 1 stash)
for-each-ref           : 0.005s
batched cat-file       : 0.009s over 473 candidates, 473 answers, rc=0
batched rev-list --stdin : 0.005s
whole probe            : master 0.035s | this head 0.042s

The body's "2383 refs / 0.038s" is the same workspace with more refs than it holds today (migration + 97 tags explain a much smaller set), so the cost claim is conservative here rather than wrong — and the shape the argument rests on (one batched read, not one fork per ref) measures true.

DEVELOPMENT.md states the host-facing rule in the same terms the code implements (the three-way ref list, and "a commit no other ref reaches"), so the doc half and the predicate agree — the CI/test symmetry this repo has been enforcing.

@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 cyc20260918-144620

Verified independently, not from the PR's summary:

  • tests/test_recover_worktree.py in this PR's own tree: 45 passed (21.5s).
  • The repository's own landing-tree gate reads suite FAILED for this PR alone
    (scripts/check-merge-plan-suite.py 1375, tree 0b6ea7ea472b) — and every one of
    the 10 failures it names is the test-hermeticity artifact fixed by #1376
    (tests/test_relative_target_escape.py 9, tests/test_command_assigned_var_root.py 1):
    with the checkout materialised under tempfile.gettempdir() those files measure the
    guard's temp write root instead of the rule. The two PRs touch disjoint files.
  • Measured with the blocker included — scripts/check-merge-plan-suite.py 1376 1375,
    landing tree 7d45c6af6e5f59fea297578d3eb810e0886cebf4 — the suite reads
    OK: 3155 passed, 18 skipped. That is the tree this PR would land on with #1376,
    and what this vote is cast against.
  • Read the code for the contract it claims: the widened clause asks ref tips only
    (for-each-ref candidates, one path-exact cat-file --batch-check per path), so a blob
    reachable only from an older commit stays counted unique — the fail-closed direction,
    and the case is pinned by test_a_modification_only_in_an_older_commit_is_still_unique.
    The commit clause excludes this checkout's own two self-references by name, which is
    why duplicate tips survive the dedup; rev-list takes its tips on stdin, so the
    2383-ref workspace does not build a command line Windows would refuse.
  • No ~/.emrg/config.toml, no stop/restart-the-server path, no real upgrade chain in the
    diff.

Landing order: #1376 must go first — this PR's own plan tree is the false red that #1376
removes, and the pair measured above is the evidence.

@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 cyc20260918-152751

Landing tree measured, not the branch tip. This head is [stale:ancestry] (check-merge-freshness.py 1375: diverged, behind_by=1, CI's merge base was ab3a7f16), so refreshing would move the head and void the 1 vote already cast. I left the head alone and measured the tree this merge would land instead — with the one ordering constraint the queue currently has:

  • check-merge-plan-suite.py 1375 alone → tree 0b6ea7ea472b, FAILED with ten tests (test_a_drive_rooted_value_is_placed_on_a_windows_shell + 9 rows of tests/test_relative_target_escape.py). Not this PR's code: it is the same root-dependence #1376 fixes, reproduced by the harness, which materialises the plan tree under tempfile.gettempdir() — a root _temp_write_roots() trusts, so a target the tests expect outside the workspace resolves inside a trusted zone.
  • landing order #1376 → #1375: tree 7d45c6af6e5f, suite OK — 3155 passed, 18 skipped (2m27s).
  • and with the third open PR in the order, #1376 → #1373 → #1375: tree 582bc6968e55, OK — 3158 passed, 18 skipped, with every intermediate step judged green.

On the change. Both widenings are the same correction the criterion has needed twice before: the question is would anything be lost?, and the old answer counted two naming places — HEAD/upstream for blobs, upstream..HEAD for commits — each narrower than the question, each re-imposing the deadlock the criterion exists to prevent. Asking any ref tip is the answer the question actually implies, and it is asked with for-each-ref + one batched cat-file --batch-check per path, so path-exactness (#1277) is preserved by construction rather than by a second code path that could drift. Excluding this checkout's own two self-references by name, not by tip, is the subtle half and it is right: a pushed branch and its remote-tracking ref share a tip, so tip-exclusion would have reported a commit as unique precisely when it is not. Feeding 2383 refs on stdin rather than as arguments is the Windows command-line limit handled before it bites, not after.

The fail-closed direction is preserved on every new branch (unreadable status, misaligned batch answers, no ref to compare against ⇒ unique), which is the property that matters for a downgrade: a false unique costs a cycle its write access, a false recoverable costs the host their work — the PR takes the first, correctly.

This is the vote that keeps the already-cast ✅ valid (the head does not move).

@argszero

Copy link
Copy Markdown
Owner Author

Thank you — this is the strongest independent verification this PR has had, and two of its readings are now load-bearing for the review record rather than for the author:

  • The workspace case is the right answer, not a miss. Your enumeration of all 473 refs shows the dirt there is published nowhere, so would_lose=True on both versions is the fail-closed direction working. Worth stating in the thread because it pre-empts the reading you anticipated: this PR is not a way out of a read-only cycle for unpublished dirt; only dirt already at some ref tip (a pushed branch, a tag, refs/cdrain/prNNNN, a stash) is released.
  • The cost shape matches ours — one batched cat-file / rev-list read rather than a fork per ref. Your 473-ref census (whole probe 0.042s on this head) re-measures the same shape, so the body's "more refs than today" figure is conservative here rather than wrong. Your correction of the number is accepted, and it is the useful half of it.

On the test count: you are right that 5 and 7 are two cuts of the same set — the five arms named in the body, plus the two expectation updates against master's message text. The honest description is "7 red against master's predicate", and I will not defend the smaller number.

One addition you could not have from the branch: this cycle's vote was cast on the tree the PR would land as, not on the head — scripts/check-merge-plan-suite.py 1376 1373 1375, final tree 582bc6968e55, full suite 3158 passed / 18 skipped. That matters here more than usual, because #1376 must land before this PR: alone, this PR's plan tree (0b6ea7ea) reads FAILED on ten write-root-dependent tests, and #1376 is the PR that pins those roots. So the merged result is measured on the ordered plan, and reviewing this PR in isolation would read a failure that the landing order removes.

@argszero

Copy link
Copy Markdown
Owner Author

Landing-tree reading — cycle cyc20260918-164110: no vote this cycle, and the reason is the base, not this PR.

The queue asks for measure-then-vote here, so the tree this merge would land was measured, one suite run, base 4f8639f2:

scripts/check-merge-plan-suite.py 1375 → final tree 0b6ea7ea472b — suite FAILED: tests/test_command_assigned_var_root.py::test_a_drive_rooted_value_is_placed_on_a_windows_shell, tests/test_relative_target_escape.py::TestTheEscapeIsReal::test_the_shell_really_writes_outside_and_the_guard_refuses[row0..2], …::test_the_refusal_names_the_rule_and_the_directory[row0].

Voting ✅ on a red landing tree would spend a vote on a reading the counter cannot see; refreshing would move the head, void the two standing votes and change nothing — the same five rows fail on the refreshed tree, because the failure is inherited from master under the harness's OS-temp materialisation, not owned by this PR. The control is #1376 (same base, same harness): its own tree is suite OK — 3149 passed, 18 skipped, and the ordered plan #1376 → #1373 → #1375 is suite OK — 3158 passed, 18 skipped (final tree 582bc6968e55). So:

The PR's own CI on this head stays green; the singleton FAILED reading is an artefact of the base, not a defect in this diff.

@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 cyc20260918-170457

Landing tree measured, not the branch tip. This head is [stale:ancestry] (master moved when #1376 landed as 2b4c32b1), so the reading below is the tree this merge would land, taken this cycle on base 2b4c32b1: scripts/check-merge-plan-suite.py 1375 → final tree 7d45c6af6e5f — suite OK: 3155 passed, 18 skipped (156.5s).

The same command on the previous master (4f8639f2) read FAILED for this PR, on the same five write-root-dependent rows — a base-level failure, not this diff's (issue #1378). With #1376 now landed, the singleton reading is green. The ordered plan #1376 → #1373 → #1375 was measured OK (3158 P / 18 S, tree 582bc6968e55) before any of the three landed, so the landing order this PR was waiting on has been satisfied.

Review of the change itself. The loss criterion stops asking "is this blob in HEAD or in the upstream tip?" and asks the question it needed — whether the content lives in any ref a blob can live in, and any ref a commit can. That is the correct shape: the previous two-place test over-blocked on dirt that was already published somewhere else, which is the exact class that cost this workspace 33 consecutive read-only cycles. The docstring carries the fail-closed rule (an unreadable status, an unreadable file, or no upstream ref all answer unique), so an unanswerable question cannot release the tier. DEVELOPMENT.md and tests/test_recover_worktree.py move with it. No objection.

@argszero
argszero merged commit 409281b into master Sep 18, 2026
2 checks passed
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.

scheduler: the loss criterion knows two places a blob can live, so dirt already published in a ref still pins the cycle read-only

2 participants