Skip to content

emrg: document the dirty-tree downgrade, its diagnosis and its one exit (#1239) - #1247

Merged
argszero merged 1 commit into
masterfrom
docs/dirty-tree-downgrade
Sep 15, 2026
Merged

argszero merged 1 commit into
masterfrom
docs/dirty-tree-downgrade

Conversation

@argszero

Copy link
Copy Markdown
Owner

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 --porcelain forces the cycle to read-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/cp destinations, truncate/tee/shred, sed -i, find -delete, git ... --output=<f>, and every git subcommand off the read allowlist (add/commit/stash/checkout/switch included). The recognised shapes are refused wherever the target lives, inside the workspace or outside it, only /dev/null exempt. 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?

git merge-base --is-ancestor HEAD FETCH_HEAD \
  && echo "HEAD is behind master: nothing unmerged is at risk"

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 plain git checkout master does 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 -f does not fix: untracked-only dirt survives it (exit 0, still dirty → next cycle downgrades again) and needs git stash -u, not git clean -fd; and an untracked file whose path the target commit also adds gets its content silently replaced. Adding the path to .gitignore does not help, because that edit is itself an uncommitted change. Try it without -f first — git refuses exactly where -f would discard something and names the file.

4. The override. EMRG_TASK_DIRTY_OVERRIDE takes 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 master protects 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):

check result
full suite (.venv/bin/python -m pytest tests/ -q) 1993 passed, 1 skipped (1994 collected = master's count; the change adds and removes no test)
scripts/check-doc-count.py rc=0 — no tracked file states the Python test count
doc-adjacent tests (test_doc_counts, test_check_doc_count, test_conflict_markers, test_no_duplicate_sources) 115 passed

The override semantics in the prose were read back against TaskHandler._effective_sandbox on this branch — comma-separated names or *, matched against the task name, with a logged receipt — rather than from memory. No code changes; docs only.

…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 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 — 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 behind origin/master, working tree holding master's content, index still at the older HEAD): git checkout masterrc=0, Already on 'master', M f.txt still in git status --porcelain. The claim is about a behind branch and holds there.
  • git checkout -f -B master FETCH_HEAD converges tracked modificationsrc=0, clean tree afterwards.
  • untracked-only dirt survives -f — reproduced: exit 0, still dirty, file still present.
  • "-f silently replaces an untracked file whose path the target commit adds" — reproduced with a branch that adds new.txt: git checkout -f otherrc=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 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 — 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 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 — 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.

@argszero
argszero merged commit 528cc6b into master Sep 15, 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.

1 participant