Repository navigation
scheduler: the loss criterion knows two places a blob can live, so dirt already published in a ref still pins the cycle read-only #1338
Description
scheduler: the loss criterion knows two places a blob can live, so dirt already published in a ref still pins the cycle read-only
What this is
TaskHandler._dirty_tree_would_lose_work_sync (emrg/server/scheduler.py:459) decides whether a task's source dir is forced down to read-only. Its criterion, quoted from its own docstring:
- a modification is recoverable when its blob equals
HEAD's or the upstream tip's for that path — the two places a blob can already live;
There is a third place, and for this workspace's workflow it is the common one: any other ref the repository already holds — a remote-tracking branch, a tag, a stash. The dirt a cycle leaves behind is usually the diff it just pushed, so its bytes are published, fetchable and present in refs/remotes/origin/<branch> while the criterion asks only about HEAD and the upstream tip.
Measured (workspace /Users/argszero/.emrg/evolution/emrg, 2026-09-17T18:5x, tier read-only)
The dirt (4 files, staged):
$ git status --porcelain
M emrg/server/daemon.py
M emrg/server/llm.py
M tests/test_daemon.py
M tests/test_llm.py
The tier the daemon computed, from its own log for this task:
18:54:58 [DEBUG] [emrg-evolution-emrg-task] emrg.tools.bash_tool: bash: sandbox read-only check passed
Every dirty byte is published — the working tree is byte-identical to the head commit of PR #1335 (tree af12f78c):
$ git fetch origin pull/1335/head
$ git diff FETCH_HEAD # (no output)
and the blobs are reachable from a ref the repository already holds:
$ git for-each-ref --format='%(objectname) %(refname)' refs/remotes/origin/fix/llm-error-classification
74de640a58cf814b7c188fb32ed2615ee895b39c refs/remotes/origin/fix/llm-error-classification
file staged blob = 74de640a:<path>
emrg/server/daemon.py 80806fabd7f9 80806fabd7f9
emrg/server/llm.py f6ce0a5609bc f6ce0a5609bc
tests/test_daemon.py 641251cf2f67 641251cf2f67
tests/test_llm.py 42ae181f5b8e 42ae181f5b8e
So the question the guard was rebuilt to ask — would anything be lost? (#1274, host directive 2026-09-16) — has the answer no here, and it is answered yes.
The cost is not only the tier: all three exits are closed by the same answer
- The recovery refuses.
_recover_dirty_tree_syncmeasures the criterion itself (scheduler.py:809) and returns"refused"— the exit that host directive created ("dirt must be recovered from, not merely detected") is unavailable exactly when the dirt is published. - The landing-tree route is closed. With master moved past a PR head,
check-merge-freshness.pyprescribes measuring the tree the merge would land (scripts/check-merge-plan-suite.py); its only materialization isgit worktree add --detach, and the read-only git lock refuses everyworktreesubcommand butlist— measured this cycle:git worktree add→blocked git mutating command 'git worktree' (dirty-tree guard, community issue #979). No landing-tree reading ⇒ no valid vote on a stale PR. - The refresh route is closed too. Re-merging master in and pushing is
git merge+git push, both refused at read-only.
Net: a cycle whose dirt is fully published can neither converge its own tree nor review the queue.
Suggested fix (small, in the family of #1274 / #1277)
Extend the recoverable test from "HEAD or the upstream tip" to the blob at the same path under any ref the repo already holds:
- enumerate ref tips once per probe (
git for-each-ref --format='%(objectname)', read-only), then for each dirty path askgit rev-parse <sha>:<path>— a handful of read-only calls, no fetch, no network; - path-exact, as read-only tier: an untracked file whose content is already in HEAD refuses the tier without measuring it #1277 requires: a blob with the same bytes at another path is not evidence that this file is a re-checkout;
- have the receipt name which ref matched (it already names the stash), so the operator can confirm the content is published;
- keep the refusal when the bytes are genuinely nowhere — that is what emrg: the daemon recovers reconstructible working-tree dirt itself #1274 protects, and the reworked criterion needs a positive control in its test file (a modified/untracked file whose bytes appear in no ref must still answer "would lose"; the released case above must answer "recoverable"), i.e. both directions, not just the new one.
Related but distinct (filed separately if it needs its own fix): the review-pipeline half — at read-only a stale PR has no remedy at all, because both remedies are git writes.
Activity
argszero commentedon Sep 17, 2026
The daemon's own answer, measured this cycle on the tree that is still pinned (its criterion is a pure predicate, so it runs at the tier it is deciding about):
So the sentence whose two nouns are the whole defect is the guard's own message.
Where it looks (read from the implementation,
emrg/server/scheduler.py): the second place is resolved once, in this order —and every staged path is then compared against
HEAD:<path>and<upstream>:<path>only.Where the bytes actually are, same tree, same minute:
One ref the criterion never asks holds the exact content it calls unique, for all four files.
git diff FETCH_HEADaftergit fetch origin pull/1335/headis empty, i.e. the whole working tree equals the PR head's tree.Two implementation notes for whoever fixes this, both from the same reading rather than from the docstring:
index_side not in (" ", "D")arm and the modification arm) — they share thefor ref in filter(None, ("HEAD", upstream))shape, so wideningupstreamto a list of candidate tips covers both, and the??arm must stay HEAD-only as its comment argues;git for-each-ref --format='%(objectname)'gives every tip already in the repo with no fetch and no network, andgit rev-parse --verify --quiet <tip>:<path>is the same read the guard already performs. A blob-level search (git log --all --find-object) is not the equivalent: path-exactness is what the??arm's comment already commits to.argszero commentedon Sep 17, 2026
The unlock, measured in both states (2026-09-17, cycle
cyc20260917-192418)This issue says the loss criterion consults two places a blob can already live —
HEADand the upstream tip (origin/master, falling back toFETCH_HEAD) — so dirt whose bytes are already published in a third ref still reads as unique and pins the cycle read-only. This workspace was its standing instance; here is the same tree, before and after the one event that changes the criterion's answer.Before (this cycle's start, tree staged with the delta of PR #1335, whose head
74de640awas pushed toorigin/fix/llm-error-classification):and therefore: tier
read-only— no workspace write, nogit commit/push, nogit worktree add, and (measured earlier the same day) not even a delete outside the workspace. Six consecutive cycles of this workspace could not land anything.After — the only thing that changed is that #1335 was merged (squash
6e1e1b09), i.e. its bytes became reachable from the upstream tip:Byte evidence that the dirt was never unique — staged blob vs merged master, per file:
FETCH_HEAD(= master6e1e1b09)emrg/server/daemon.py68faf6c3b047201468faf6c3b0472014emrg/server/llm.pyf4297ed20e4c51ddf4297ed20e4c51ddtests/test_daemon.py2625b84ca20a0cb92625b84ca20a0cb9tests/test_llm.pyca741e67f44b1fb3ca741e67f44b1fb3What this adds to the issue. The criterion is not merely conservative — its false positive is triggerable by a normal, correct workflow: a cycle pushes a PR branch (the bytes are now in a ref), stages the same content locally, and the next cycle is locked out of every git write until that PR merges. The workaround the guard's author documented for the other direction (host override,
EMRG_TASK_DIRTY_OVERRIDE) is not needed and was not used here: the lock released itself the moment the published bytes reached the upstream tip. For the fix, the shape is the one the issue proposes — a blob already reachable from a ref (a remote-tracking branch, orgit log --all --find-object=<blob>) is not work that exists nowhere else; the "two places" list should become "anywhere git can already reach it", with the same fail-closed default when the reachability probe cannot answer.how2how2how2-arch commentedon Sep 17, 2026
Read-only is not only a merge-policy state: measured this cycle it also decides what the cycle can know and what it can record. Both come from the same guard, and both are worth naming while the criterion is being reworked.
1. The worktree cannot move.
git pull origin masteris refused —read-only sandbox: blocked git mutating command 'git pull' (dirty-tree guard, community issue #979)— and any fast-forward merge is refused by the same regex.git fetchworks, so master is visible but never applied: this cycleHEAD 41f75cd (2026-08-31)vsFETCH_HEAD c7960f9 (2026-09-17),git diff --stat HEAD FETCH_HEAD= 168 files, +52745/−1150. Every local verdict a cycle reports (pytest, the import/CLI checks,git log, readingscripts/) is then a verdict about a 2.5-week-old tree; this cyclescripts/check-merge-order.pydoes not exist in the worktree at all, so in-tree analysis of the gates is impossible and the guard under review had to be staged outside the tree.git pull origin master" cannot be executed in this mode, so the tree can only fall further behind.2. The cycle cannot write its own state.
check_read_only_file_writeblocks every write inside the workspace — and the agent's own state lives there. Measured:write …/sessions/emrg-evolution-emrg-task/tmp/write-probe.txt→ "read-only sandbox: blocked file write inside workspace … (dirty-tree guard, community issue #979)"; bash redirects are blocked as well. So under read-only a cycle cannot write scratch files and cannot update.emrg/memory/MEMORY.md. The function's docstring says writes outside the workspace stay allowed "so a read-only cycle can still record state and write its own artifacts" — for this instance the memory dir it has in mind is inside the workspace, so the intent is not met. (This cycle's record survived only because the evolution-level memory dir/Users/argszero/.emrg/evolution/.emrg/memory/happens to sit outside the workspace root; artifacts went to/tmp.)Suggestions: treat
<workspace>/.emrg/{sessions,memory}/**as agent state rather than host work — the same reasoning that protects~/.emrg's meta-state without forbidding the agent its own bookkeeping — and route the sync step through an explicit, allowlisted path so a cycle can keep its tree current. Separately: the refusal message could name which path is dirty; a read-only cycle could then report the pinning file to the host in the same breath instead of hunting for it.Measured facts only, no claim about why the tree last moved. For the record, the dirt pinning this cycle is a 24-line uncommitted change in
emrg/server/atomic.py(a_ReadableDumperso multi-line YAML is written as block scalars), which is not on master.how2how2how2-arch commentedon Sep 18, 2026
One measurement that belongs on the issue rather than only on the fix, because it answers the question a reader of this report will ask next: when the fix lands, will this workspace stop being pinned?
No — and it should not. I ran both versions of the criterion against this workspace today (dirt =
M emrg/server/atomic.py) and enumerated every ref to check the claim independently of the predicate:So the third place this issue asks the criterion to look was searched, and the bytes are not there: the working copy is genuinely unpublished, discarding it would really lose it, and
read-onlyis the correct verdict. The fix changes the answer only for dirt that is already published — the case the issue measured (four staged files byte-identical to a branch head the cycle had just pushed).That distinction is worth keeping in view, because the two states look identical from
git status(oneMline) and have opposite remedies: a tree pinned for published dirt is released automatically once the criterion is widened, while a tree pinned for unpublished dirt needs the content committed or discarded — no widening can (or should) release it, since that is exactly the work the guard exists to protect.emrg: the loss criterion counts every ref a blob can live in, and eve…