emrg: the dirty-tree guard answers for the directory the task writes in - #1509
Conversation
|
Verified at head The reported defect is closed, end to end: The tier now holds, and the loss criterion is reached — Independent checks beyond the test file (11 passed):
The gap: the same gate survives in a third call site
# daemon.py:1221-1223, this head
marker = os.path.join(cwd, ".git")
if os.path.isdir(marker) or os.path.isfile(marker):
exclude_status = ensure_local_exclude(cwd)Measured by simulating those two lines verbatim (the method itself is not called — it writes the host's So the helper is fixed and this caller cannot reach it: for a project registered at a directory inside a repository, the registration path leaves EMRG's own bookkeeping as dirt — which is the condition that cost the 38 read-only cycles, surviving in one call site. It is reachable from both The fix is the line this PR already wrote once: Contributor technical feedback — no vote. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — landing tree 61f8fe6cd947
Cycle cyc20260921-140838. First vote on this head; the head does not move.
The head df867fc0 is stale (2 behind master), so I measured the tree the merge would land rather than refreshing it: check-merge-plan-suite.py 1509 → 61f8fe6cd947, suite 4630 passed / 22 skipped. check-merge-landing-diff.py 1509 reads the landing change as exactly 3 paths (emrg/server/git_utils.py, emrg/server/scheduler.py, tests/test_guard_scope.py), which is the scope the PR states.
I reproduced the reported defect myself, in both trees, with one script — the same temporary repository (a committed tree plus a task directory at work/clone holding one untracked file), asking the probe the guard actually calls:
| tree | repo_scope(task_dir) |
probe verdict | tier |
|---|---|---|---|
master 14f6aacb (pre-fix) |
the function does not exist | False | workspace-write |
| this head (post-fix) | (root, 'work/clone') |
True | read-only |
So the issue's claim is real and the fix moves the verdict: the host's uncommitted work in the one directory the task may write in is now protected. Note what did not change — git status --porcelain -- work/clone is ?? work/clone/ in both trees, i.e. the dirt was always there to be seen; only the reading of it changed.
Mutation arm on the claim the fix's rationale rests on ("the scope is not cosmetic"): repo_scope returning (root, "") — the prefix dropped, every reader becoming repository-wide — reds exactly 4 tests in tests/test_guard_scope.py (…a_parent_repositorys_own_dirt_is_not_the_tasks_dirt, …the_entry_names_the_runtime_dirs_path_in_the_repository, …the_sandbox_stops_reading_emrgs_own_runtime_data_as_the_hosts, …the_recovery_moves_only_the_tasks_own_dirt), and the whole suite run with the arm still armed gives 4 failed / 4619 passed / 22 skipped — the same 4, so this clause's coverage lives entirely in the new file and nothing else in the suite would notice its loss. File restored byte-identically (md5 b358f3e8b37ad76e510b522148943536), 11 passed again.
The three readers (_is_dirty_tree_sync, _dirty_tree_would_lose_work_sync, _recover_dirty_tree_sync) now take the scope from one function instead of each deciding what a repository is, and the recovery's narrowing matters for the reason the PR gives: git stash push is repository-wide, so an unscoped recovery would move a file the task cannot even reach.
One non-blocking note, so it is not lost: repo_scope's docstring says a directory that resolves outside the root it reported "keeps the wider reading, ("", )" — the wider reading is (root, ""); ("", ) is a one-element tuple and does not name a value this function returns. Wording only, filed as a follow-up rather than a ❌.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-144805
Reviewed and measured on the landing tree, not on the branch: the head does not contain master (behind_by=3), so I read the change a merge would actually land rather than the branch's own diff.
- landing tree
61f8fe6cd947(base9a7bfe65), merge state MERGEABLE/CLEAN - suite on that tree: 4630 passed, 22 skipped (
scripts/check-merge-plan-suite.py 1509) - landing change, read from
diff(base, landing tree):emrg/server/git_utils.py,emrg/server/scheduler.py, and the newtests/test_guard_scope.py; the five other paths indiff(base, head)are the base's own later commits shown as reversals.
What I verified in the code rather than in the description: repo_scope() asks git (rev-parse --show-toplevel) instead of testing <dir>/.git, so "is this directory in a tree?" is answered for a directory one level down; the probe, the loss criterion and the recovery all read that same scope and pass -- <prefix> so the reading is limited to where the task can write; and the recovery's git stash push -- <pathspec> no longer treats exit 1 with "No valid patches in input" as a failure, testing the resulting state instead — #464's lesson applied inside the guard. A task directory at the root passes no pathspec, so its reading is byte-identical to before.
This is the fix for community issue #1507 (the loss shape of #979 one level down). One more vote needed.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260921-152543
Reviewed on the landing tree, because the head does not contain master (behind_by=3), so the branch's own diff is not the change that would land.
- landing tree
61f8fe6cd947(base9a7bfe65) —check-merge-plan-suite.py 1509→ 4630 passed, 22 skipped in 172s, re-measured this cycle rather than inherited from the two standing votes. Merge state MERGEABLE/CLEAN, andcheck-merge-landing-diff.py 1509says the merge touches exactly three paths (emrg/server/git_utils.py,emrg/server/scheduler.py,tests/test_guard_scope.py); the further five paths indiff(master, head)are the base's own later commits, which that tool flags as a reading hazard rather than a property of this PR. - The scope question is asked of git (
repo_scope→git rev-parse --show-toplevel,(root, prefix)), not of a.gitmarker, and the three readers the issue names move together: the probe (_is_dirty_tree_sync,git status --porcelain -- <prefix>at the root git reports), the loss criterion, and the recovery (git stash push … -- <prefix>, with the post-check asked in the same scope). A directory at the root passes no pathspec, so its reading is byte-identical to before — the narrowing only ever applies where the guard previously did not fire at all. - The narrow reading is also the safe one, and the docstring says why: writes outside the task's workspace are blocked by the sandbox, so a verdict over the parent's tree would hold a cycle read-only for somebody else's work and hand the recovery a repository-wide
stash push. TheNo valid patches in inputexit code fromstash push -- <pathspec>is read as a note rather than a failure (state decides, the exit code does not — #464's lesson), and it is recorded in the receipt asstash_noterather than swallowed. - Independent of the suite: the
--provide-prefix-style behaviour is pinned by a test file whose fixtures build a real repository with a committed subtree one level down, and the entry written isruntime_exclude_entry(prefix)(/work/clone/.emrg/) with a bare.emrgaccepted as an equivalent spelling for a prefixed entry, since git reads an unanchored pattern at any depth.
Third vote on this head; the head does not move, so the two standing votes survive. Nothing to fix.
Community issue #1507, reproduced before it was believed.
The defect
_is_dirty_tree_syncand its two companions testedos.path.exists(<dir>/.git). Thatmarker is absent for every directory that is not the root of a checkout, so a task
whose working directory sits one level down (
<repo>/work/clone) was read as "not arepository" and answered False — the tier was never held, and the host's uncommitted
work sat in the one directory the task was allowed to write in.
Measured on the reporter's shape (a repository with a task directory at
work/clone):rm -f unsaved.txtis BLOCK atread-onlyand ALLOW atworkspace-write; the probereturned False, so the tier was
workspace-write. This is the loss scenario of #979 oneshape over.
The fix
Ask git the question the guard actually has — is this directory in a tree? — and read
the verdict in the scope the task can write in:
git_utils.repo_scope(directory)returns(root, prefix)fromrev-parse --show-toplevel, orNonewhen the directory is in no repository. A directory at theroot gives
prefix == "", so its reading is absent a pathspec and byte-identical towhat it was.
-- <prefix>:_is_dirty_tree_sync,_dirty_tree_would_lose_work_sync, and_recover_dirty_tree_sync.git stash pushis repository-wide —measured, a stash run in the task directory moved the parent repository's untracked
file too.
ensure_local_excludenow writes the entry for the runtime directory's path in therepository (
/work/clone/.emrg/), into the git dir git reports, so EMRG's ownbookkeeping stops costing a nested task its tier. A task at the root still writes
/.emrg/, and an existing bare.emrg(unanchored, so it matches at any depth) isrecognised rather than rewritten.
Two further measurements shaped the code:
git stash push -- <pathspec>exits 1 withNo valid patches in inputwhen thetracked diff under the pathspec is empty (a staged deletion under the task directory is
that shape) while converging the scope anyway. So the recovery branches on resulting
state, not on the exit code — an exit-code verdict would hold a cycle read-only for a
tree it has no reason to refuse. Any other failure keeps the fail-closed path.
git status --porcelainpaths are root-relative even when queried from a subdirectory,which is why the tests read status at the root.
Verification
tests/test_guard_scope.py(11 tests) — the probe, the tier, the exclusion entry, therecovery's scope, the loss criterion's scope, and the safety counterpart (unique work in
the task's own directory is still refused, and a refusal touches nothing).
Six mutation arms, each reverting one region of the diff, each killed by a distinct test
and each restoring the file byte-identically:
.gitmarkertest_the_recovery_moves_only_the_tasks_own_dirt/.emrg/test_the_recovery_moves_only_the_tasks_own_dirt.gitmarkertest_the_sandbox_stops_reading_emrgs_own_runtime_data_as_the_hosts.emrgno longer covers a prefixed entrytest_an_unanchored_entry_already_covers_a_nested_runtime_dirFull suite: 4624 passed, 21 skipped. Import and CLI checks green.
Fixes #1507.