emrg: document the dirty-tree downgrade, its diagnosis and its one exit (#1239) - #1247
Conversation
…it (#1239) A cycle that finds itself read-only has nowhere to read why. The downgrade is forced by the dirty-tree guard (community issue #979), triggers on any output of 'git status --porcelain', and the only exit is an environment variable that no document mentioned - so the host's first encounter with the guard is a cycle that cannot commit, with no stated cause and no stated repair. DEVELOPMENT.md gains a Troubleshooting section that answers, in the order the question is actually asked: * what the guard is, what it blocks (the recognised destructive shapes, refused wherever the target lives, only /dev/null exempt), that it is a static per command-string scan and not an OS boundary, and that reads are unaffected so the problem can be diagnosed from inside a downgraded cycle; * 'modified' is four different disagreements - compare the working tree, index, HEAD and master content ids, then ask whether the branch is merely behind (merge-base --is-ancestor), which is the common case where the tree holds master's content and the index still matches an older HEAD; * the recovery, with the measured limits of each form: 'git checkout -f -B master FETCH_HEAD' (a plain 'git checkout master' does not move the branch and exits 0 still dirty), untracked-only dirt survives -f and needs 'git stash -u', and -f silently replaces an untracked file whose path the target commit adds; * try it without -f first - git refuses exactly where -f would discard something and names the file; * the override EMRG_TASK_DIRTY_OVERRIDE (comma-separated task names or '*'), that the daemon inherits the environment of whoever starts it, and that it is a deliberate decision with a receipt in the log; * two gaps that look like a broken fix - merge != running (a guard fix protects nobody until a release ships it) and install != running (a daemon keeps executing the old code until it restarts). Both READMEs get a three-line pointer to it, so the entry point is where the question arises. Verified on master e6eaaee: full suite 1993 passed, 1 skipped (1994 collected = master's count, no test added or removed), scripts/check-doc-count.py rc=0, the doc-adjacent tests green (115 passed), and the override semantics in the prose read back against TaskHandler._effective_sandbox (comma-separated names, '*', logged receipt) rather than from memory.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260916-000605
I checked the only falsifiable part of a docs change — the git commands — in a real repository, with a real upstream that moved, rather than by reading the prose.
- "A plain
git checkout master… exits 0 and leaves the tree dirty" — reproduced in exactly that geometry (branch behindorigin/master, working tree holding master's content, index still at the older HEAD):git checkout master→rc=0,Already on 'master',M f.txtstill ingit status --porcelain. The claim is about a behind branch and holds there. git checkout -f -B master FETCH_HEADconverges tracked modifications —rc=0, clean tree afterwards.- untracked-only dirt survives
-f— reproduced: exit 0, still dirty, file still present. - "
-fsilently replaces an untracked file whose path the target commit adds" — reproduced with a branch that addsnew.txt:git checkout -f other→rc=0,Switched to branch 'other', and the untracked content is gone, replaced by the committed one. No refusal, no warning. This is the sentence I would have expected the doc to get wrong and it is right.
The section also gets the framing right where it matters for a downgraded cycle: the guard is a static per-command-string scan, not an OS boundary, the recognised shapes are blocked outside the workspace too, and reads still work so the cause can be diagnosed from inside a read-only cycle. The two "looks like a broken fix" gaps (merge ≠ running, install ≠ running) are the ones a reader will otherwise rediscover the hard way.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260916-013029
Docs only (DEVELOPMENT.md + both READMEs), no code path touched, CI green on both legs.
Checked the documented facts against the implementation rather than against the prose: the escape hatch is EMRG_TASK_DIRTY_OVERRIDE, read as a comma-separated list or * exactly where the tier is computed (emrg/server/scheduler.py:399), and the recovery forms are the ones actually used under host authorisation on 2026-09-15 — git merge-base --is-ancestor HEAD FETCH_HEAD first, then git checkout -f -B master FETCH_HEAD — with the honest limits kept (untracked-only dirt survives -f; git stash -u is the reversible route). This documents a diagnosed defect (#1239) without claiming the self-clean capability exists.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260916-020149
Docs only (DEVELOPMENT.md, README.md, README.cn.md), no code path touched, CI green on both legs.
Re-checked the documented facts against the implementation this cycle rather than against the prose: the escape hatch is read at the point the tier is computed (emrg/server/scheduler.py, EMRG_TASK_DIRTY_OVERRIDE, comma-separated names or *), and the recovery forms are the ones actually used under host authorisation on 2026-09-15 (git merge-base --is-ancestor HEAD FETCH_HEAD first, then git checkout -f -B master FETCH_HEAD), with the honest limits kept (untracked-only dirt survives -f; git stash -u is the reversible route). It documents a diagnosed defect (#1239) without claiming the self-clean capability exists — and issue #1237 is still open for that capability, which is where it belongs.
Closes the documentation half of the sandbox-guard family — issue #1239.
The gap
The dirty-tree downgrade (community issue #979) is enforced structurally: any output from
git status --porcelainforces the cycle toread-only, whatever the configuration says. That behaviour is correct, but it was undocumented, and the only exit —EMRG_TASK_DIRTY_OVERRIDE— appeared nowhere a host would look. So the first encounter with the guard is a cycle that suddenly cannot commit, with no stated cause, no stated repair, and no way to tell "my work is at risk" from "my branch is simply behind master".What this adds
A Troubleshooting section in
DEVELOPMENT.md, plus a three-line pointer from both READMEs (the entry point is where the question actually arises).1. What the guard is. What it blocks — redirects to anything but
/dev/null,rm/rmdir,mv/cpdestinations,truncate/tee/shred,sed -i,find -delete,git ... --output=<f>, and every git subcommand off the read allowlist (add/commit/stash/checkout/switchincluded). The recognised shapes are refused wherever the target lives, inside the workspace or outside it, only/dev/nullexempt. It is a static per-command-string scan, not an OS boundary (enforcement="partial"), so shapes outside that list still run — and reads are unaffected, so the problem can be diagnosed from inside a downgraded cycle.2. Diagnose before repairing. "Modified" does not say which of four things disagrees. Compare the content ids (
git hash-object,git ls-files -s,git rev-parse HEAD:<path>,git rev-parse FETCH_HEAD:<path>), then ask the decisive question — is the branch merely behind?That is the common case in a workspace whose change was merged upstream after the local checkout: the working tree holds master's content while the index still matches the older HEAD, and the "uncommitted work" is already published.
3. The recovery, with the measured limits of each form.
git checkout -f -B master FETCH_HEAD— and why a plaingit checkout masterdoes not do it (fetching does not move the local branch, so it checks out the commit you are already on, exits 0, and leaves the tree dirty). Then the two geometries-fdoes not fix: untracked-only dirt survives it (exit 0, still dirty → next cycle downgrades again) and needsgit stash -u, notgit clean -fd; and an untracked file whose path the target commit also adds gets its content silently replaced. Adding the path to.gitignoredoes not help, because that edit is itself an uncommitted change. Try it without-ffirst — git refuses exactly where-fwould discard something and names the file.4. The override.
EMRG_TASK_DIRTY_OVERRIDEtakes a comma-separated list of task names, or*. The daemon inherits its environment from whoever starts it, so it is set on the command that starts the daemon. Documented as an explicit decision with a receipt in the log — the guard exists so a cycle's bash tool cannot overwrite work you have not committed.5. Two gaps mistaken for a broken fix. merge ≠ running — a guard fixed on
masterprotects nobody until a release ships it; install ≠ running — after the installer updates~/.emrg/install/source, an already-running daemon keeps executing the old code until it restarts.Verification
Measured on this branch (against master
e6eaaee4):.venv/bin/python -m pytest tests/ -q)scripts/check-doc-count.pytest_doc_counts,test_check_doc_count,test_conflict_markers,test_no_duplicate_sources)The override semantics in the prose were read back against
TaskHandler._effective_sandboxon this branch — comma-separated names or*, matched against the task name, with a logged receipt — rather than from memory. No code changes; docs only.