Fix #2626: orchestrator pre-sync commit no longer deletes agent-pushed files - #2642
Conversation
…ktree update-ref `_commit_statefiles_to_worktree` runs at every phase boundary to capture the orchestrator's live `.egg-state/` writes (contract mutations from MCP calls) before `_sync_worktree_with_remote`. In the #2626 failure shape it instead landed a commit that *deleted* the plan draft and agent-outputs that BRC consensus had just produced — observed on `issue-1557-v2` plan phase (`d4a7dc974`) and also at the refine boundary (`8a6d96bb2`). Two compounding bugs: 1. **Cross-worktree branch-ref advance.** Agents are taught (`sandbox/agent-config/rules/branch-recovery.md`, plus the gateway's detached-HEAD commit hint) to run `git update-ref refs/heads/<assigned-branch> <sha>` after a detached commit. `update-ref` is plumbing and does not honour per-worktree branch locks, so the call from a per-agent worktree silently advances the shared `refs/heads/<pipeline-branch>` ref that the orchestrator's worktree has checked out. The orchestrator's HEAD symref jumps to the agent's commit; its index and working tree stay at the prior state. `git status` then reports every agent-pushed file under `.egg-state/` as a *staged deletion* (index disagrees with HEAD). 2. **`git commit -- <pathspec>` auto-stages working-tree changes.** The helper's final commit used `git commit -m ... -- .egg-state/`, which defaults to `--only` semantics that pull working-tree changes (including deletions) for the named paths into the commit — independent of any explicit `git add`. In the cross-worktree-advance state, this also committed the working-tree gap (files in HEAD but absent on disk) as real deletes. Fix: * Add `_read_tree_head` helper and call it just before `git add`, so the index is refreshed to match HEAD without touching the working tree. Stale-index-vs-HEAD deletions stop showing up in `git diff --cached`. * Drop the trailing `-- .egg-state/` pathspec from the `git commit`. The commit now lands exactly what `git add --force <hits>` staged — nothing more. Regression coverage: * New `test_commit_statefiles_cross_worktree_ref_advance.py` reproduces the bug end-to-end with real `git worktree` plumbing: two worktrees added against the same bare repo, agent runs `update-ref` after pushing, then the helper is invoked with the orchestrator's contract write live on disk. Asserts the agent's plan draft and agent-output remain reachable from HEAD after the commit. Pairs a positive test (commit captures contract change only) with an idempotency test (no orch writes ⇒ no commit, even with cross-worktree ref advance in play). * Existing test `test_none_identifier_stages_everything` updated to locate the `git add` call by subcommand rather than position 0 (the helper now runs `read-tree HEAD` first). This addresses only the symptom — the contract on origin still ends up unpopulated downstream because `_populate_contract_from_plan_safe` reads the plan draft from the orchestrator's stale working tree, not from `HEAD`. That gap (and #2627's "fail if contract is empty" guard) are follow-ups.
This comment has been minimized.
This comment has been minimized.
Integration Tests StatusOriginal failure (PyYAML) is fixed. The Current failure is a transient k3s infrastructure flake. Run Recommendation: Please re-run the integration tests. Recent successful integration test runs on other branches confirm the infrastructure works normally. — Authored by egg |
Autofix tracking{"Test/Integration Tests / Integration Tests": 2} |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
k3s occasionally becomes unstable during the calico-node DaemonSet rollout: the API server refuses connections before the 300s timeout expires, failing the integration tier. Add a single retry that restarts k3s (systemctl restart), waits for the API to recover, then re-runs the idempotent install-calico.sh. Mirrors the existing image-import retry guard added in #2474.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns. This is a git-plumbing fix in _commit_statefiles_to_worktree (add read-tree HEAD, drop the --only-semantics pathspec on git commit) plus a CI flake-guard retry — no agent prompts, LLM call paths, output formats, or pre/post-processing pipelines are touched.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review
Targeted symptom fix for #2626 that I think is correct. Detailed walk-through and a few non-blocking notes below.
Why both halves of the fix are needed
I traced the four combinations (with/without read-tree HEAD × with/without -- .egg-state/ pathspec) against the failure state (cross-worktree update-ref has advanced refs/heads/<branch> while the orchestrator worktree's index/working-tree are stale):
| read-tree | pathspec | outcome |
|---|---|---|
| no | yes | broken: index disagreement → staged deletions; --only pathspec also auto-stages working-tree deletions |
| no | no | broken: staged deletions still committed even without auto-staging from --only |
| yes | yes | broken: index is clean, but git commit -- .egg-state/ (--only) still re-stages working-tree-vs-HEAD deletions for files missing on disk |
| yes | no | fixed |
So both halves are load-bearing, and the failure modes are distinct. The new test only exercises the combined fix, which is fine — if either half is dropped, the test fails.
Function-level audit
_read_tree_head (orchestrator/routes/pipelines.py:7441) and the two new call sites at :7568 and :7577 are correct. git read-tree HEAD is single-tree fast-mode and replaces the entire index with HEAD's tree without touching the working tree, which is exactly the property the docstring claims.
I checked all 14 production call sites of _commit_statefiles_to_worktree (pipelines.py:7902, 8871, 8952, 9825, 10322, 11046, 18986, 19553, 20027, 20206, 20576, 21252, 21336 and phases.py:437). None pre-stage anything before invoking the helper, so the implicit "index wipe at entry" semantics introduced by read-tree HEAD is safe. Worth mentioning in the docstring nonetheless — see non-blocking note below.
The asymmetry between git diff --cached --quiet -- .egg-state/ (path-scoped) and the new git commit --no-verify -m message (no pathspec) is fine because read-tree HEAD + the explicit git add --force of target paths guarantees the staged set is .egg-state/-only at the commit point. No worktree-external mutation happens between the diff and the commit, so this is robust in practice.
Test — test_commit_statefiles_cross_worktree_ref_advance.py
The setup mirrors the production hazard end-to-end:
- Bare remote → bare
main-repoclone → two siblinggit worktree addworktrees that sharerefs/heads/. _agent_pushes_draft_and_runs_update_refdoes the gateway-allowed recovery primitive (git update-ref refs/heads/<branch> <sha>) from the agent worktree, which advances the ref the orchestrator worktree's HEAD symref resolves through.- The explicit sanity check
assert orch_head == agent_sha(line 209) confirms the test really did reproduce the precondition before the helper runs — without that check a setup bug could mask a regression.
Both positive and idempotent cases assert the right post-conditions (HEAD reachability of the agent files, modification-not-deletion of the contract, no commit when nothing changed). The "no commit" test is particularly useful because the pre-fix code lands a delete-commit even with zero orch-side writes — purely from the cross-worktree advance.
Non-blocking notes
-
Docstring on
_commit_statefiles_to_worktreeshould note the index-reset. Anyone reading_commit_statefiles_to_worktree(...)outside this PR's context could reasonably assume it preserves a caller's pre-staged index entries. It doesn't anymore —read-tree HEADwipes them. Add a sentence to the function docstring: "Any pre-existing staged changes in the worktree's index are discarded; only files matching the pipeline scope and present on disk are committed." Today nobody relies on the old behaviour, so this is documentation-only. -
Residual race window after
read-tree HEAD. If an agent'sgit update-reffires between this function'sread-tree HEADand itsgit diff --cached --quiet(or itsgit commit), the index disagreement reappears and the same bug class can still produce a delete-commit. The window is much narrower than before (single helper-internal interleave vs. an entire phase window), and the PR's scope statement explicitly defers the root cause to the agent-side recovery flow — but worth filing as a follow-up. Agit update-index --refreshafter thegit addwon't help (different problem); the real fix is either advisory worktree-level locking around the recovery primitive or moving the orchestrator's commit to a fresh transient worktree per phase. Out of scope for this PR; flagging so it doesn't get forgotten. -
CI workflow change is unrelated and not mentioned in the PR body. The k3s/calico retry guard in
.github/workflows/test-integration.yml:43-62is a sensible defensive change but it's a separate concern from the_commit_statefiles_to_worktreefix. Either split it into its own PR or add a line to the PR body so the bisection trail ingit logdoesn't lose track of why it landed here. The change itself is fine (set -eis still active inside theif-body, so a second-attempt failure does fail the step). -
Test fixture doesn't exercise
.gitignore. The new test seeds a repo with no.gitignore, so the production--forceflag isn't actually tested intest_commit_statefiles_cross_worktree_ref_advance.py.test_commit_statefiles_scoping.pyalready covers--forcewith mocks, so this is just an asymmetry note — not worth a new fixture. -
Minor: existing scoping test mocks
docker.errorsdefensively (test_commit_statefiles_scoping.py:21-23); the new test relies on thetry/except ImportErrorfallback inpipelines.py:22-27. Both are fine, but inconsistent. Ifdockeris indevdeps the mock is unnecessary in both files; if not, the new test will fail in environments where the package is absent. Worth picking one pattern.
Verdict
The fix is correct, the regression test reproduces the bug with real git worktree plumbing (not subprocess mocks), and the audited call sites confirm the index-reset is safe. The scope is explicitly bounded — the _populate_contract_from_plan_safe stale-working-tree gap and #2627's empty-contract guard remain as follow-ups per the PR body. Approving with the notes above as non-blocking.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Note the index-reset behavior in _commit_statefiles_to_worktree's docstring (callers must not pre-stage state they expect this helper to preserve). - Add the docker / docker.errors / docker.types MagicMock prelude to test_commit_statefiles_cross_worktree_ref_advance.py for consistency with test_commit_statefiles_scoping.py — both files now defend against the same missing-dep failure mode instead of one relying on the try/except ImportError fallback in pipelines.py.
|
Thanks for the thorough audit. Per-item disposition below:
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The conflict was in the comment block above the pre-sync commit's final 'git commit --no-verify -m ...' call. Both sides describe the same fix (drop the '-- .egg-state/' pathspec) but attribute it to different failure shapes: - HEAD (#2642): cross-worktree ref advance leaves drafts on HEAD but not on disk (#2626) - origin/main (#2630): agents pushing drafts to origin from their own worktrees leave files on HEAD missing locally (#2625) These are the same underlying mechanism (HEAD references a draft not materialised on disk). Resolution merges both attributions into a single comment that references both issue numbers.
Conflict Resolution SummaryResolved merge conflicts with
Notes for the reviewer
Verification
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns on the delta since my previous review (51e3b7c).
The two new commits (8f8f0dd review-feedback, ea34fe7 merge from main) only touch a docstring paragraph on _commit_statefiles_to_worktree, a MagicMock import prelude for docker/docker.errors/docker.types in the cross-worktree-ref-advance test, and a comment-only merge resolution. Still pure git-plumbing + test infra — no agent prompts, LLM call paths, output formats, or pre/post-processing pipelines touched.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review
All five non-blocking points from the prior review are addressed, deferred with a reason, or explicitly disagreed with a reason. The merge resolution against origin/main (now carrying #2630) is correct. No new blocking issues.
Verification of prior feedback
| # | Item | Disposition | Verified |
|---|---|---|---|
| 1 | Docstring should note the index-reset | Fixed in 8f8f0dd | _commit_statefiles_to_worktree docstring at orchestrator/routes/pipelines.py:7495-7500 says exactly what was asked for, and cross-refs :func:_read_tree_head. |
| 2 | Residual race window after read-tree HEAD |
Deferred to #2676 | Explicitly framed in the prior review as out-of-scope; the follow-up issue is the right surface. |
| 3 | CI workflow change not mentioned in PR body | Disagree with documented reason | Bot can't edit the PR body (jwbron-owned); rationale captured in the PR comment thread. Acceptable. |
| 4 | Test fixture doesn't exercise .gitignore |
Disagree with documented reason | Matches my own framing ("not worth a new fixture"). |
| 5 | Docker mock inconsistency | Fixed in 8f8f0dd | test_commit_statefiles_cross_worktree_ref_advance.py:69-71 now matches the sys.modules.setdefault pattern from test_commit_statefiles_scoping.py:22-24. |
Merge resolution against origin/main (#2630 already landed)
The conflict was in two locations of _commit_statefiles_to_worktree:
-
Comment block above
git commit— both sides drop the-- .egg-state/pathspec; the doc-comment explaining why differed. The resolved comment atpipelines.py:7622-7639accurately describes both failure shapes (#2625agent-pushes-to-origin and#2626cross-worktreeupdate-ref) and correctly identifies that they share the same underlying mechanism ("HEAD references a draft that is not on disk locally"). Comment-only change, no behavior delta. -
Early-out diff scope —
origin/main's #2630 also changedgit diff --cached --quiet -- .egg-state/togit diff --cached --quietto keep the diff scope symmetric with the unscopedgit commitbelow. This came in via the merge, not from the PR. The change is safe given that nothing in this code path stages outside.egg-state/today, and the supporting comment atpipelines.py:7594-7601is accurate.
The test-file conflict in test_commit_statefiles_scoping.py auto-merged. The PR's adjustment to test_none_identifier_stages_everything (locate git add by subcommand rather than position, because the helper now runs read-tree HEAD first) is at :142-152; the TestCommitStatefilesNoAutoStageDeletions class from #2630 is at :303-448. These don't overlap and both exercise the fix from different angles, as the resolution summary notes. No duplicate class — searched the tests tree to confirm.
Functional audit (re-verification)
Traced both regression tests in test_commit_statefiles_cross_worktree_ref_advance.py against the current _commit_statefiles_to_worktree:
- Positive (
test_pre_sync_commit_does_not_delete_agent_files_after_cross_worktree_update_ref):read-tree HEADresets index to HEAD (which now includes the agent's plan + agent-output via the silent cross-worktree advance).git add --force contract.jsonstages only the orchestrator's modified contract.git diff --cached --quietexits 1; commit lands withM\t.egg-state/contracts/issue-42.jsononly — agent files remain reachable from HEAD because they were in HEAD and stayed in the index unchanged. - Idempotent (
test_pre_sync_commit_idempotent_when_no_orch_writes_after_cross_worktree_advance): Same setup but no contract mutation. Afterread-tree HEAD+git add --force <contract.json>the index matches HEAD;git diff --cached --quietexits 0; helper returns False. The cross-worktree advance no longer manufactures a phantom delete-commit.
Both halves of the fix (read-tree HEAD + no pathspec on commit) remain load-bearing, as documented in the prior-review compatibility matrix.
Non-blocking observations
-
commit.gpgsign=falseconsistency in the new test.test_commit_statefiles_scoping.py:324disablescommit.gpgsignin_init_repoto defend against developers with global gpgsign + no signing key. The new test's_git()helper attest_commit_statefiles_cross_worktree_ref_advance.py:84-91only disablescore.hooksPath(via-c core.hooksPath=/dev/null). A developer withcommit.gpgsign=trueset globally would hit a seed-commit failure here. Trivial follow-up: add-c commit.gpgsign=falsealongside-c core.hooksPath=/dev/nullin the_git()wrapper. CI shouldn't hit this (no signing key configured), so this is purely a contributor-ergonomics nit. -
Same comment as before on
#2676: the in-helper race window betweenread-tree HEADandgit commitis the right thing to track separately. No change requested here; just noting #2676 captures it.
Verdict
The fix scope and intent stand from the prior approval; the new commits address the two actionable items I flagged (docstring + docker mock) and the merge resolution preserves the fix correctly. Approving again with the single non-blocking note above.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Pairs the commit.gpgsign=false flag alongside the existing core.hooksPath=/dev/null guard in the _git() wrapper, matching the defensive pattern from test_commit_statefiles_scoping.py's _init_repo. Without it, contributors with commit.gpgsign=true set globally but no signing key configured would hit a failure on the seed commit when running this test locally. CI is unaffected (no signing key configured) — this is purely a contributor-ergonomics fix.
|
Thanks for the re-review. Per-item disposition on the two non-blocking observations:
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns on the delta since my previous review (ea34fe7).
The single new commit (047cf26 "Add commit.gpgsign=false guard to _git() helper") adds -c commit.gpgsign=false next to the existing -c core.hooksPath=/dev/null flag in the _git() wrapper at orchestrator/tests/test_commit_statefiles_cross_worktree_ref_advance.py:84-91 — exactly the contributor-ergonomics fix I flagged as non-blocking last round. Test-helper plumbing only; no agent prompts, LLM call paths, output formats, or pre/post-processing pipelines touched.
— Authored by egg
There was a problem hiding this comment.
Re-review #3
The single new commit (047cf26) adds -c commit.gpgsign=false alongside the existing -c core.hooksPath=/dev/null in the _git() helper at orchestrator/tests/test_commit_statefiles_cross_worktree_ref_advance.py:84-98. That is exactly the change I asked for in non-blocking observation #1 of the prior re-review (commit ea34fe7).
Verification
- The new
-c commit.gpgsign=falseis placed on everygitinvocation routed through_git(). For non-commit subcommands (init,worktree,read-tree, etc.) the flag is a no-op; for the seedgit commitit defends against contributors withcommit.gpgsign=trueglobally and no signing key configured. End-state is equivalent to thegit config commit.gpgsign falseperformed intest_commit_statefiles_scoping.py:_init_repo— the difference is purely placement (per-command-cvs. one-timegit configpost-init), which is fine. - No behavioural change to production code (
orchestrator/routes/pipelines.py) sinceea34fe7. CI remains unaffected (no signing key configured on runners). - The other non-blocking note from the prior review (in-helper race window between
read-tree HEADandgit commit) remains correctly deferred to #2676; no new artifact needed in this PR.
Functional audit (unchanged from prior approval)
The core fix — _read_tree_head(git_base) + dropping the -- .egg-state/ pathspec on git commit inside _commit_statefiles_to_worktree — is intact and continues to:
- Reset the worktree's index to HEAD on entry so a silent cross-worktree
update-refadvance cannot manufacture phantom deletions when files in HEAD are absent on disk. - Commit without a pathspec so
git diff --cached --quietand the commit itself see the same staged set, eliminating the early-out-vs-commit mismatch from #2625.
Both regression tests in test_commit_statefiles_cross_worktree_ref_advance.py still pin both halves of the fix.
Verdict
Approving. The fix scope from the prior approvals is preserved; the new commit closes the last non-blocking contributor-ergonomics note.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
|
egg review completed. View run logs 19 previous review(s) hidden. |
Summary
Closes #2626.
_commit_statefiles_to_worktreeruns at every phase boundary to capture the orchestrator's live.egg-state/writes before_sync_worktree_with_remote. In #2626 it instead landed a commit that deleted the plan draft and agent-outputs that BRC consensus had just produced — observed onissue-1557-v2plan phase (d4a7dc974) and also at the refine boundary (8a6d96bb2), so this is a class issue at every phase, not plan-specific.Root cause is two compounding bugs:
Cross-worktree branch-ref advance. Agents are taught (
sandbox/agent-config/rules/branch-recovery.md, plus the gateway's detached-HEAD commit hint) to rungit update-ref refs/heads/<assigned-branch> <sha>after a detached commit.update-refis plumbing and does not honour per-worktree branch locks, so the call from a per-agent worktree silently advances the sharedrefs/heads/<pipeline-branch>ref the orchestrator's worktree has checked out. The orchestrator's HEAD symref jumps to the agent's commit; its index and working tree stay at the prior state.git statusthen reports every agent-pushed file under.egg-state/as a staged deletion.git commit -- <pathspec>auto-stages working-tree changes. The final commit usedgit commit -m ... -- .egg-state/, which defaults to--onlysemantics — pulls working-tree changes (including deletions) for the named paths into the commit independent of any explicitgit add. In the cross-worktree-advance state, this also committed the working-tree gap (files in HEAD but absent on disk) as real deletes.The fix:
_read_tree_head(git_base)just beforegit addrefreshes the index to match HEAD without touching the working tree, so the stale-index-vs-HEAD deletions stop showing up.-- .egg-state/pathspec fromgit commit; only whatgit add --forcestaged is committed.Scope
This addresses the delete-commit symptom only. The contract on origin still ends up unpopulated downstream because
_populate_contract_from_plan_safereads the plan draft from the orchestrator's stale working tree, not from HEAD — see the comments on #2626 and the recovery flow there. That gap, plus #2627's "fail if contract is empty" guard, are separate follow-ups.The mechanism analysis (verified end-to-end with a real git reproduction) is in #2626 (comment).
Test plan
test_commit_statefiles_cross_worktree_ref_advance.py— two test cases against the production helper with realgit worktreeplumbing:update-refrecovery, helper runs, asserts plan draft + architect output remain reachable from HEAD post-commit.orchestrator/tests/test_commit_statefiles_scoping.py— adjustedtest_none_identifier_stages_everythingto locate thegit addcall by subcommand (helper now runsread-tree HEADfirst); other 21 tests still pass.orchestrator/tests/test_contract_preserved_across_post_phase_sync.py— all 3 sync-flow tests still pass.