fix(orchestrator): commit+push contract statefiles at slice boundaries (#3117) - #3127
Conversation
#3117) Contract task-record mutations (task status, commit links, gaps, notes) and slice.status flips are written only to the live contract copy in the orchestrator's shared pipeline worktree. The copy committed to the pipeline work branch was written exactly once ("Initialize SDLC contract") and never updated mid-implement-phase, so per-agent worktrees forked from the branch saw an all-pending init-time snapshot and a mid-phase crash or worktree prune lost every task record. Add a slice-boundary commit+push in _persist_slice_status_complete — the single funnel for slice-close, merged-skip, and bootstrap reconciliation persistence — reusing _commit_statefiles_to_worktree (idempotent, pipeline-scoped) + GatewayClient.push_worktree_branch (launcher-auth, non-FF reconcile). Best-effort throughout; the commit runs under the per-pipeline state lock to serialise concurrent slice-close threads. Bootstrap passes batch a single commit after the reconciliation loop instead of one per slice.
There was a problem hiding this comment.
Review of #3127
Targeted fix that closes the right gap. The closure design is clean — reusing the existing _commit_statefiles_to_worktree + push_worktree_branch pair, threading the per-pipeline state lock through the commit-side, and gating bootstrap with a commit_to_branch=False parameter so the batched single commit covers Layer-B and Layer-C-case-3 in one go rather than N. The tests cover the unit behavior well (slice-close, no-op, both failure modes, main-repo guard, bootstrap batching).
I have no blocking concerns. A few non-blocking observations worth surfacing:
Non-blocking
1. Push is unlocked, and concurrent reconcile in the shared worktree is a theoretical hazard. _run_one_slice runs per slice in a ThreadPoolExecutor; a wave with K ready slices that all hit _persist_slice_status_complete near-simultaneously results in K concurrent push_worktree_branch calls against worktree_repo_path after the lock-protected commit. The expected case is fine — the lock-serialised commits produce a linear local history (B's commit is on top of A's), so both pushes are equivalent fast-forwards, and a no-op fast-forward of the same SHA from two threads is harmless. The hazard is the _reconcile_and_retry_push path (gateway_client.py:1361): two threads both encountering a non-FF rejection will each run git fetch origin + git rebase inside the same shared worktree, which can interleave (.git/index.lock, rebase-in-progress state) and corrupt the checkout. Within the implement phase no other writer pushes to pipeline.branch, so non-FF shouldn't fire in normal operation — but an external push (operator hand-fix, a stale concurrent orchestrator) would expose this. The lock comment ("the push runs outside the lock") makes the choice deliberate; worth either documenting that the reconcile path is the residual risk, or extending the lock to cover the push (the gateway HTTP RTT is ~10ms in steady state, and serialising it across slice closes within one pipeline matches what the phase-boundary push already does implicitly).
2. Failed slices skip the commit. When exit_code_inner != 0 (line 16395) or pr_created == False (line 16572), _run_one_slice_inner returns before _persist_slice_status_complete fires. Agent task-record mutations made during the failed slice remain on the shared worktree's disk only. They're eventually picked up by the next successful slice's commit (the glob is pipeline-scoped, not slice-scoped) or by the phase-boundary commit — and the issue you're fixing explicitly scopes to "meaningful boundaries — slice completion and phase completion at minimum" — so this is consistent with intent. But it's a narrower durability window than the docstring's "every slice boundary" framing suggests. A short comment at the call sites noting "successful slices only; failed slices wait for next-slice or phase boundary" would calibrate expectations.
3. Docstring framing vs the #3077 scope note. The closure docstring at pipelines.py:15657-15661 motivates the fix with "per-agent worktrees forked from the branch see an all-pending snapshot." Issue #3117's Scope note is explicit that this fix targets impacts 2–4 (post-prune audit, operator/PR review, restart durability) and that impact #1 (agents reading .egg-state/contracts/ from their checkouts) is the anti-pattern #3077 retires — agents must use mcp__sdlc__show_contract. The docstring as written reads as if it's justifying boundary commits as a means of freshening agent-side disk reads, which is exactly the framing #3077 disallows. The PR description gets this right ("the MCP contract surface remains the authoritative mid-slice read path"); the docstring should match.
4. Comparison style inconsistency. New code at pipelines.py:15690 uses str(worktree_repo_path) == str(store.repo_path); the existing phase-boundary at pipelines.py:22926 uses worktree_repo_path != repo_path (Path comparison). Functionally equivalent; matching the existing style would keep the two main-repo guards visually identical for future grep.
5. Test gap — no multi-slice concurrent wave. TestSliceBoundaryStatefileCommit runs one slice (or two via bootstrap, where neither actually runs through _run_one_slice). A test exercising a ready_batch of ≥2 slices that all complete and call _persist_slice_status_complete concurrently would verify the lock serialises the commit step and that the _commit_statefiles_to_worktree returns-False-on-no-staged-changes path correctly collapses the second commit into a no-op. Not required to land, but useful for the next person who touches the locking shape.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Reframe _commit_and_push_slice_statefiles docstring per #3077: live agents read via mcp__sdlc__show_contract (orchestrator in-memory), not from .egg-state/contracts/ in their checkout. Make explicit that the boundary commits cover post-prune audit, operator/PR review, and restart durability — NOT a freshening read path. - Document the residual concurrent-reconcile-in-shared-worktree hazard on the docstring (lock covers commit; push runs outside the lock). - Clarify in _persist_slice_status_complete docstring that failed slices (exit_code_inner != 0 or pr_created == False) return early before this helper; their mutations wait for the next successful slice or the phase boundary. - Match the existing main-repo-guard comparison style at pipelines.py:22926 (Path equality, not str() coercion). - Add test_concurrent_wave_serialises_commits_and_collapses_noop — exercises a 2-slice ready batch where one commit returns True and the second (no-op) returns False, verifying lock serialisation + push elision.
|
Thanks for the careful review. Disposition per item: 1. Push reconcile hazard in shared worktree — fixed-in-PR (commit 09746e5). Documented the residual risk on the closure docstring: the expected case is a lock-serialised fast-forward; the hazard is 2. Failed slices skip the commit — fixed-in-PR (commit 09746e5). Expanded the 3. Docstring framing vs the #3077 scope note — fixed-in-PR (commit 09746e5). Removed the "per-agent worktrees see an all-pending snapshot" framing and added an explicit Scope paragraph: this closes post-prune audit, operator/PR review, and orchestrator-restart durability (impacts 2–4 of #3117). It is NOT the read path for live agents — 4. Comparison style inconsistency — fixed-in-PR (commit 09746e5). Switched to 5. Test gap — multi-slice concurrent wave — fixed-in-PR (commit 09746e5). Added
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of #3127
Re-review against commit 09746e5 (delta from b7df50410): two files touched (orchestrator/routes/pipelines.py + 6-test class), all 5 non-blocking points from my prior review addressed.
Issues from prior review — all addressed
- Push-reconcile residual hazard (prior #1) — documented on the closure docstring (
pipelines.py:15676-15685). The hazard description correctly identifies_reconcile_and_retry_pushon non-FF as the trigger and notes no other writer pushes topipeline.branchduring the implement phase. Chose documentation over extending the lock to cover the push; reasonable given the gateway RTT cost. - Failed slices skip the commit (prior #2) — documented on
_persist_slice_status_complete(pipelines.py:15744-15751). Correctly cites both early-return paths (exit_code_inner != 0at line 16411,pr_created == Falseat line 16595) and explains the carry-over to the next successful slice's commit or the phase boundary. - Docstring framing per #3077 (prior #3) — the "per-agent worktrees forked from the branch see an all-pending snapshot" framing is gone (
pipelines.py:15663-15669); the new Scope paragraph correctly attributes the durability scope to impacts 2–4 of #3117 and explicitly disclaims read-path framing for live agents. Verifiedmcp__sdlc__show_contractis real (sandbox/egg_agent_tools/tools/sdlc.py) and reads via orchestrator HTTP, not the per-agent checkout's.egg-state/contracts/. - Comparison style consistency (prior #4) —
worktree_repo_path == store.repo_path(pipelines.py:15705) matches the phase-boundary guard atpipelines.py:22950. VerifiedStateStore.repo_pathisPathin production (state_store.py:152, 162;get_state_storeat line 1616-1617 coercesstr→Pathat construction). The test mock update toPath("/tmp/x")correctly reflects the new comparison semantics. - Multi-slice concurrent wave test (prior #5) —
test_concurrent_wave_serialises_commits_and_collapses_noopadded. Two dep-free slices → both READY in first wave → both dispatched viaThreadPoolExecutor→ both reach_persist_slice_status_complete. Asserts 2 helper calls + 1 push (the no-op collapse). Ran the test 5× locally — stable.
Non-blocking observations on the new test
The test's side_effect=[True, False] enforces the assertion behavior in mock-call order rather than slice-completion order. With the save and the commit on separate lock acquisitions (_persist_slice_status_complete releases the lock between save_contract and the call into _commit_and_push_slice_statefiles), the production interleaving where both threads' commits each see staged changes (A saves → A commits → B saves → B commits) is theoretically possible — both commits would return True and both pushes would fire, which the test would not detect under the mocked ordering. In practice the no-op-collapse path is what the docstring and assertion target, and that's what gets exercised; the test name ("serialises_commits") slightly overstates what's verified. Not blocking — the production correctness story (lock-serialised commit → pipeline-scoped glob sweeps both saves under whichever thread wins → second is a no-op) holds independently, and the mocked timing in this test does happen to surface the intended path consistently.
Verification
- Targeted tests green:
pytest orchestrator/tests/test_slice_run_loop_integration.py::TestSliceBoundaryStatefileCommit -v→ 7 passed. - Cross-checked the changed files for any other deltas — only the two files in the patch, only the changes called out in the response comment.
No blocking concerns.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Re-review observation: test_concurrent_wave_serialises_commits_and_collapses_noop overstated what the test verifies. Under side_effect=[True, False] the mock returns True/False in mock-call order rather than thread-completion order, so the assertions hold under any interleaving and the test does not actually prove the per-pipeline state lock serialised the commits. What it does pin is the externally observable no-op-collapse: both slices reach _persist_slice_status_complete, only one push hits the gateway. Rename to test_concurrent_wave_no_op_collapse_elides_second_push and rewrite the docstring with an explicit Scope paragraph: what is verified (no-op collapse → single push per wave), what is NOT (lock-enforced serialisation — the production correctness story comes from the lock itself; the test pins the externally visible behavior). 7/7 in TestSliceBoundaryStatefileCommit still pass.
|
Thanks for the re-review. Single observation, addressed: Test name overstates what's verified — fixed-in-PR (commit 15a0bbb). You're right that under — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of #3127
Re-review against commit 15a0bbb (delta from 09746e5): one file touched (orchestrator/tests/test_slice_run_loop_integration.py, +19/-10), addressing the single non-blocking observation from my prior re-review.
Prior observation — addressed
Test name overstates what's verified. Renamed test_concurrent_wave_serialises_commits_and_collapses_noop → test_concurrent_wave_no_op_collapse_elides_second_push (test_slice_run_loop_integration.py:1482) and rewrote the docstring with an explicit Scope paragraph:
Scope: this verifies the no-op-collapse semantics — both slices reach the helper, only one push hits the gateway. It does NOT directly verify lock serialisation:
side_effect=[True, False]is consumed in mock-call order, not in slice-completion order, so the assertions hold under any interleaving rather than proving the per-pipeline state lock enforces one. Production correctness for the serialisation property comes from the lock itself; this test pins the externally observable behavior (single push per wave when subsequent commits are no-ops) that single-slice tests can't reach.
The rename + scope paragraph correctly resolve the name-vs-behaviour contradiction: the new name accurately describes the assertion (assert len(pushes) == 1), and the docstring spells out why mocked side_effect ordering does not constitute lock-serialisation evidence. The test assertions and fixtures are unchanged (commit_mock = MagicMock(side_effect=[True, False]), two dep-free slices, ThreadPoolExecutor dispatch via _run) — the change is purely cosmetic-but-meaningful.
Verification
- The substantive logic in
pipelines.py(closure docstrings,commit_to_branchparameter, comparison style, main-repo guard, bootstrap batching) is unchanged from09746e5, which I previously approved. - CI is green on the relevant checks (Python, Unit Tests, Integration Tests, Lint, Security Scan all SUCCESS).
- No new blocking concerns.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
… resolve conflict in orchestrator/routes/pipelines.py Additive resolution of _persist_slice_status_complete signature and docstring. #3127 adds the commit_to_branch keyword (statefile commit on slice boundary, #3117); #3122 adds pr_number/pr_url keywords (slice-PR linkage written in the same contract write). Both sets of parameters and their docstring sections are kept; the function body on this branch already handles both (the merge auto-merged the body itself).
Conflict Resolution SummaryMerged
Detail on
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of #3127
Re-review against commit 387dbae (delta from 15a0bbb): one merge commit that resolves a conflict in orchestrator/routes/pipelines.py between this PR's commit_to_branch keyword and #3122's pr_number/pr_url keywords on _persist_slice_status_complete. The test file (test_slice_run_loop_integration.py) auto-merged cleanly — both PRs added new test classes in non-overlapping regions.
Conflict resolution — verified correct
Compared the merge result (387dbae) against main (186239b): only the two files in scope changed, and the diff against main is exactly the #3117 surface I previously approved on 15a0bbb — _commit_and_push_slice_statefiles helper, commit_to_branch keyword + docstring paragraphs, if commit_to_branch: block after the save, commit_to_branch=False at the two bootstrap call sites, and the single batched commit+push after the bootstrap reconciliation loop. No extraneous changes leaked through the merge.
The combined _persist_slice_status_complete (pipelines.py:16274-16352):
- Signature —
(slice_id, *, pr_number=None, pr_url=None, commit_to_branch=True). Keyword-only, both parameter sets coexist. All 4 call sites verified consistent:pipelines.py:16445(bootstrap Layer-B merged) —commit_to_branch=False, nopr_numberpipelines.py:16509(bootstrap Layer-C case 3) —commit_to_branch=False, nopr_numberpipelines.py:16838(run-loop merged-skip) — defaultcommit_to_branch=True, nopr_numberpipelines.py:17284(run-loop slice-close) — defaultcommit_to_branch=True, withpr_number=slice_pr_number, pr_url=slice_pr_url if slice_pr_number else None
- Body — the in-lock save block correctly applies status + optional pr_number/pr_url; the
if commit_to_branch:block sits outside the try/except so a save failure short-circuits via the earlyreturnand does not push stale state. Thecommit_to_branch=Trueruntime path acquires the per-pipeline state lock a second time inside_commit_and_push_slice_statefilesfor the commit step (the comment thread onb7df504covered this); push runs outside the lock. Body behaviour matches both prior surfaces. - Docstring — both prose sections preserved verbatim. The
TODO(#3122)paragraph about "three None callers" is the same text that landed onmainvia #3122; the slight imprecision (the three None callers are actually Layer-B, Layer-C case 3, and the run-loop merged-skip — Layer-A returns before the helper) pre-exists onmainand isn't introduced by this resolution.
Verification
- CI green on
387dbae: Python ✅, Unit Tests ✅, Integration Tests ✅, Security Scan ✅, Lint ✅, Docker ✅. - Substantive logic from the prior approval on
15a0bbbis unchanged (closure docstrings, comparison style, main-repo guard, bootstrap batching, the renamed concurrent-wave test). - The test file's two top-level classes —
TestSliceMergedDetection(test_slice_run_loop_integration.py:1147, from #3122) andTestSliceBoundaryStatefileCommit(:1478, from this PR) — are both intact in the merge result.
No new blocking concerns.
— Authored by egg
|
egg review completed. View run logs 9 previous review(s) hidden. |
Summary
Fixes #3117 — the work branch's
.egg-state/contracts/<pipeline_id>.jsonwas committed exactly once ("Initialize SDLC contract") and never updated through the implement phase. All contract mutations — agent task-record updates viamutate_contractand the orchestrator'sslice.status = COMPLETEflips — landed only on the shared pipeline worktree's disk copy. Observed onpipeline-2d9cc50d: the branch copy showed every task row pending forever, contributing to the #3114 misdiagnosis, and a worktree prune would have made that wrong record permanent.The phase-boundary commit machinery (
Persist statefiles after <phase> phase+ push) already exists but is too coarse: it only fires when the whole implement phase completes, so a multi-slice phase accumulates everything uncommitted for its entire duration.Change
_commit_and_push_slice_statefilesin_run_implement_phase_slices, reusing the existing work-branch pair:_commit_statefiles_to_worktree(idempotent, pipeline-scoped glob that includes the contract since Plan-phase contract update not reaching PR branch tip (blocks #1825) #1829) +GatewayClient.push_worktree_branch(launcher-auth, built-in non-FF reconcile from #3088)._persist_slice_status_complete— the single funnel for slice-close, merged-skip, and bootstrap-reconciliation persistence — so every slice completion commits the contract (and any other uncommitted pipeline statefiles, i.e. the slice's accumulated task-record mutations) and pushes the work branch.get_pipeline_state_lock) to serialise concurrent slice-close threads against the shared worktree's git index; the push runs outside the lock.commit_to_branch=Falseand batch one commit+push after the loop instead of one per reconciled slice.What this fixes downstream
Out of scope (per issue, tracked separately): HITL-park commits (#3070 family) and per-mutation commits (too chatty; the MCP contract surface remains the authoritative mid-slice read path).
Test plan
TestSliceBoundaryStatefileCommit(orchestrator/tests/test_slice_run_loop_integration.py): commit+push on slice close (scoped withpipeline_id), no push on no-op commit, commit/push failures don't block slice completion, no push when worktree == main repo checkout, bootstrap batches a single commit for N reconciled slices.test_slice_run_loop_integration.py,test_slice_4_restart_hardening.py,test_slice_loop_import_seam.py,test_create_slice_integration_branch.py— 138 passed.make lintgreen.