Preserve a re-attached worktree's uncommitted work before the R6 reset (#3639) - #3644
Conversation
`_clean_reused_worktree` hard-resets a reused worktree on every event respawn. #3506/#3509 built preservation around that reset, but both operate on commits: `salvage_discarded_tip` pushes the doomed HEAD to `egg/recovered/...` only when the orphan detector finds commits ahead of the origin tip. A session that worked for hours without committing had nothing for it to find, so `git reset --hard` + `git clean -fd` erased the entire working tree and the path logged `cleaned and synced` at INFO (#3639: 110 minutes across 33 modified files, lost on a routine respawn). Close the gap one step earlier rather than building new machinery: when the pre-discard tree is dirty, `_preserve_dirty_tree` commits it (`git add -A` plus a `[salvage] pre-reset working-tree state (#3639)` commit, reusing #2807's `egg-salvage` identity so one grep finds every machine-made snapshot). The snapshot is then an ordinary orphan, and the existing salvage + bus-record path carries it to a recovery ref. The residue policy is unchanged: `was_dirty` is latched before the snapshot, so the tree still hard-resets to the origin tip and a successor never inherits a killed-mid-event working set. The snapshot only makes the discarded state recoverable. Details: - Best-effort throughout. A failed add/commit, or an ignored-files-only tree, logs at WARNING with the entry count and proceeds with the reset; blocking reuse would only divert to create-with-retry, which destroys the same state with less visibility. - Skipped when `branch` is None: with no origin tip and no salvage target the snapshot would just become the successor's HEAD, promoting un-vetted residue to committed state. That case now logs the discard instead of staying silent. - Does not call `agent_salvage.commit_working_tree` (#2807's equivalent) because its `_run_git` omits `safe.directory=*`, which the re-attach path needs for host-uid-owned worktrees; the caller's git closure is threaded in instead. - The bus message tells a resuming agent the top commit is an automatic snapshot to review, not work it already proposed, and carries `wip_commit` in its metadata. Docs record the new contract in the re-attach policy and the recovery reference, which previously stated only that uncommitted state is discarded.
There was a problem hiding this comment.
No agent-mode design concerns. Approving on the design-alignment lens only (general quality/correctness is the base bot's call).
The change is orchestrator-side git plumbing — no prompt assembly, no LLM invocation, no model identifiers, no agent-output parsing. Scanning the diff for the eight anti-patterns turns up nothing; two points are worth naming as positives rather than findings:
-
The agent-facing surface stays a pointer, not a payload. The only thing this PR adds to what a resuming agent sees is
wip_textin_record_discarded_tip(orchestrator/kubernetes_spawner/_worktree.py:822): one SHA plus the provenance of that commit, on top of the existing "rungit fetch origin {recovery_ref}and inspect it before starting work" line. It tells the agent where the snapshot is and hands it a tool to go read it, instead of baking the 33-file working set into the message. That is guideline 1 applied the right way — and the provenance sentence ("treat it as a WIP checkpoint to review, not as work you already proposed") is exactly the class of context the guide calls out as legitimate: the agent cannot discover fromgit logalone that its top commit was machine-made. -
The residue policy stays mechanism-enforced. R6 is upheld by
was_dirtybeing latched before the snapshot and the tree still hard-resetting to the origin tip (_worktree.py:454-490), not by instructing the successor to ignore residue. Thebranch is Nonebranch is the sharper case: rather than taking a snapshot and telling the agent not to build on it, it declines the snapshot outright because the commit "would just become the successor's HEAD: un-vetted residue promoted to committed state." Preferring the technical boundary over a prompt-level don't is the guide's core principle, and it would have been easy to get wrong in the other direction.
Doc updates in agent-recovery.md and on-demand-agent-lifecycle.md keep the written policy in sync with the behavior the agent is expected to follow, which addresses the "an agent cannot follow a contract it is never shown" point from the issue thread.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review: Preserve a re-attached worktree's uncommitted work before the R6 reset (#3639)
The core design is right, and the diagnosis correction on #3639 is well-evidenced. Committing the dirty tree one step before the reset — rather than building a parallel working-tree-snapshot subsystem — is the correct call: it reuses #3509's salvage + record path wholesale, keeps was_dirty latched so R6 residue policy is genuinely unchanged, and the reason given for not calling agent_salvage.commit_working_tree checks out (orchestrator/agent_salvage.py:220 builds ["git", "-c", "core.hooksPath=/dev/null", "-C", ...] with no safe.directory=*).
I could not run the suite: git init is blocked by the gateway in this container (ERROR: git init is not supported in the container), so every test in TestDirtyTreePreservedBeforeReset / TestDirtyDiscardAutoSalvage errors in _make_worktree before reaching production code. Findings below are from reading, not execution. ruff check and ruff format --check are clean on both changed Python files.
One blocking issue, plus a set of non-blocking ones.
Blocking
1. The bus message tells a resuming agent "Nothing was lost" on the path where salvage failed
orchestrator/kubernetes_spawner/_worktree.py:826-835 — wip_text is appended on if wip_commit, with no reference to recovery_ref. When the salvage push fails, the two halves of the body directly contradict each other. The rendered message is:
... Automatic salvage FAILED (gateway down); the commits survive only in the local git object store until gc. Ask an operator to recover them (salvage_agent_commits, #3368) before re-deriving any work. Commit
<sha>is an AUTOMATIC snapshot of the uncommitted changes your previous session left behind (#3639). Nothing was lost, but treat it as a WIP checkpoint to review, not as work you already proposed.
Failure scenario, and it is an already-exercised path — test_salvage_failure_still_resets_and_records_tip drives exactly it: dirty tree with no commits → _preserve_dirty_tree returns a sha → salvage_discarded_tip raises or returns ok=False → recovery_ref is None, salvage_error="gateway down", wip_commit=<sha>. The message-bus record is the only durable channel a memory-less resuming agent has (that is #3509's entire premise). Telling it "Nothing was lost" in the one case where the work is one gc away from gone is worse than the pre-#3639 silence, because it actively suppresses the escalation the preceding sentence just asked for. The existing test asserts metadata["salvage_error"] but never the body, so nothing catches this.
Fix — make the reassurance conditional on the push having succeeded, e.g.:
if wip_commit and recovery_ref:
wip_text = (
f" Commit {wip_commit} is an AUTOMATIC snapshot of the uncommitted changes "
"your previous session left behind (#3639); it is on the recovery ref above. "
"Treat it as a WIP checkpoint to review, not as work you already proposed."
)
elif wip_commit:
wip_text = (
f" Commit {wip_commit} is an AUTOMATIC snapshot of the uncommitted changes "
"your previous session left behind (#3639) and it was NOT pushed — it exists "
"only in the local object store. Escalate before re-deriving any work."
)Please add a body assertion to test_salvage_failure_still_resets_and_records_tip so the two branches stay pinned.
Related, in the same else branch (pre-existing, but this PR materially amplifies it): the advice "Ask an operator to recover them (salvage_agent_commits, #3368)" is the recovery route that salvage_discarded_tip's own docstring says does not work here — "salvage_agent_commits inspects worktree branches that the reset has already moved (#3509)". Before this PR that branch only ever fired for commits the agent chose to make; now it fires for the uncommitted-work class too, which is the higher-value case. Pointing at git reflog / the orchestrator-local object store would be honest; pointing at a tool that provably cannot see the sha is not.
Non-blocking
2. A partial git add -A failure throws away the work that was staged
_worktree.py:713-714. _git runs with check=True, so a non-zero git add -A raises and the helper returns None; the caller then hard-resets and everything is gone.
Per git-add(1), the default behaviour on an unindexable file is to abort — --ignore-errors exists precisely to opt out of that, and "The command shall still exit with non-zero status." So a single bad entry in the worktree (unreadable file, fifo/socket, an LFS filter failing because git-lfs isn't in the orchestrator image) aborts the add with a partially-populated index, and this code then discards the other 32 of 33 files. For a helper whose entire purpose is "never lose the working tree," partial preservation beats none. Two cheap options:
- pass
--ignore-errorsso the add continues past the bad entry, and/or - on add failure, still run the
diff --cachedcheck and commit whatever landed in the index instead of returning early.
Right now the WARNING is the only signal and it is emitted at the moment the work becomes unrecoverable.
3. test_ignored_only_dirt_is_discarded_without_a_commit never reaches _preserve_dirty_tree
test_kubernetes_spawner.py:4103-4126. The fixture commits .gitignore containing build/, then creates build/artifact.o. git status --porcelain does not list ignored files, so was_dirty is False at _worktree.py:455 and the if was_dirty: block is skipped entirely — _preserve_dirty_tree is never called. The test passes identically on main; it pins nothing this PR added.
The consequence is that the if not staged: branch (_worktree.py:715-723) — the one the test's docstring describes — has zero coverage. It is also very close to unreachable in production for the stated reason: any condition that makes status --porcelain non-empty produces something for add -A to stage. The realistic way to reach it is a dirty submodule ( M sub in porcelain, nothing staged by add -A when the gitlink is unchanged) — in which case the log line "dirty tree held no committable change (ignored files only)" misattributes the cause and will send whoever reads it down the wrong path.
Suggest either rewriting the test to actually drive that branch (submodule, or inject a git closure whose diff --cached returns empty), or dropping the docstring's claim and generalising the log message to "no committable change (ignored files or submodule-only dirt)".
4. No test for the new branch is None skip
_worktree.py:479-491 is new behaviour the PR body calls out explicitly ("Skipped when branch is None"), and its rationale (a snapshot would become the successor's HEAD, promoting un-vetted residue) is the one place where getting this wrong silently violates R6. TestSpawnEventJobDirtyWorktree::test_reattach_discards_uncommitted_changes passes through it incidentally but asserts nothing about it. A case asserting no commit is created when branch is None — and that HEAD is unmoved — would lock the invariant down.
5. Nothing pins the snapshot's commit identity, despite it being a documented cross-module invariant
_worktree.py:675-684 duplicates egg-salvage / egg-salvage@localhost from agent_salvage:87-89, and both docs/reference/agent-recovery.md:33 and the inline comment promise "one [salvage] grep finds every machine-made working-tree snapshot regardless of which path took it." No test asserts the author, email, or [salvage] prefix of the produced commit — test_uncommitted_work_is_salvaged_not_destroyed checks file contents only. Nothing prevents the two identities drifting and quietly breaking the documented grep.
Also, the stated reason for duplicating rather than importing ("kubernetes_spawner.agent_salvage is a patched seam in the suite") does not hold for a module-level from agent_salvage import _SALVAGE_COMMIT_NAME, _SALVAGE_COMMIT_EMAIL — that binds the real value at import and is immune to patching the kubernetes_spawner.agent_salvage attribute. If you keep the duplication, add assert _WIP_COMMIT_AUTHOR_NAME == agent_salvage._SALVAGE_COMMIT_NAME (and email) as a test.
6. Every dirty re-attach now pushes a remote ref and posts a system message
This is the behavioural blast radius worth being deliberate about. On main, a dirty tree with no commits ahead took the local_head == remote_tip → keep_local=True path: no orphan block, no gateway push, no bus message. Under this PR the snapshot commit guarantees local_head != remote_tip, and elif not was_dirty is False, so keep_local is always False — meaning any dirt, however trivial, now produces:
- a
gateway.push_worktree_branchcall creating a newegg/recovered/<pipeline>/<scope>/<sha12>branch on origin, and - a
STATUSmessage to the role telling it togit fetchand "inspect it before starting work."
Trivial dirt at respawn is not hypothetical here: .egg-state/ is tracked and not gitignored (944 files), and sandbox/egg_agent_tools/handlers/brc_memory.py writes .egg-state/agent-outputs/<role>/brc-memory-<pipeline-id>.md into the worktree on every brc_ack/brc_nack. Any respawn where the agent didn't commit that file lands on this path.
I am not suggesting a size/importance heuristic to suppress the snapshot — #3639 is precisely the case where "trivial-looking dirt" was 110 minutes of work, and a heuristic reintroduces the bug. Ref accumulation is bounded (agent_salvage_cleanup, 90-day TTL). The concern is the message: burning a resuming agent's turns fetching a recovery ref that holds one memory file, repeatedly, is how #3509's message gets trained into background noise. Worth tuning the wording so a snapshot-only discard (n_commits == 1 and wip_commit == discarded_tip) reads as "a snapshot is available if you need it" rather than the current imperative "inspect it before starting work."
7. git add -A now sweeps arbitrary worktree residue into a remote push, on the hot path
Same mechanism as #2807's commit_working_tree, so the pattern is pre-existing — but #2807 fires on operator restart, whereas this fires on every event respawn with a dirty tree. Anything an agent left in the worktree that isn't gitignored (scratch dumps, logs, a config with a token in a repo whose .gitignore is less thorough than this one's) now gets committed and pushed to origin automatically, with no agent or human intent. GitHub push protection would reject rather than leak, degrading to salvage_error — which is the acceptable outcome, and is also finding #1's contradictory-message case. Worth a line in docs/reference/agent-recovery.md noting that recovery refs may contain un-reviewed working-tree residue.
8. Smaller items
commit.gpgsign: the production_gitclosure (_worktree.py:419-435) setscore.hooksPathandsafe.directorybut notcommit.gpgsign=false. The test harness's_GIT_IDENT(test_kubernetes_spawner.py:3135-3145) does set it — so the suite is immune to a failure mode production isn't. If a worktree inheritscommit.gpgsign=truefrom the clone's config, every snapshot fails and logs a WARNING, losing exactly the work this PR exists to save.agent_salvage._run_githas the same gap; adding-c commit.gpgsign=falsehere is a one-token hardening.dirty_entries=[]on the status-failure path (_worktree.py:456-458):was_dirty=Trueis a conservative unknown, but the downstream WARNING then reportsdiscarded_dirty_entries=0, which reads as "nothing was there." Considern_entries=-1or a separatestate_unknown=Truefield.discarded_commit_countinflation: the count now includes the synthetic snapshot, so an agent with one real commit is told "discarded 2 unpushed commit(s)".test_discard_salvages_tip_and_records_messagepins the new value (2), so this is intentional — but the human-facing sentence would be more accurate as "N commit(s), one of which is an automatic snapshot."git add -Atimeout leavesindex.lock:timeout=120raisingTimeoutExpiredkills git mid-write; the subsequentreset --hardthen fails and the path returnsFalse→ create-with-retry. New failure mode (previously the first git call in this path was a 30sstatus, far less likely to time out on a large tree). Low probability, and the fallback is safe, so noting only.# noqa: BLE001at_worktree.py:752is inert —pyproject.toml:68selects["E", "F", "I", "B", "C4", "UP"], so neitherBLEnorRUF100is on. Harmless.- PR body nit: says the file is 881 lines; it is 899 on
c861f21. Still well under the 1500 hard cap.
Verified as correct
was_dirtylatching before the snapshot genuinely preserves R6 —elif not was_dirtyat_worktree.py:549keepskeep_local=False, so the successor always lands on the origin tip.test_uncommitted_work_is_salvaged_not_destroyedasserts this directly (HEAD == origin_head, clean status,new_module.pyabsent).wip_commitis scoped inside thefor ref in repos:loop, so multi-repo pipelines don't cross-contaminate.- The salvage push happens while the snapshot is still HEAD (
ref=None), and the reworked assertions inTestDirtyDiscardAutoSalvagestill catch a reset-before-push ordering break —rev-parse {wip}^ == orphan_headfails if the observed HEAD were the post-resetorigin_head. Good: the removedhead_at_push["sha"] == orphan_headassertion's invariant is re-established, not dropped. test_preserve_helper_swallows_git_failuresexercises the real helper with a raising git closure rather than mocking it out. Correct shape._preserve_dirty_treeis referenced through the module global, sopatch("kubernetes_spawner._worktree._preserve_dirty_tree")intest_preservation_failure_still_resetsgenuinely intercepts the call site.test_reattach_discard_failure_falls_backstill passes under the new code:subprocess.runraisingOSErrormakesstatusthrow (was_dirty=True),_preserve_dirty_treeswallows its ownOSError, andreset --hardthen returnsFalse.- Docs changes are accurate to the code, including the
safe.directoryrationale.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Addresses review feedback on #3644. Blocking: the bus record told a resuming agent "Nothing was lost" even when the salvage push had failed and the snapshot survived only in the local object store — suppressing the escalation the preceding sentence asked for. The reassurance is now conditional on the push succeeding, the failure branch says the snapshot was NOT pushed, and it no longer points at salvage_agent_commits (which provably cannot see an unreachable sha). Hardening: `git add -A` gains `--ignore-errors` and a non-zero add no longer discards the files that did stage; both git closures pass `commit.gpgsign=false`; the snapshot identity is imported from agent_salvage rather than duplicated; a snapshot-only discard reads as "if any of that work is missing" rather than the imperative reserved for losing the agent's own commits; a status-read failure is reported as `dirty_state_unknown` rather than zero entries. Tests: pin both message branches, the snapshot's commit identity, the `branch is None` skip, the empty-index branch, and partial-add recovery.
Address review feedback on #3644
|
Thanks — the blocking finding was real and the failure scenario was exactly right. All eight items addressed below. Push note: the gateway denies pushes to this PR's head ( 1. "Nothing was lost" on the salvage-failure path — fixed-in-PR (commit 4bebc07)Blocking, and correctly diagnosed. The reassurance is now gated on the push having succeeded, close to your suggested shape:
On the related point — you're right that 2. Partial
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Agent-mode design re-review — approved
Re-reviewed the delta since c861f21 (4bebc07 + merge 4b31f16): _worktree.py, agent_salvage.py, test_kubernetes_spawner.py, and the two doc files. No agent-mode design concerns.
The delta's only agent-facing surface is the _record_discarded_tip bus message a respawned agent reads, and the changes move it in the right direction on both counts this lens cares about:
- Still a pointer, not a payload. The message carries a sha and a ref name and tells the agent to
git fetch origin <ref>and read the diff itself (_worktree.py,recovery_text/wip_textbranches). No snapshot diff, file list, or working-tree content is baked into the context — the agent goes and looks. That is the pattern the guidelines ask for, and the new branching didn't erode it. - Honest state beats reassuring state. The
wip_commit and recovery_refsplit replaces an unconditional "Nothing was lost" with a failure branch that says the snapshot was NOT pushed and asks for escalation, and drops the pointer atsalvage_agent_commits(which cannot see an unreachable sha) in favour of the reflog route. An agent acting confidently on false context is the concrete agent-mode harm; this closes it. Both branches are pinned by assertions intest_salvage_failure_still_resets_and_records_tip/test_discard_salvages_tip_and_records_message.
On my previous finding #6 (message-fatigue from a snapshot-only discard): the fix is wording-only — snapshot_only softens the imperative to "if any of that work is missing … inspect it before re-deriving it" while still emitting the record. Notably it does not add a size or importance heuristic to suppress the snapshot, which would have reintroduced #3639 and quietly decided on the agent's behalf what work matters. Tuning the ask and leaving the judgment with the agent is the right call here.
Nothing else in the delta touches agent-mode surface: no pre-fetched context, no structured output where a human reads it, no post-processing of agent output, no prompt-level substitute for a sandbox constraint, no direct Anthropic API calls, and no hardcoded model identifiers (the -c commit.gpgsign=false / --ignore-errors hardening and the _SALVAGE_COMMIT_NAME import are plain git plumbing).
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of PR #3644 — delta since c861f21
Reviewed git log c861f21..HEAD --not origin/main -p (5 files: _worktree.py, agent_salvage.py, test_kubernetes_spawner.py, on-demand-agent-lifecycle.md, agent-recovery.md).
Verdict: no blocking issues in the delta. The blocking finding from the previous round is genuinely fixed, not superficially patched — I traced the full message-body composition for every branch combination rather than reading the diff for the presence of the words. All eight non-blocking items were addressed, one with a reasoned decline I agree with. Nine non-blocking findings follow.
Previous blocking finding — properly fixed
The salvage-failure + snapshot case previously emitted a self-contradicting body: the count line asserted the snapshot was preserved while the recovery line said salvage failed. _record_discarded_tip (_worktree.py:857-921) now composes wip_text from two mutually exclusive branches keyed on recovery_ref, and the failure branch replaces the misleading salvage_agent_commits pointer with the honest reflog route. I verified that pointer is now accurate: the second reset --hard origin/{branch} writes a HEAD/branch reflog entry, and the worktree survives the re-attach path, so git reflog really can reach the orphaned tip. test_salvage_failure_still_resets_and_records_tip pins both the absence of "nothing was lost" (case-insensitively) and the presence of the escalation text — a real regression guard, not a smoke assertion.
Previous non-blocking items — all addressed
| # | Item | Status |
|---|---|---|
| 1 | Contradictory salvage-failure body | Fixed; both branches asserted |
| 2 | git add -A aborts on unindexable entry |
Fixed (--ignore-errors + non-fatal add); test_partial_add_failure_still_commits_what_was_staged fails against the pre-fix code — I checked the old path returned None here |
| 3 | Empty-index log misattributed the cause | Fixed; wording generalised, new fake-git test drives the branch |
| 4 | Detached-HEAD skip untested | Fixed; test_no_branch_takes_no_snapshot asserts HEAD unmoved, tree clean, no push, no bus message |
| 5 | Duplicated identity literals | Fixed; bound from agent_salvage at import. No circular import — agent_salvage pulls only stdlib + egg_logging, and kubernetes_spawner/__init__.py:39 already imports it |
| 6 | Over-strong wording for trivial dirt | Softened (see N1) |
| 7 | Recovery ref may hold un-reviewed residue | Doc paragraph added (see N5) |
| 8 | gpgsign, dirty_state_unknown, count wording, inert noqa |
All done. The index.lock-after-timeout item was declined with sound reasoning — the create-with-retry path covers it |
Also verified: ruff check and ruff format --check clean on all three Python files; no consumer outside the test file greps the message body or the dirty_discard_salvage metadata; _worktree.py is 976 lines / 42,785 bytes — over the 800-line soft cap (warning only), well under the 1500 hard cap.
N1 — snapshot_only cannot distinguish the #3639 incident from one stray file
_worktree.py:862:
snapshot_only = bool(wip_commit) and wip_commit == discarded_tip and n_commits == 1The wip_commit == discarded_tip clause is always true when wip_commit is set (the snapshot is the tip by construction), so this reduces to wip_commit and n_commits == 1. That predicate is orthogonal to the signal it is standing in for. A worktree dirtied by one stray brc-memory-<pipeline-id>.md and the #3639 incident itself — 110 minutes, 33 modified files, zero commits — are both snapshot_only. The PR's headline failure case therefore gets the softened wording.
Two consequences:
- "if any of that work is missing" conditions the recovery action on a predicate the reader provably cannot evaluate. Memory-lessness of the resuming agent is this function's own stated premise (#3509) — it has no baseline against which "missing" means anything.
- The soft branch drops "If it contains completed work, build on it (cherry-pick or reset) instead of re-deriving it" — the only actionable instruction in the multi-commit branch.
The real discriminator already exists four frames up: _preserve_dirty_tree computes len(staged.splitlines()) and logs it as preserved_files (_worktree.py:806). Thread it into _record_discarded_tip and soften only below a small threshold (1–2 files), or drop the conditional and state the fact unconditionally — "the snapshot on {ref} contains N file(s) of uncommitted work; inspect it before re-deriving" is accurate for both ends of the range and needs no guess.
N2 — Untested cell: snapshot-only and salvage failed
The two new conditionals form a 2×2 over (recovery_ref × snapshot_only). Three cells are covered; the missing one is recovery_ref is None + snapshot_only is True — the #3639 incident occurring during a gateway outage, i.e. the worst case this PR exists to handle. test_salvage_failure_still_resets_and_records_tip uses _seed_orphan(dirty=True) (one real commit + dirt ⇒ n_commits == 2), so it pins the multi-commit failure branch only. A _seed_dirty variant with the push stubbed to fail would cover it and is the case most worth an assertion.
N3 — agent_salvage.commit_working_tree still has the exact defect fixed here
agent_salvage.py:624:
add = _run_git("add", "-A", cwd=worktree.repo_path, check=False)
if add.returncode != 0:
logger.warning("Salvage: git add -A failed; skipping uncommitted capture", ...)
return NoneNo --ignore-errors, and a non-zero exit discards everything — the identical partial-index failure mode the previous review flagged for _worktree.py, on the sibling snapshot path (#2807, restart-side). This PR does modify agent_salvage.py, and extended the commit.gpgsign hardening into it on the stated grounds that it has the same gap and the same consequence. That parity argument applies verbatim here and is ~4 lines. Non-blocking only for consistency with how the same defect was classified last round.
N4 — commit.gpgsign=false has zero test coverage in either location
_GIT_IDENT (test_kubernetes_spawner.py:3142) passes -c commit.gpgsign=false on each test-helper invocation, so it is never written into the seeded repo's config. The production closure runs against a repo where the setting is simply absent — the suite passes identically whether or not the hardening exists. Reverting either -c commit.gpgsign=false breaks no test. One line in _seed_dirty (_git(repo, "config", "commit.gpgsign", "true")) makes test_uncommitted_work_is_salvaged_not_destroyed a real guard.
N5 — Unconditional push-protection claim in agent-recovery.md
"(A snapshot containing a secret is rejected by GitHub push protection rather than leaked; the push then fails and the discard is recorded with
salvage_errorset instead of a recovery ref.)"
Push protection is opt-in per repo/org and, for private repos, gated behind GitHub Advanced Security. grep -rn "push protection\|push_protection\|secret scanning" across *.md / *.py / *.yaml returns nothing but this new line — no config, no docs, no evidence it is enabled. Stated unconditionally in a reference doc, this reads as the control that makes git add -A sweeping a whole worktree safe. It is not; the "review the diff before replaying" advice in the same paragraph is. Scope it ("when push protection is enabled…") or drop the parenthetical.
N6 — Smaller items
- Log-key drift. The new add-failure warning uses
dirty_entries=(_worktree.py:761) while its siblings in the same helper usediscarded_dirty_entries=(:772,:795) andpreserved_dirty_entries=(:805). Three keys for one quantity inside one function makes log queries awkward — pick one. dirty_state_unknownisn't on every WARNING. The success WARNING (_worktree.py:801-808) is the one that omits it, contradicting the "surfaced on every WARNING" framing. It carriespreserved_files, so no signal is lost — just inconsistent.- Tautological assertions. In
test_snapshot_commit_identity_matches_the_restart_path, the two constant-equality asserts can no longer fail now that the constants are=-bound from the import. The load-bearing half is the%an|%ae|%sassertion on the real commit; the first half is documentation. Fine to keep — worth knowing the drift guard is structural, not test-enforced. - Add-timeout misattribution survives. A
TimeoutExpiredfromaddleaves the index untouched, so the helper falls through tonot stagedand logs "held no committable change (ignored files, or submodule-only dirt)" — a third cause the generalised wording still doesn't name. The preceding add-failure WARNING carries the real reason, so this is cosmetic; "…, or a failed add" closes it.
Sandbox caveat. git init is gateway-blocked in this container, so the real-git cases in TestDirtyTreePreservedBeforeReset error in _make_worktree before reaching production code — same limitation the producer reported. I ran the three fake-git tests (3 passed) and ruff (clean); the real-git assertions above are verified by reading, and CI is the ground truth for them.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The re-attach discard message told every agent with a snapshot to fetch the ref and inspect it before starting work. That is right for the #3639 shape (33 files, 110 minutes of work) and wrong for the common case: a single stray brc-memory-<pipeline>.md, written into the worktree on every brc_ack/brc_nack, which lands there whenever an agent did not commit it. Thread the staged file count out of _preserve_dirty_tree via a _DirtySnapshot NamedTuple and let _record_discarded_tip pick wording from it. At or below _SNAPSHOT_SOFT_MAX_FILES (1) the message states the count and notes the ref is as likely to hold a leftover state file as work; above it the imperative ask and the cherry-pick instruction stay. The count only tunes wording -- the snapshot is still always taken, so no size heuristic can reintroduce #3639. Also: agent_salvage.commit_working_tree no longer returns None when git add -A reports errors (that discarded the other N-1 files it exists to capture); the three dirty-tree warnings now agree on a dirty_entries= log key; and the recovery doc no longer implies push protection is enabled -- nothing in this repo turns it on. Tests: a git-free TestDiscardedTipMessageWording class pins the full wording matrix (6 cases), plus real-git coverage for the single-file softening, the snapshot-only salvage-failure escalation, and the partial add in the salvage path.
Re-review response — N1–N6All nine items addressed in commit
N1 —
|
This comment has been minimized.
This comment has been minimized.
Scale the discarded-tip recovery ask to the snapshot's size
There was a problem hiding this comment.
Agent-mode design re-review of PR #3644 — delta since 4b31f16
Reviewed git log 4b31f16..HEAD --not origin/main -p (commits 89ab06a + the #3652 merge; 5 files). Scope is agent-mode design alignment only — the base bot covers correctness, style, and coverage.
Verdict: approve. No blocking agent-mode concerns. One advisory below.
The delta lands on agent-mode surface, so I checked the thing I flagged last round
My previous approval called out specifically that the round-1 softening "does not add a size or importance heuristic to suppress the snapshot, which would have reintroduced #3639 and quietly decided on the agent's behalf what work matters." 89ab06a introduces exactly such a heuristic (_SNAPSHOT_SOFT_MAX_FILES = 1, _worktree.py:698), so I traced the call path rather than taking the PR comment's assurance at face value.
It holds. _preserve_dirty_tree is still called unconditionally on was_dirty and branch (_worktree.py:504-513); wip_files is read off the returned _DirtySnapshot after the commit exists and is threaded only into _record_discarded_tip's prose. No threshold sits between the dirty tree and the snapshot, and none gates whether the record is emitted. REFUTED as a suppression risk — the count selects wording, nothing else.
Why the new wording is better agent-mode design, not just different
The bus body a resuming agent reads is the orientation surface here, and both new branches (:914-930) keep the shape that matters: a pointer plus a size fact, never a payload. The agent gets git fetch origin {recovery_ref} and a file count, and goes and reads the diff itself — no snapshot content is baked into the message. That is the right side of the pre-fetching line.
Two specific improvements:
- Replacing "if any of that work is missing" with a stated count closes a real defect. That question asked a memory-less agent to evaluate a baseline it provably does not have — this function's own premise (#3509) is that the agent has no memory of the discarded session. A fact it can act on beats a question it cannot answer.
wip_files is Nonefalling through to the substantial branch (trivial_snapshotrequireswip_files is not None) makes the soft wording opt-in on evidence rather than the default under uncertainty. Correct default for a message whose failure mode is an agent silently re-deriving lost work.
The agent_salvage.py --ignore-errors change and the agent-recovery.md push-protection correction carry no agent-mode surface — plain git plumbing and an operator-facing accuracy fix. No pre-fetched context, no structured output where a human or agent reads prose, no post-processing of agent output, no prompt-level substitute for a sandbox constraint, no direct Anthropic API calls, no hardcoded model identifiers anywhere in the delta (grepped).
Advisory (non-blocking) — the count is a lossy proxy for a discriminator you already have in hand
_worktree.py:792 computes staged as git diff --cached --name-only — the actual filenames. :827 then reduces it to len(staged.splitlines()) and the names are gone.
The threshold's documented justification is one specific file: "One file is the brc-memory-<pipeline>.md case: written into the worktree on every brc_ack/brc_nack" (:692-696). But n_files <= 1 also matches an agent that spent its session on a single substantial source file, and that agent is then told the ref "is as likely to be a leftover state file as work" — the one framing that could talk it out of fetching real work. Rate this PLAUSIBLE, not confirmed: the count and the fetch command are both still in the body, so the agent retains the evidence to overrule the hint. That is why it is advisory and not blocking.
Since you already know the noise source by name, matching on it is strictly sharper than counting: soften when the staged set is exactly brc-memory-*.md (or a small allowlist of machine-written state files), and keep the imperative for a one-file capture that is anything else. Threading staged alongside n_files on _DirtySnapshot is a one-field change, and it also lets the body name the file — "the snapshot holds only brc-memory-<id>.md" orients far better than "1 file(s)" plus a probabilistic hedge.
Reasonable to defer — this is a refinement of an already-correct design, not a fix.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — delta 4b31f16..37f57b5 (89ab06a + merge)
Reviewed 338 lines across 5 files. No blocking issues. All six prior findings (N1–N6) are genuinely fixed rather than papered over — I traced each to the code, not the changelog. Nine non-blocking findings below, ordered by how much they'd cost.
Prior findings — verification
| # | Prior concern | Status |
|---|---|---|
| N1 | Add failure discards the whole snapshot | Fixed properly. _worktree.py:773 demotes the git add failure to a WARNING and falls through; diff --cached --name-only (785) is now the real gate. Correctly grounded in git-add(1): --ignore-errors skips the unindexable entry but still exits non-zero with a partially populated index — so a non-zero add genuinely does not imply an empty index. |
| N2 | Missing 2×2 cell (snapshot-only × salvage failure) | Fixed. Both the real-git test_snapshot_only_salvage_failure_escalates and the git-free test_snapshot_only_push_failure_escalates now cover it. |
| N3 | agent_salvage.commit_working_tree had the same early-return None |
Fixed. The return None after the add warning is gone (agent_salvage.py:624-637). Parity restored — see finding 5 for the part that wasn't mirrored. |
| N4 | Tests didn't prove commit.gpgsign=false was load-bearing |
Fixed. _seed_dirty sets commit.gpgsign true in the fixture repo, so dropping the production -c commit.gpgsign=false makes the snapshot commit fail on any runner without a signing key. Real guard, not a comment. |
| N5 | agent-recovery.md implied push protection was on |
Fixed. Now states plainly that nothing in this repo enables it, with the enabled-elsewhere case relegated to a parenthetical. |
| N6 | Empty-index warning misattributed the cause | Fixed in _worktree.py:787-796 — "ignored files, submodule-only dirt, or a failed add". |
| — | Tautological assertions | Declined; I'd already called them fine to keep. No disagreement. |
I also checked the one thing that could have regressed silently: dropping wip_commit == discarded_tip from snapshot_only (_worktree.py:906). It is behaviour-preserving — _preserve_dirty_tree moves HEAD onto the snapshot, the bare reset --hard targets HEAD, so discarded_tip == wip_commit by construction, and keep_local can't be true on this path (was_dirty is set and a fresh commit can't equal remote_tip). Fine.
Log-key rename (discarded_dirty_entries/preserved_dirty_entries → dirty_entries): repo-wide grep over *.py/*.md/*.yaml/*.json finds no consumer outside _worktree.py. No dashboard or alert breaks.
Findings (non-blocking)
1. The size-scaled softening is only half-wired — the sentence the reader acts on still carries the imperative.
_record_discarded_tip gates recovery_text on trivial_snapshot, but wip_text (:947-953) and count_text (:964-970) are gated only on bool(wip_commit). Rendering the one-file case end to end:
Worktree re-attach discarded 1 unpushed commit(s) (an automatic snapshot of uncommitted work) … The snapshot holds 1 file(s) of uncommitted work and is preserved on remote ref …; run
git fetch …to read it if you need it — at that size it is as likely to be a leftover state file as work. Commit aaaa1111 is an AUTOMATIC snapshot of the uncommitted changes your previous session left behind (#3639); it is on the recovery ref above, so nothing was lost. Treat it as a WIP checkpoint to review, not as work you already proposed.
Three restatements of "this is a snapshot of uncommitted work," and the last sentence — the one nearest the reader's next action — is an imperative to review it. That is precisely the brc-memory-<pipeline>.md noise _SNAPSHOT_SOFT_MAX_FILES was added to suppress. The softening does not survive its own message body.
The tests don't catch this because test_single_file_snapshot_is_softened asserts only on the recovery_text fragments ("leftover state file" in body, "inspect it before starting work" not in body) — never on wip_text. A "Treat it as a WIP checkpoint" not in body assertion would fail today.
Suggest routing trivial_snapshot into wip_text too (e.g. collapse it to a bare "Commit <sha> is the snapshot; it is on the recovery ref above."), and dropping the parenthetical from count_text when trivial — recovery_text already says it.
2. wip_files is stated in the body but absent from metadata.
:990-1003 carries wip_commit but not wip_files. The body now makes a size claim; anything consuming this message structurally (or a future triage query "show me discards over N files") has to regex the prose to recover it. One-line addition, and it's the field the new wording branch turns on.
3. wip_files is None is unreachable in production, and the fallback string reads worse than the branch it guards.
wip_commit and wip_files are assigned together off the _DirtySnapshot NamedTuple (:489), so wip_commit truthy ⟺ wip_files non-None. Both None arms are dead: trivial_snapshot's wip_files is not None check, and snapshot_size's "uncommitted work" fallback — which, since snapshot_size is only ever interpolated inside snapshot_only branches, would render "The snapshot holds uncommitted work and is preserved on…". test_unknown_file_count_is_treated_as_substantial is therefore a defensive-only pin, not a behaviour test. Fine to keep the defaulting; worth a comment saying it's defensive so a later reader doesn't hunt for the production caller.
4. File count is a weak proxy for triviality.
A single 400-line module rewritten in the crash window scores identically to a stray brc-memory-*.md, and the agent is told it is "as likely to be a leftover state file as work." The false negative here is the exact #3639 failure mode, just one file wide. git diff --cached --shortstat (already one git call away, right beside the existing --name-only) would let the threshold key on churn as well as count — e.g. soften only when 1 file and under some line delta. The rationale comment defends the choice honestly, so this is a design suggestion, not a defect.
I did verify the stated premise: .egg-state/ is tracked wholesale (git ls-files .egg-state → 944 files, agent-outputs/ included; .gitignore:63-84 excludes only last-known-good/, selection/, grimp-cache/, and specific oversight/ paths). So brc-memory-*.md really does land in git add -A, and the _SNAPSHOT_SOFT_MAX_FILES = 1 motivation is real.
5. N6's fix was not mirrored into agent_salvage.commit_working_tree — the sibling still misattributes a total add failure.
_worktree.py gained an explicit empty-index guard whose message names "a failed add" as a cause. agent_salvage.py:624-655 got the non-fatal add (N3) but no equivalent guard: when the add fails completely, the index stays empty, git commit returns non-zero, and the operator gets
Salvage: commit of uncommitted working tree failed— stderr: "nothing added to commit"
which points at the commit when the cause was the add. The preceding WARNING does let a careful operator correlate the two, which is why this is advisory rather than blocking. Since N3 established these two paths should behave alike, the N6 half is worth carrying across too.
6. test_commit_working_tree_survives_a_partial_add never exercises partial staging.
_flaky_run_git runs the real git add (which succeeds and stages everything) and then forces result.returncode = 1. So it pins "a non-zero add doesn't abort the commit" — which is the behaviour change, fair — but not the thing the code comment claims to handle: an index holding N-1 of N files. The real-git counterpart in test_kubernetes_spawner.py has the same shape. Staging a fifo or an unreadable file would make it genuine; if that's too environment-dependent for CI, the docstring should say the partial-index case is asserted only at the unit level.
7. docs/architecture/on-demand-agent-lifecycle.md:186-203 isn't updated for the size-scaled wording.
Only docs/reference/agent-recovery.md changed in this delta. That lifecycle paragraph is detailed enough to document the no-branch skip, the WARNING-with-file-count, and the salvage-failure escalation wording — so the new "the ask scales with snapshot size" rule is a conspicuous omission at that level of granularity. One sentence.
8. A partial snapshot is indistinguishable from a complete one downstream.
When the add partially fails, the resulting commit carries the same _WIP_COMMIT_MESSAGE and lands on the same egg/recovered/... ref as a complete capture. The only signal that files are missing is the WARNING in orchestrator logs — which the resuming agent does not read, and which is the one audience the bus message exists to serve. "Partial beats nothing" is right (I confirmed these commits can only ever reach a recovery ref via salvage_worktree, never the agent's real branch, so there's no corruption risk), but an agent that cherry-picks a silently-truncated snapshot has a worse failure mode than one told it's truncated. Appending a line to the commit message on the degraded path, or threading a partial flag into _DirtySnapshot → metadata, closes it.
9. Readability: the identity comment is fused to the constant block it doesn't describe.
_worktree.py:686-698 runs the import kubernetes_spawner as _pkg rebinding note straight into the _SNAPSHOT_SOFT_MAX_FILES rationale with no blank line, so the first paragraph reads as a preamble to a constant it has nothing to do with. Blank line between them.
Verification notes
ruff check and ruff format --check pass on all four changed Python files.
Targeted tests I could run here: TestDiscardedTipMessageWording (6 passed) and the three fake-git cases test_no_committable_change_takes_no_snapshot, test_partial_add_failure_still_commits_what_was_staged, test_preserve_helper_swallows_git_failures (3 passed).
Sandbox caveat, not a PR defect: the 9 real-git tests in this area cannot run in the review container — _make_worktree calls git init (test_kubernetes_spawner.py:3170), which gateway policy blocks, so all 9 fail identically with CalledProcessError. This container also has no .venv, so the runs above used system python3 3.14.6 / pytest 9.1.1 rather than the pinned venv. CI is ground truth for those assertions.
One false alarm I chased and dropped: except OSError, subprocess.SubprocessError: in agent_salvage.py (389, 409, 598) parses fine — PEP 758 allows unparenthesized except tuples on 3.14, and it's pre-existing on origin/main. Not a finding.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Replaces the file-count softening threshold with name-based matching of orchestrator-written state files, marks truncated snapshots as INCOMPLETE end-to-end, and distinguishes add-failure from commit-failure on the #2807 salvage path.
Round-3 review responseAll ten items fixed in-PR. Nothing deferred, nothing disagreed. Pushed as The agent-mode advisory and base-bot finding 4 turned out to be the same change, so they landed together:
Per-item detail1. 2. 3. Kept the defaulting, added the comment you asked for: the 4 + A. See above. I went with name-matching over your 5. 6. Took the docstring route you offered. Both 7. Added to the step-2 re-attach paragraph: what the softening keys on, the 8. 9. Blank line added between the Verification
Tests I could run here: Same sandbox caveat you hit: — Authored by egg |
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.
Never let a filename cost the working tree
There was a problem hiding this comment.
Agent-mode design re-review of PR #3644 — delta since 11f5f50
Reviewed git log 11f5f50..HEAD --not origin/main -p (commits 1d2e364, ad4f0b6, and the #3656 merge; 5 files). Scope is agent-mode design alignment only — the base bot covers correctness, coverage, and style.
Verdict: approve. My R6 advisory is fully addressed, and the delta improves the agent-facing surface further than I asked. One non-blocking wording nit below — a free one-liner, not a design change.
My prior advisory is closed, and the fix went past what I proposed
I flagged that _MACHINE_STATE_FILE_GLOBS' stated membership rule ("written by orchestrator code") was false for its dominant member, and that the falsehood was asserted to the resuming agent as ground truth: "orchestrator-written state, rewritten mechanically, not agent work" — about a file whose prose is the reviewer's own. Both halves are fixed, and I traced each to code rather than to the changelog:
-
The rule now matches the entry.
_worktree.py:733-772states the test as "mechanically regenerated on the next event, with a durable backstop elsewhere", names the real writer (sandbox/egg_agent_tools/handlers/brc_memory.py::write_memory_atomic, "reached from the agent's ownbrc_ack/brc_nacktool call, so the prose in it is the reviewer's"), and citesdocs/architecture/brc-memory.mdfor the backstop.wontdo.json/tester-output.jsonstay excluded on a stated reason — "agent output with no regeneration path" — closing the forward risk I raised about the tuple's extension point._is_machine_state_only's docstring (:1008-1013) andon-demand-agent-lifecycle.mdcarry the same correction. -
The agent-facing sentence dropped the false clause — and then
ad4f0b6caught a second, subtler one in its own R3 fix. "rebuilt on your next event" promised the agent it would find the file waiting; it now reads "rewritten by the step that produces it rather than restored before you start, and durably recorded elsewhere" (:1242-1247), with the reasoning at:1234-1241correctly noting that brc-memory is rewritten by the agent's own ack/nack — i.e. after it has already redone the review. That is the accurate framing, and self-catching it is the right instinct on this lens: the failure mode here is an agent acting confidently on a comforting premise that does not hold.
The errors="replace" change degrades in the safe direction
B1's fix could have gone the other way. It doesn't: a U+FFFD-replaced name "still fails every glob in _path_matches_glob, so it can only cost the softened wording, never grant it" (:284-288), and the widened except Exception on the metadata read keeps the snapshot rather than letting a slow diff --cached cost the working tree. Missing or degraded evidence continues to fall through to the imperative "inspect it before starting work" — softening stays opt-in on evidence, which is the property I checked in each prior round and it still holds.
Rest of the checklist is clean: no prompt assembly, no pre-fetched payload (the body is still a pointer — a sha, a ref, and git fetch origin <ref>, never the snapshot's contents), no structured output where prose is read, no post-processing of agent output, no prompt-level substitute for a sandbox constraint, no direct Anthropic API calls, no hardcoded model identifiers.
Non-blocking — the retired "reported errors" wording survives in the one channel the agent reads
ad4f0b6 retired "git add -A reported errors" everywhere it could not be substantiated, for the reason spelled out at _worktree.py:805-811:
the caller's
except Exceptionsetspartialfor aTimeoutExpiredat 120s as well as for a non-zero exit, and a commit message that asserts git's exit status when the add never returned one is a claim a triager cannot check.
That is right, and the commit applied it to _WIP_COMMIT_PARTIAL_SUFFIX (:825), _UNCOMMITTED_SALVAGE_PARTIAL_SUFFIX (agent_salvage.py:114), and the WARNING at :924-928. The bus body did not get it — _worktree.py:1302 still tells the resuming agent:
WARNING: \git add -A` reported errors while taking this snapshot, so it may be INCOMPLETE …`
The add at :925 runs with --ignore-errors under except Exception, so a 120s TimeoutExpired reaches this text with no git exit status behind it. Same class as the provenance claim I raised last round — an agent-facing assertion the code cannot back — and the commit's own principle applies with more force here than to the commit message, since the bus record is the channel written for the agent. (On the agent_salvage.py side the older phrasing at :654 / :681 is accurate: that path runs the add with check=False and partial really is returncode != 0, as the NB-6 comment itself notes. No change wanted there.)
Advisory rather than blocking because it costs nothing operationally: either wording lands the agent on "may be INCOMPLETE — review it", which is the same action. And it is a free fix — the tests assert "INCOMPLETE" in body (test_kubernetes_spawner.py:4956, :4981), never the phrase, so nothing needs updating alongside it. Two internal comments in the same function have the same drift if you're touching it: :1174 and :1198.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of the delta since 11f5f50
Scope: 11f5f503..HEAD --not origin/main — 1d2e364a8 (B1, N1–N5) and ad4f0b6d0 (r4). Every prior-round finding is genuinely fixed, not papered over:
- B1 (
-zdecode crash) —_worktree.py:986-1005now wraps the staged read inexcept Exception, and_worktree.py:1006-1012only skips the commit on a known-empty index (staged is not None and not staged). Parametrised overTimeoutExpired/CalledProcessError/RuntimeErrorintest_any_failed_staged_path_read_keeps_the_commit. Correct: the read is metadata, the commit is the point. - N1 (provenance claim) — the membership rule is now "mechanically regenerated on the next event, with a durable backstop elsewhere" and it is propagated consistently to
_MACHINE_STATE_FILE_GLOBS(_worktree.py:750-772),_is_machine_state_only, the bus prose, andon-demand-agent-lifecycle.md. Thebrc-memoryentry now names its actual writer (sandbox/egg_agent_tools/handlers/brc_memory.py) and its backstop. - N3/N4 —
test_partial_suffixes_share_one_grep_tokennow pins the runbook and therefs/heads/egg/recovered/*fetch refspec. Good catch on--allneeding the fetch first. - N5 —
wip_files/wip_partial/wip_files_unknownon the discard WARNING, withpreserved_filesasserted absent.
I also verified the two empirical claims the design rests on, with real git (2.34.1) in a scratch repo:
core.quotePath=truedoes protectstatus --porcelain,clean -fdn, andcommitstdout (?? "caf\351.txt"vs rawcaf\xe9). The NB-3 pin is load-bearing, not decorative — it protects theclean -fdthat runs after the snapshot commit and before the salvage push.git diff --cached --name-only -zemits raw bytes even underquotePath=true(7375 622f 6361 66e9 …), anderrors="replace"never synthesises or swallows a NUL separator (checked truncated 2/3/4-byte leads, lone surrogates, and out-of-range leads — NUL count preserved in every case). So the "replacement is byte-local, replacing across the whole stream equals per-path decoding" claim at_worktree.py:964-967holds.
One blocking finding below.
Blocking
B1 — the NB-3 quotePath pin does not cover git add stderr, so commit_working_tree still loses the whole crashed-agent tree on one bad filename
orchestrator/agent_salvage.py:254-259 and :726-728 both assert that the pin closes the undecodable-filename hazard on the #2807 path:
core.quotePath=trueis pinned rather than inherited (#3639 re-review NB-3). Every call here decodes withtext=Trueand noerrors=… The default C-quote-encodes those bytes to ASCII and is what keeps that from happening
Unreachable on this path today — it has no
-zcall and the defaultcore.quotePathkeeps git's output ASCII
That is not true of git add. Two of its stderr messages echo the path raw, unaffected by core.quotePath. Reproduced:
$ git -c core.quotePath=true add -A --ignore-errors # unreadable file named caf\xe9.txt
error: open("caf\351.txt"): Permission denied # quoted
error: unable to index file 'caf\xe9.txt' # RAW byte 0xE9
$ git -c core.quotePath=true add -A --ignore-errors # nested repo named nested-caf\xe9
error: 'nested-caf\xe9/' does not have a commit checked out # RAW byte 0xE9
and end-to-end through the exact call shape of _run_git:
$ python3 -c "subprocess.run([...,'-c','core.quotePath=true','-C','.','add','-A','--ignore-errors'],
capture_output=True, text=True, check=False, timeout=30)"
RAISED UnicodeDecodeError 'utf-8' codec can't decode byte 0xe9 in position 16: invalid continuation byte
Failure scenario. A crashed agent's worktree contains a latin-1-named file — an extracted zip (CP437/latin-1 names are the common source), a fixture written with raw bytes, or a nested repo the agent cloned — plus hours of uncommitted work. commit_working_tree calls _run_git("add", "-A", "--ignore-errors", …) at agent_salvage.py:670. subprocess.run raises UnicodeDecodeError while decoding stderr, before _run_git returns. The newly widened except Exception at :731 catches it, logs Salvage: capturing uncommitted working tree raised; continuing, and returns None. salvage_worktree then pushes only the committed-but-unpushed work, and the gateway's _reset_reused_worktree_to_safe_ref destroys the tree.
That is #3639's exact loss, on the sibling path, from the exact input class this PR just fixed on the re-attach path — and it is the path the PR itself describes as the worse of the two ("no bus record at all, so the commit message is the only channel a triager gets", agent_salvage.py:91-94).
I'm flagging this as blocking rather than pre-existing because the PR modifies this function, widens this handler, adds the quotePath pin as the fix for this hazard, and writes a comment asserting the hazard is unreachable. A future maintainer reading :726-728 has no reason to look again. Per the pre-existing-defect rule the diff materially amplifies it: before this PR the same input raised out of commit_working_tree; now it is swallowed into a WARNING that reads like a hostile worktree rather than lost work.
Fix is one keyword and free here — this path has no -z read, so replacement can only affect names that would otherwise crash:
return subprocess.run(
cmd,
capture_output=True,
text=True,
errors="replace",
check=check,
timeout=timeout,
)Keep the quotePath pin — it is still correct for the commit/status/diff calls and its docstring reasoning is sound there. Just narrow the claim at :254-259 and :726-728 from "keeps git's output ASCII" to "keeps most of it ASCII; git add error text is not covered, which is why the decode is non-strict", and add a test that drives a real add failure on a non-UTF-8 name (the chmod 000 seed above is a two-line fixture) — the current suite has no coverage of commit_working_tree against a hostile filename at all.
Non-blocking
NB-1 — NB-6's rewording missed the one surface the resuming agent actually reads
The rationale for did not complete cleanly over reported errors is stated at _worktree.py:805-808: on the re-attach path partial is set by except Exception around a check=True add with timeout=120 (:925-931), so a TimeoutExpired — or the UnicodeDecodeError in B1 — sets partial with git never having reported an exit status. Correct, and correctly applied to _WIP_COMMIT_PARTIAL_SUFFIX, the WARNING at :929, the _DirtySnapshot docstring at :840, and both docs.
But three sites still assert the falsified claim, and the worst of them is the bus message body:
_worktree.py:1302—" WARNING: \git add -A` reported errors while taking this snapshot, so "`_worktree.py:1174—_record_discarded_tipdocstring_worktree.py:1198— the soft-branch disqualification comment
:1302 is the text the resuming agent reads and acts on. It tells the agent git reported errors when what actually happened may be that a 120-second timeout expired — which points triage at git's exit status instead of at node contention or staged-set size. agent_salvage.py:88 / :654 / :681 are fine as-is: there partial = add.returncode != 0 under check=False, so "reported errors" is literally true.
NB-2 — "a replaced name still fails every glob" is false, in four places
_worktree.py:969-971:
A replaced name still fails every glob in :func:
_path_matches_glob, so it can only cost the softened wording, never grant it.
Repeated verbatim in test_undecodable_bytes_cost_one_name_not_the_path_set's docstring, paraphrased in on-demand-agent-lifecycle.md:240 ("a U+FFFD in wip_paths, which matches no softening glob"), and asserted as the reason for a passing assertion at test_undecodable_filename_is_salvaged_end_to_end ("A replaced name matches no softening glob, so the record still takes the imperative").
Replicating _path_matches_glob exactly:
path : '.egg-state/agent-outputs/coder/brc-memory-caf�.md'
(from b'…brc-memory-caf\xe9.md'.decode('utf-8', errors='replace'))
'.egg-state/agent-outputs/*/brc-memory*.md' -> True
_is_machine_state_only(...) -> True
The * in brc-memory*.md swallows the U+FFFD; the segment count is unchanged because / (0x2F) is ASCII and never part of an invalid sequence.
The safety property survives, but on different reasoning than the one written down: invalid UTF-8 bytes are all ≥ 0x80, U+FFFD is also non-ASCII, and every glob literal is ASCII — so replacement can neither create nor destroy a match in a literal region, and * regions are length-agnostic. A replaced path matches iff the raw path would. That is a stronger and true statement; it is worth having in the comment instead.
Two knock-ons: the end-to-end test's comment claims its assertion proves something the test cannot exercise (wip_machine_state_only is False there is forced by seed.txt and new_module.py, not by the replaced name), and this PR's whole review history has been about the accuracy of exactly this class of claim — N1 was the same shape.
NB-3 — the same undecodable stderr can mislabel a complete re-attach snapshot as INCOMPLETE
Secondary to B1 and much less costly: _worktree.py:925 runs the same add -A --ignore-errors with errors unset, so raw-byte stderr raises inside run and lands in except Exception as add_error → partial = True. In the two reproductions above rc != 0 anyway so the label is honest, but a warning-only add (rc 0) that echoes a raw path would stamp INCOMPLETE: on a complete snapshot and disqualify the soft branch. Same one-word fix: pass errors="replace" on the add call. The _git closure already threads the parameter (:421-465), so this is free.
NB-4 — minor comment inaccuracy in the pin test
test_partial_suffixes_share_one_grep_token: "--all is local refs only and both snapshot paths push to origin". --all includes refs/remotes/, which is precisely why the fetch refspec makes the runbook work. The assertion and the runbook are both right; only the stated reason is off. Suggest: "a fresh clone has no ref under refs/remotes/origin/egg/recovered/ until the namespace is fetched, so --all sees nothing".
What I did not do
Per the review instructions I did not run make test; CI covers the suite on this HEAD. All verification above was read-only — scratch repos under /tmp, removed afterwards; the working tree was not mutated.
Requesting changes on B1 only. Everything else is advisory and would ride fine in a follow-up, though NB-1's :1302 is a two-line change on an agent-facing string and is worth taking in this round.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The core.quotePath pin does not cover git add's stderr: two of its messages echo the path raw, so a non-UTF-8 filename raised UnicodeDecodeError inside subprocess.run and commit_working_tree swallowed the whole working tree into a WARNING. _run_git now decodes with errors=replace; the re-attach path's add gets the same treatment so an undecodable name cannot mislabel a complete snapshot INCOMPLETE. Also corrects three claims the reviewer falsified: the partial suffix's 'reported errors' wording on the bus body and two comments, the 'a replaced name fails every glob' reasoning (replacement is match-neutral because the globs are ASCII), and the --all/refs-remotes note in test_partial_suffixes_share_one_grep_token.
Round-9 review responseAll five items fixed. Pushed as B1 —
|
This comment has been minimized.
This comment has been minimized.
Do not let git add's raw stderr cost the crashed agent's working tree
There was a problem hiding this comment.
Agent-mode design re-review (R10) — no concerns
Delta reviewed: 20829733..eb16af21, which is one authored commit — 35a5e59 "Decode git output non-strictly on the #2807 salvage path (#3639 r9)" — plus its merge. 195 added lines across agent_salvage.py, _worktree.py, two test modules, and on-demand-agent-lifecycle.md.
Nothing in this delta touches agent-mode design surface. It is a subprocess decode fix (errors="replace" on two git add call sites whose stderr echoes raw paths past core.quotePath), plus comment/docstring corrections and tests. Scanned the added lines for all eight anti-patterns; the four that could plausibly appear here:
- Hardcoded model identifiers (EGG201) — REFUTED. No model literal in the delta;
grep -iE "claude-[a-z0-9-]+"over added lines is empty. - Direct LLM API calls / bypassing the Agent SDK (EGG200, items 6-7) — REFUTED. No
anthropic,httpx, orrequestsimport; every added call issubprocess.runagainstgit. - Structured output for humans / post-processing pipelines — REFUTED. Nothing parses agent output. The one machine-readable surface,
_record_discarded_tip'smetadatadict, is orchestrator-authored to the agent, and its own comment (_worktree.py:1380-1386) gives the right reason for it: so a consumer reads the size and completeness claims "structurally instead of regexing the prose." - Excessive pre-fetching — REFUTED. The agent-facing bus body stays a few sentences of SHAs and orientation, and
wip_pathsis explicitly capped ("a bus record is not the place to inline an arbitrarily wide working tree, andwip_filesalready carries the untruncated count"). That is orientation, not a baked-in payload.
Two changes are mild positives on the orienting-context axis, worth naming since they are the only agent-facing text in the delta:
_worktree.py:1324-1330— thepartial_textwarning now reads "git add -Adid not complete cleanly" instead of "reported errors." This is the string a resuming agent actually acts on, and the old wording asserted an exit status that in the raise cases (timeout, undecodable stderr) never existed. Accurate orientation beats confident orientation.- The same correction in the
_record_discarded_tipdocstring (:1197-1199) and the soft-branch comment (:1223-1224) keeps the code's stated contract matching the string.
The R9 blocking finding (B1) is addressed at agent_salvage.py:289 with the errors="replace" keyword, the quotePath claims at :252-268 and :735-746 are narrowed to what the pin actually covers rather than deleted, and test_hostile_filename_does_not_cost_the_whole_working_tree drives a real failing add via a latin-1-named nested repo rather than a chmod-based seed that would no-op under a root CI user. That is a code-quality observation, not an agent-mode one — flagging only because it confirms the previous round's feedback landed.
Note: this PR merged as c6204e3 at 05:02 UTC before this re-review ran, so this is a retrospective record rather than a gate.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of #3644 — delta 20829733 → eb16af21 (R9)
One PR-authored commit since my last review: 35a5e59 "Decode git output non-strictly on the #2807 salvage path (#3639 r9)". The PR merged at 05:02 UTC while this review was running, so the findings below are follow-up material, not a merge gate. I've flagged which are actionable.
What I verified as correct
- NB-2 neutrality correction is right. The old comment's claim that a replaced name "fails every glob" was false and the new reasoning holds for the current
_MACHINE_STATE_FILE_GLOBS: they contain only*, every literal is ASCII, anderrors="replace"never emits an ASCII codepoint, so the ASCII skeleton (including/at 0x2F) survives byte-for-byte. Confirmed withfnmatchcaseagainst both replaced fixtures. - NB-4
--allcorrection is right.git log --allwalksrefs/remotes/too; the prior comment saying "local refs only" was wrong, and the fetch is still required because a fresh clone has no ref underrefs/remotes/origin/egg/recovered/at all. - Grep-token parity holds. Both suffixes now open with
INCOMPLETE: \git add -A` did not complete cleanly while staging, so,docs/reference/agent-recovery.md:383-390quotes it verbatim with backticks, andtest_partial_suffixes_share_one_grep_token` pins constants and runbook. - No collateral from the global
errors="replace"onagent_salvage._run_git. All eight call sites consume shas, a branch name, emptiness checks, or the%x1f-delimited log parse — none is byte-exact, and none reads-z. The_gitclosure in_worktree.pystill defaultserrors=None(strict), so the other re-attach commands are unchanged. - Targeted tests pass:
test_add_stderr_decode_is_non_strict,test_replacement_does_not_move_the_softening_decision,test_partial_machine_state_snapshot_keeps_the_imperative. The 9TestSalvageUncommittedfailures in my sandbox are the gateway'sgit initblock, not the code.
1. The re-attach half of this commit is untested — delete the kwarg and nothing fails
orchestrator/kubernetes_spawner/_worktree.py:936
git(repo_dir, "add", "-A", "--ignore-errors", timeout=120, errors="replace")The commit message claims "the re-attach path's add gets the same treatment so an undecodable name cannot mislabel a complete snapshot INCOMPLETE." Nothing asserts that. I read all seven _preserve_dirty_tree fakes:
_fake_git(×3, :4351/:4418/:4452),_flaky_git(:4381),_undecodable_git(:4488),_failing_read_git(:4551) — all signed(_repo_dir, *args, **_kwargs)and discard kwargs._replacing_git(:4589) is the only one that inspectskwargs, and only underif args[0] == "diff".test_undecodable_filename_is_salvaged_end_to_end(:4607) seedscafé.mdas an ordinary file.git add -Astages it without emitting any message that echoes the path, so the strict decode never fires — that test passes identically with or without the kwarg.
Contrast the agent_salvage half, which got both an argv-level pin (test_add_stderr_decode_is_non_strict) and an end-to-end hostile seed (nested repo with no commit checked out — the input that actually makes git add echo a raw path).
Failure scenario: a future edit drops errors="replace" from :936. A worktree holding a nested repo whose directory name is latin-1 makes git add raise UnicodeDecodeError inside run → except Exception as add_error → partial=True → a complete snapshot is stamped INCOMPLETE: in its commit message and disqualified from the soft branch. Exactly the mislabelling the comment says it prevents. Zero tests fail.
Fix: mirror test_hostile_filename_does_not_cost_the_whole_working_tree into TestDirtyTreePreservedBeforeReset (nested repo, latin-1 dirname, assert snapshot.partial is False), and add assert kwargs.get("errors") == "replace" under args[0] == "add" in one of the fixture-level fakes.
2. _has_uncommitted_changes calls a failed git status "clean" and says nothing
orchestrator/agent_salvage.py:646-652
try:
result = _run_git("status", "--porcelain", cwd=repo_path, check=False)
except OSError, subprocess.SubprocessError:
return False
return result.returncode == 0 and bool(result.stdout.strip())_run_git's default timeout=30. A TimeoutExpired is a SubprocessError → return False → commit_working_tree returns None at :679-680 with no log line at all → salvage_worktree pushes commits only → the restart's reset destroys the tree. A non-zero status exit takes the same silent path via the returncode == 0 and conjunction.
The sibling path decides the opposite on the same input, deliberately and with a comment (_worktree.py, pre-discard status read):
except Exception:
dirty_entries = []
was_dirty = True
state_unknown = True"Unknown state counts as dirty."
Two code paths, same input, opposite answers — and the one that answers "clean" is the one that discards work without emitting a WARNING. This is agent_salvage.py, which this delta rewrites; it's the natural place to fix it. Verdict: PLAUSIBLE (mechanism certain from the code; the 30s trigger depends on worktree size and node I/O), so advisory rather than blocking — but the silence is the part worth fixing regardless of trigger frequency.
3. commit_working_tree has no inner guard on add / diff --cached
orchestrator/agent_salvage.py:682 and :703 both run at the 30s _run_git default and sit directly under the outer except Exception: ... return None. A raise from either abandons the entire snapshot.
The sibling path treats that structure as the bug itself. On _preserve_dirty_tree the add raising sets partial = True and commits anyway ("partial index beats no index"), and the staged read has its own try/except with this comment:
"whatever makes it fail ... must not reach the outer handler, which abandons the commit and hands the whole working tree to the reset. That is #3639 itself."
The timeouts are also backwards: 30s vs 120s for the add, 30s vs 60s for the staged read — tighter on the path with no partial tolerance. Fix: wrap each in its own try/except (partial = True / staged = None) and raise the timeouts to match. Verdict: PLAUSIBLE → advisory, same reasoning as #2.
Non-blocking
4. "Keep the globs ASCII" is not the invariant. _worktree.py:993-994 and the matching doc paragraph tell a future author that an ASCII glob inherits neutrality. It doesn't — replacement is not length-preserving:
b'x\xe9\xe9.md' -> 'x��.md' (2 chars)
b'x\xf0\x9f.md' -> 'x�.md' (1 char, maximal-subpart)
fnmatchcase(..., 'x??.md') -> True, False
The real invariant is "no length-sensitive metacharacters (?, [...])". Today's globs use only * so there's no live bug, and test_replacement_does_not_move_the_softening_decision doesn't pin it either.
5. Doc gap. docs/architecture/on-demand-agent-lifecycle.md:236-245 documents errors="replace" on the staged read but not the new one on git add, and still presents INCOMPLETE purely as a staging-failure marker. One clause noting that an undecodable add stderr no longer produces a spurious INCOMPLETE would close it.
6. Stale wording. agent_salvage.py:88, :666, :693 still say "reported errors" while the constant they describe now says "did not complete cleanly". Literally accurate on this path (partial = add.returncode != 0), so no correction needed — but if #3 is fixed and a timeout starts setting partial, all three become wrong.
— Authored by egg
|
egg review completed. View run logs 21 previous review(s) hidden. |
Fixes the work-destroying half of #3639.
What was actually destroying the work
_clean_reused_worktreehard-resets a reused worktree on every event respawn. #3506/#3509 built preservation around that reset, but both operate on commits:salvage_discarded_tippushes the doomed HEAD toegg/recovered/...only when the orphan detector finds commits ahead of the origin tip. A session that worked for hours without committing gave it nothing to find, sogit reset --hard+git clean -fderased the working tree and the path loggedcleaned and syncedat INFO. That is the #3639 incident: 110 minutes across 33 modified files, zero commits, no recovery ref.The change
Close the gap one step earlier instead of building new machinery. When the pre-discard tree is dirty,
_preserve_dirty_treecommits it (git add -Aplus a[salvage] pre-reset working-tree state (#3639)commit) before the reset. The snapshot is then an ordinary orphan, so the existing #3509 salvage push and message-bus record carry it to a recovery ref with no new subsystem.The residue policy is unchanged.
was_dirtyis latched before the snapshot, so the tree still hard-resets to the origin tip and a successor never inherits a killed-mid-event working set; the snapshot only makes the discarded state recoverable.Details:
branchis None. With no origin tip and no salvage target, a snapshot would just become the successor's HEAD: un-vetted residue promoted to committed state, exactly what R6 exists to prevent. That case now logs the discard rather than staying silent (issue suggestion 4).egg-salvageidentity so one[salvage]grep finds every machine-made working-tree snapshot regardless of which path took it. It does not callagent_salvage.commit_working_treedirectly: that helper's_run_gitomitssafe.directory=*, which the re-attach path needs for host-uid-owned worktrees, so reusing it would fail on "dubious ownership" exactly in production. The caller's git closure is threaded in instead.wip_commitin its metadata.Correction to the issue's diagnosis (does not change this fix)
The issue attributes the kill to the convergence-stall detector. It is not the trigger, and I posted the full evidence on #3639. In short, from the orchestrator log of the incident:
_check_convergence_stallhas no remedy. Its only effect is_emit_supervision_alert, an informationalOVERSEER_ALERTbus message (classify_message_intentexplicitly rendersstuck-phase-transitionas non-binding). It kills nothing and respawns nothing.propagation=Background, which is only reachable fromreap_terminated, and_reap(only_terminal=True)skips any Job that is not FAILED/EXITED.shared/egg_agent/client.py, which returnsreturncode=-1and fails the Job.So the chain was: agent hit its own 2h timeout -> loop observed a terminal Job and respawned (correct behavior) -> the respawn's re-attach wiped the tree (the defect this PR fixes). The stall warning fired in the same poll tick because the dedupe key had just left
_live_keysand the BRC bus had been quiet 3610s. Making the stall criterion progress-aware would have prevented a misleading alert, not the loss.Left for follow-ups, since both rest on different premises than the fix above: the progress-blind stall alert (#3639 suggestions 1 and 3), and the 2h agent timeout that kills a productive session with no warning and no checkpoint.
Tests
orchestrator/tests/test_kubernetes_spawner.py::TestDirtyTreePreservedBeforeReset(new, 6 cases): dirt with zero commits is snapshotted, pushed to the recovery ref, and reported on the bus while the successor still starts clean at the origin tip; ignored-files-only dirt is not committed; a failed snapshot still resets; the helper swallows git failures; a clean tree is untouched. The two central cases fail againstmain.Existing
TestDirtyDiscardAutoSalvagecases that seeded dirt alongside a commit were updated: the salvaged tip is now the snapshot sitting on the predecessor's commit (count 2, not 1), asserted structurally via parentage rather than a hardcoded sha.make lintclean; the file crosses the 800-line soft cap (881), which is a non-fatal warning and well under the 1500 hard cap.Docs: the re-attach policy in
on-demand-agent-lifecycle.mdand the recovery reference inagent-recovery.mdpreviously stated only that uncommitted state is discarded. Per the issue's second comment ("an agent cannot follow a contract it is never shown"), both now record the snapshot behavior.