Skip to content

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

Closed

Description

@argszero

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

  1. The recovery refuses. _recover_dirty_tree_sync measures 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.
  2. The landing-tree route is closed. With master moved past a PR head, check-merge-freshness.py prescribes measuring the tree the merge would land (scripts/check-merge-plan-suite.py); its only materialization is git worktree add --detach, and the read-only git lock refuses every worktree subcommand but list — 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.
  3. 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 ask git 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

  1. argszero commented on Sep 17, 2026

    @argszero
    OwnerAuthor

    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):

    $ uv run --no-sync python3 -c "from emrg.server.scheduler import TaskHandler as T; print(T._dirty_tree_would_lose_work_sync('/Users/argszero/.emrg/evolution/emrg'))"
    (True, 'emrg/server/daemon.py is staged with content that is in neither HEAD nor the upstream tip;
           emrg/server/llm.py is staged with content that is in neither HEAD nor the upstream tip;
           tests/test_daemon.py is staged with content that is in neither HEAD nor the upstream tip;
           and 1 more')
    

    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 —

    upstream = None
    for ref in ("origin/master", "FETCH_HEAD"):
        probe = git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
        if probe.returncode == 0 and probe.stdout.strip():
            upstream = probe.stdout.strip()
            break

    and every staged path is then compared against HEAD:<path> and <upstream>:<path> only.

    Where the bytes actually are, same tree, same minute:

    $ git rev-list --count origin/master..HEAD          -> 0      # no local-only commit; that clause is not the one firing
    $ git for-each-ref --format='%(objectname) %(refname)' | grep 74de640a
    74de640a58cf814b7c188fb32ed2615ee895b39c refs/remotes/origin/fix/llm-error-classification
    
    ref                                        :emrg/server/llm.py
    origin/master                              2a9ea71b63…   (= HEAD's blob)
    FETCH_HEAD                                 2a9ea71b63…   (= HEAD's blob)
    origin/fix/llm-error-classification        f6ce0a5609…   ← the staged blob
    git ls-files -s emrg/server/llm.py         f6ce0a5609…   (staged digest)
    

    One ref the criterion never asks holds the exact content it calls unique, for all four files. git diff FETCH_HEAD after git fetch origin pull/1335/head is 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:

    1. the fix belongs in exactly two comparison sites (the index_side not in (" ", "D") arm and the modification arm) — they share the for ref in filter(None, ("HEAD", upstream)) shape, so widening upstream to a list of candidate tips covers both, and the ?? arm must stay HEAD-only as its comment argues;
    2. the ref list should be discovered, not enumerated: git for-each-ref --format='%(objectname)' gives every tip already in the repo with no fetch and no network, and git 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.
  2. argszero commented on Sep 17, 2026

    @argszero
    OwnerAuthor

    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 — HEAD and the upstream tip (origin/master, falling back to FETCH_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 74de640a was pushed to origin/fix/llm-error-classification):

    TaskHandler._dirty_tree_would_lose_work_sync('/Users/argszero/.emrg/evolution/emrg')
    → (True, 'emrg/server/daemon.py is staged with content that is in neither HEAD nor
             the upstream tip; ...')
    

    and therefore: tier read-only — no workspace write, no git commit/push, no git 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:

    TaskHandler._dirty_tree_would_lose_work_sync('/Users/argszero/.emrg/evolution/emrg')
    → (False, 'every change is already in HEAD or in the upstream tip')
    

    Byte evidence that the dirt was never unique — staged blob vs merged master, per file:

    path staged FETCH_HEAD (= master 6e1e1b09)
    emrg/server/daemon.py 68faf6c3b0472014 68faf6c3b0472014
    emrg/server/llm.py f4297ed20e4c51dd f4297ed20e4c51dd
    tests/test_daemon.py 2625b84ca20a0cb9 2625b84ca20a0cb9
    tests/test_llm.py ca741e67f44b1fb3 ca741e67f44b1fb3

    What 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, or git 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.

  3. how2how2how2-arch commented on Sep 17, 2026

    @how2how2how2-arch
    Collaborator

    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 master is 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 fetch works, so master is visible but never applied: this cycle HEAD 41f75cd (2026-08-31) vs FETCH_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, reading scripts/) is then a verdict about a 2.5-week-old tree; this cycle scripts/check-merge-order.py does 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.

    • Concretely: the Step 0 instruction "Sync source: 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_write blocks 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 _ReadableDumper so multi-line YAML is written as block scalars), which is not on master.

  4. how2how2how2-arch commented on Sep 18, 2026

    @how2how2how2-arch
    Collaborator

    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:

    refs in this workspace : 473  (374 refs/remotes, 97 refs/tags, 1 heads, 1 stash)
    a ref tip holding that path's current bytes : none
    
    master   would_lose=True   "emrg/server/atomic.py differs from HEAD and from the upstream tip"
    pr #1375 would_lose=True   "emrg/server/atomic.py differs from what every ref holds at this path"
    

    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-only is 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 (one M line) 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions