Never let a filename cost the working tree - #3656
Conversation
B1: the R6 `diff --cached --name-only -z` read moved the failure mode below the output layer of git. Without `-z`, `core.quotePath` guarantees ASCII; with it, unmunged bytes reach `subprocess.run(..., text=True)` and a non-UTF-8 filename raises `UnicodeDecodeError` *inside* `run`. The outer best-effort handler swallowed it, abandoned the snapshot commit, and handed the whole tree to the hard reset -- #3639 itself, over a filename. Keep `-z` (R6 bought real metadata fidelity) but catch the decode at the read and commit blind: `_DirtySnapshot.paths`/`n_files` widen to `None`, which the existing wording layer already reads as "take the imperative". Only a *known*-empty index still skips the commit. N1: the machine-state softening rule claimed provenance it does not have. brc-memory is written by the sandbox on the agent own `brc_ack`/`brc_nack` tool call and holds agent-authored prose. Replace "written by orchestrator code" with the rule that actually holds -- regenerated by the next event, with a durable backstop elsewhere -- which admits brc-memory on its merits and still excludes `wontdo.json` / `tester-output.json` (agent output, no regeneration path). Propagated to the agent-facing bus message, both docstrings, and on-demand-agent-lifecycle.md. N2: widen the except in `commit_working_tree` to `Exception`. Its docstring promises it never raises, and the class that would break that promise is `UnicodeDecodeError`, not a subprocess error. N3: couple the two `INCOMPLETE:` grep tokens with a test. N4: document incomplete snapshots + the grep in agent-recovery.md. N5: carry `wip_partial`/`preserved_files` on the discard WARNING so completeness is one query, not a join back by worktree id.
There was a problem hiding this comment.
No agent-mode design concerns.
This is orchestrator git-plumbing (_preserve_dirty_tree, commit_working_tree) plus the wording of the discarded-tip bus record. No prompt assembly, no LLM invocation, no model identifiers, no output parsing — the four linter-adjacent anti-patterns (EGG100/EGG200/EGG201, direct API calls) have no surface here.
The one agent-facing surface, _record_discarded_tip, is a good instance of the guide's "orientation, not pre-fetching" pattern and this PR strengthens it:
- The record hands the agent a pointer (
git fetch origin <recovery_ref>) rather than baking snapshot contents into the message — the agent pulls what it needs.orchestrator/kubernetes_spawner/_worktree.py:1146-1152. - The B1 fix keeps the snapshot when the path list is undecodable and degrades only the metadata. The resulting
wip_paths=Noneroutes through_is_machine_state_only(None) is Falseto the imperative branch, so an unknown file set tells the agent to go look rather than reassuring it that nothing was lost. That's the right default under guideline 5 — the orchestrator declines to decide relevance on incomplete information and lets the agent judge. - The
_MACHINE_STATE_FILE_GLOBSsoftening selects wording only; the ref is always pushed and always named, so nothing is withheld from the agent in either branch (_worktree.py:1118-1121). The N1 re-derivation from "orchestrator-written" to "regenerated with a durable backstop" is a provenance correction, not a widening of what gets hidden —wontdo.json/tester-output.jsonstay excluded, and the criterion stated at_worktree.py:733-738is testable against a new entry. - Message body stays natural-language prose; the structured fields alongside it (
wip_paths,wip_files,wip_partial) are genuinely machine-consumed by downstream matching, which is the sanctioned case under guideline 2 rather than JSON-for-humans.
Docs changes are consistent with the code: on-demand-agent-lifecycle.md and agent-recovery.md both describe the degradation honestly ("commits blind", "git log -1 <ref> before you conclude the ref holds everything").
Correctness of the UnicodeDecodeError handling, the except Exception widening, and the real-git test coverage are outside this lens — the general review bot's scope.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review: PR #3656 — Never let a filename cost the working tree
I traced B1 through the real execution path and confirmed the diagnosis is correct: UnicodeDecodeError is a ValueError, the outer except Exception catches it, and the pre-fix behaviour was to abandon the snapshot commit and hand the tree to the reset. The fix is the right shape. N1's provenance correction is also correct and I verified it against the source rather than taking the claim at face value.
Two blocking findings. Neither is in the reasoning — both are in how far the fix reaches.
Blocking
B-1 — The decode guard is class-specific; any other failure of the same metadata-only read still costs the whole working tree, and the new docstring claims otherwise
orchestrator/kubernetes_spawner/_worktree.py:913-926
except UnicodeDecodeError as decode_error:The new docstring (_worktree.py:842-845) promises:
Nothing short of "no commit exists" returns
None: a staged-path list that cannot be read degrades the snapshot's metadata (paths/n_filesbecomeNone) and never its existence.
and docs/architecture/on-demand-agent-lifecycle.md:234-235 promises:
The threshold selects wording only; the snapshot itself is always taken — including when the path list cannot be read at all.
Neither holds. I ran the production helper against the same seam the new unit test uses, varying only the exception class the diff read raises:
UnicodeDecodeError -> snapshot=TAKEN commit_attempted=True
TimeoutExpired(60s) -> snapshot=ABANDONED commit_attempted=False
CalledProcessError -> snapshot=ABANDONED commit_attempted=False
The two abandoned cases fall to the outer except Exception at _worktree.py:948, log "could not preserve uncommitted work; the hard reset below WILL discard it", and return None before the commit is ever attempted. reset --hard + clean -fd then destroy the tree and the orphan detector finds nothing to salvage. That is #3639 verbatim, with the trigger moved from "one bad filename" to "the metadata read was slow or exited non-zero".
Both triggers are concrete:
- Timeout. The read is
git diff --cached --name-only -zwithtimeout=60, called immediately aftergit add -Astaged the entire dirty tree. 33 files is the small case; a hostPath-mounted worktree with a large staged set on a contended node can exceed 60s. - Non-zero exit. The call inherits
check=True. Thegit add -Aabove is wrapped inexcept Exception→partial = True(_worktree.py:880-890), so an add that timed out at 120s is swallowed and execution continues while the git process may still holdindex.lock. The followingdiff --cachedreturning non-zero for any reason (locked or half-written index) is thenCalledProcessError→ total loss.
The asymmetry argument the PR makes for commit_working_tree in this same diff applies here with more force:
Deliberately broader than the
(OSError, subprocess.SubprocessError)the read helpers above use, and broader than it needs to be today. The docstring promises this never raises […]
That reasoning widened the handler on the path where the bug is unreachable (agent_salvage.py:689, no -z, ASCII output) while leaving the path where the bug shipped on a single exception class. The priority is inverted: commit_working_tree's promise is about not raising; _preserve_dirty_tree's promise is about not losing the working tree.
Fix — widen the read's handler. The cost is strictly one-directional: staged = None can only lose the softening (_is_machine_state_only(None) is False) and the size claim. There is no input for which widening makes the record quieter or wronger — it can only trade path metadata for a commit. And if the index genuinely is empty, the git commit below still exits non-zero and falls through to the outer handler exactly as today, so the known-empty case does not regress.
try:
staged_out = git(repo_dir, "diff", "--cached", "--name-only", "-z", timeout=60).stdout
staged: list[str] | None = [p for p in staged_out.split("\0") if p]
except Exception as read_error:
logger.warning(
"Worktree re-attach: staged-path list could not be read (non-UTF-8 "
"filename, timeout, or a failed diff); committing the snapshot "
"without a path set",
agent_worktree_id=agent_worktree_id,
repo=repo,
dirty_entries=n_entries,
dirty_state_unknown=state_unknown,
undecodable=isinstance(read_error, UnicodeDecodeError),
error=str(read_error),
)
staged = NoneAdd the sibling unit test with subprocess.TimeoutExpired. test_undecodable_staged_path_does_not_cost_the_commit pins only the class that was fixed, which is what makes this gap invisible — a test named for the invariant ("does not cost the commit") that only covers one of the three ways to lose it.
B-2 — The INCOMPLETE: grep this PR adds to the runbook (N4) is malformed Markdown and, as rendered, silently matches nothing
docs/reference/agent-recovery.md:383. Two broken code spans on the new sentence:
`INCOMPLETE: \`git add -A\` reported errors while staging`
`git log --all --grep 'INCOMPLETE: `git add -A`'`
Per CommonMark, backslash escapes do not work inside code spans, and a single-backtick span cannot contain a backtick. Both parse as two adjacent code spans with the inner backticks consumed as delimiters:
- the first renders as
INCOMPLETE: \+ plaingit add -A\+reported errors while staging - the second renders as
git log --all --grep 'INCOMPLETE:+ plaingit add -A+'
So the command an operator copies out of the rendered page is git log --all --grep 'INCOMPLETE: git add -A' — backticks gone. The commit-message token does contain them (_WIP_COMMIT_PARTIAL_SUFFIX = "INCOMPLETE: `git add -A` reported errors while staging…"), so that pattern matches nothing. Zero results reads as "no truncated snapshots" — the precise false negative this paragraph was added to prevent, and on the #2807 path the same paragraph correctly notes the commit message is the only channel a triager gets.
Fix — double-backtick fences:
``INCOMPLETE: `git add -A` reported errors while staging``
``git log --all --grep 'INCOMPLETE: `git add -A`'``
Worth correcting in the same edit: git log --all covers local refs only. Both snapshot paths push to egg/recovered/* on origin, so a triager on a fresh clone has no ref for them until they fetch. As written, "finds every truncated snapshot from either path" is false on the common triage setup — either name the fetch (git fetch origin 'refs/heads/egg/recovered/*:refs/remotes/origin/egg/recovered/*') or drop the "from either path" claim.
Note that N3 cannot catch this: test_partial_suffixes_share_one_grep_token hardcodes the literal rather than reading the doc, so the constants stay coupled to each other but not to the runbook. Asserting the token is a substring of docs/reference/agent-recovery.md would close it.
Non-blocking
NB-1 — N5's new log fields are named off-pattern, and None renders as blank. _worktree.py:648-653 emits wip_partial=…, preserved_files=wip_files on a line whose sibling snapshot field is wip_commit, and whose bus-record counterpart calls the count wip_files. N5's stated goal is "one query over this WARNING"; as written a consumer queries wip_files on the bus and preserved_files in the log. Rename to wip_files. Second-order: running the real logger with wip_files=None renders preserved_files= (empty), which reads as zero files preserved rather than unknown — on the exact B1 path N5 exists to make queryable. This is the ambiguity dirty_state_unknown was introduced elsewhere to avoid; consider a companion wip_files_unknown=True.
NB-2 — Discarding all 33 paths for one bad byte is avoidable, and this repo already has the better pattern. orchestrator/routes/pipelines/_worktree_sync.py:1326-1352 performs the same -z read in bytes mode (no text=True, deleted.stdout.split(b"\0")) for exactly this reason, with a comment saying so. Decoding per-path (errors="replace") keeps the 32 good names and degrades only the bad one, which strictly improves wip_paths and cannot leak surrogates downstream. The PR's reason for rejecting surrogateescape is right about raw surrogates reaching json.dumps/the DB driver, but it does not rule out a lossy decode at the boundary. The softening still cannot fire on a replaced name (_path_matches_glob fails on caf\ufffd.md), so the safety property is preserved. This needs an errors= passthrough on the git closure, so it's real work — flagging it as the better shape, not as a blocker.
NB-3 — core.quotePath is now load-bearing and is the one git config the closure does not pin. _worktree.py:420-442 explicitly overrides core.hooksPath, safe.directory, and commit.gpgsign precisely because inherited config can break the snapshot. B1's analysis now rests on core.quotePath=true in four places (_worktree.py:892, agent_salvage.py:707-708, and the new test docstring at test_kubernetes_spawner.py:4496). I grepped the tree — nothing sets it either way, so the default holds today. If it ever doesn't: git clean -fd at _worktree.py:527 prints Removing caf\xe9.md raw, raises UnicodeDecodeError, and returns False — sending the spawn to create-with-retry after the snapshot commit exists but before the salvage push, so the commit is never pushed. Adding -c core.quotePath=true to the closure (and agent_salvage._run_git) turns four inherited-default assumptions into an invariant, and costs nothing since -z ignores the setting.
NB-4 — N5 ships without a test. B1 got two and N3 got one; the two new WARNING fields got none. A caplog assertion would pin them.
NB-5 — except Exception in commit_working_tree now swallows programming errors indistinguishably. agent_salvage.py:689. The trade is defensible given the docstring's promise, but "capturing uncommitted working tree raised; continuing" renders an AttributeError from a future refactor identically to a subprocess failure. Add error_type=type(e).__name__.
NB-6 — partial=True is set for causes other than "git add reported errors". _worktree.py:880-890 catches Exception and stamps INCOMPLETE: `git add -A` reported errors while staging on the commit. A TimeoutExpired at 120s takes that branch and produces a commit message making a claim about git's exit status that never happened. Pre-existing, not introduced here — raising it because N3 and N4 make that exact sentence the operator-facing triage token, so its accuracy now matters more than it did before this PR.
NB-7 — "rebuilt on your next event" overstates what the agent gets back. _worktree.py:1149-1152. brc-memory is rewritten by the agent's next brc_ack/brc_nack — i.e. after it has already redone the review — not restored before the next event. docs/architecture/brc-memory.md:15-18 fully backs the durability half of the claim, but line 142 notes summary_of_assessment is "the bit that takes the most context to reconstruct from raw transcript". "Rewritten the next time you ack or nack" is what actually happens and is still soft enough to serve the purpose. The N1 rule change itself is correct — see below.
Verified, for the record
I checked the claims rather than accepting them, since N1 is a correction to a previously-wrong claim:
- N1 is right and the old comment was wrong.
orchestrator/routes/event_prompt/_memory_io.pyhas no write path at all (_read_task_description,_parse_per_producer_sha,_pipeline_id_token,_memory_path,_read_memory_excerpt— reads only), so the old "routes/event_prompt/_memory_iorewrites it" was false.write_memory_atomicis atsandbox/egg_agent_tools/handlers/brc_memory.py:577.docs/architecture/brc-memory.md:15-18states the backstop verbatim: "the durable backstop is the orchestrator message history rehydrated byreconstruct_tracker_from_messages"._MACHINE_STATE_FILE_GLOBSis byte-identical, so N1 smuggles in no behaviour change. - The
Nonewidening is contained. Greppedwip_paths/wip_files/wip_partial/wip_machine_state_only/wip_softenedacross the tree: no consumer outside_worktree.pyand its tests.Nonecannot reach alen()or a comparison in another module. _record_discarded_tip'sNonearms are correct.snapshot_sizefalls back to the unquantified phrasing,_is_machine_state_only(None)isFalse,trivial_snapshotisFalse, metadatawip_paths/wip_filesareNone,wip_softenedisFalse.TestDiscardedTipMessageWording+ both new unit tests: 23 passed.- The #2807 "no bus record" doc claim checks out.
salvage_agent_commitsand_restart.py:275-290log only. - ruff check and format: clean on all three touched Python files.
- The 20 local failures in
TestDirtyTreePreservedBeforeReset/TestDirtyDiscardAutoSalvageare the environmentalgit initblock, as the PR states —CalledProcessErroron_make_worktree, not assertion failures. CI is ground truth fortest_undecodable_filename_is_salvaged_end_to_end. I traced its production path by hand and expect it to pass:git commit's summary goes throughwrite_name_quoted, so thecreate modeline forcaf\xe9.mdis octal-escaped ASCII under default quotePath;salvage_discarded_tipruns no git subprocess at all (gateway push only); andclean -fdhas nothing untracked left to name by the time it runs.
The e2e test is a genuinely good addition — it's the only real-git coverage of -z, and the os.fsdecode(b"caf\xe9.md") round-trip is the right way to build the fixture without bypassing the production path.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Round-4 review fixes for the worktree re-attach preservation path. The staged-path read now decodes with errors="replace" and catches any exception, so a timeout or non-zero diff degrades only the snapshot's metadata while an undecodable filename costs one name instead of the whole path set. core.quotePath=true is pinned explicitly in both _git closures rather than inherited. The discard and success WARNINGs use a consistent wip_* key namespace with an explicit wip_files_unknown flag, and both partial-commit suffixes share one greppable token that the runbook now quotes verbatim inside a fenced block alongside the origin/egg/recovered fetch git log --all needs.
Round-4 review responseAll nine items fixed in-PR. Nothing deferred, nothing disputed. BlockersB-1 — a failed staged-path read must not lose the commit — B-2 — runbook grep was unrunnable — git fetch origin 'refs/heads/egg/recovered/*:refs/remotes/origin/egg/recovered/*'
git log --all --grep 'INCOMPLETE: `git add -A`'A sentence names both constants and the pinning test, and warns that the backticks are part of the commit message — a pattern without them matches nothing and reads as "no truncated snapshots". Non-blockingNB-1 — WARNING key namespace — NB-2 — per-path decode instead of all-or-nothing — NB-3 — pin NB-4 — no test on the discard WARNING's completeness fields — NB-5 — NB-6 — "reported errors" is wrong for the timeout case — NB-7 — soft-branch message overstated rebuild — Verification
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
|
egg review failed. View run logs 4 previous review(s) hidden. |
|
egg agent-mode-design failed. View run logs 4 previous review(s) hidden. |
2082973
into
issue-3639-preserve-dirty-worktree
Never let a filename cost the working tree
Round-4 review fixes for #3644. The blocking finding (B1) is a real regression the R6 change introduced: switching the snapshot's staged-path read to
git diff --cached --name-only -zmoved the failure mode below git's output layer. Without-z,core.quotePathguarantees ASCII output; with it, unmunged path bytes reach the caller'ssubprocess.run(..., text=True), which decodes as strict UTF-8 — so a single non-UTF-8 filename raisesUnicodeDecodeErrorinsiderun. The outer best-effort handler swallowed it, abandoned the snapshot commit, and handed the entire working tree to the hard reset. That is #3639 itself, triggered by one filename.The fix keeps
-z— R6 bought real metadata fidelity and giving it back would be the wrong trade — and instead catches the decode at the read, logging a WARNING and committing blind._DirtySnapshot.paths/n_fileswiden toNone, which the existing wording layer already reads as "take the imperative". Only a known-empty index still skips the commit;staged is Nonemeans "could not tell", and this helper never discards a tree on a maybe.errors="surrogateescape"was deliberately not used: lone surrogates are unencodable by a UTF-8 DB driver and byjson.dumps, which would move the failure downstream into the message bus.N1 corrects a provenance claim the softening rule did not have: brc-memory is written by the sandbox on the agent's own
brc_ack/brc_nacktool call and holds agent-authored prose. The membership rule is now regeneration-plus-backstop, which admits brc-memory on its actual merits and still excludeswontdo.json/tester-output.json. N2 widenscommit_working_tree's except toException(its docstring promises it never raises; the class that would break that isUnicodeDecodeError, not a subprocess error). N3 couples the twoINCOMPLETE:grep tokens with a test, N4 documents incomplete snapshots and the grep, N5 carrieswip_partial/preserved_fileson the discard WARNING.Issue: #3639
Test Plan
test_undecodable_staged_path_does_not_cost_the_commit(unit — asserts the commit is still taken and onlypaths/n_filesdegrade toNone),test_undecodable_filename_is_salvaged_end_to_end(real-git — a latin-1 filename in the tree, asserting the WIP commit holds the edits, the message takes the imperative wording, and the worktree lands clean at the origin tip),test_partial_suffixes_share_one_grep_token(N3).git initis gateway-blocked in the agent container, so the 10 real-git cases inTestDirtyTreePreservedBeforeReset— including the new end-to-end one — cannot execute here and fail withCalledProcessErroron_make_worktree. Same caveat as prior rounds; CI is ground truth. The 28 non-real-git cases pass locally, including both new unit tests.make lint: ruff and format clean. Mypy reports 3 pre-existing errors inshared/egg_agent/client.py, a file this PR does not touch.Manual Steps