fix(orchestrator): non-destructive worktree-divergence reconcile (#2979) - #2986
Conversation
Stop reconciling sync divergence destructively and stop failing the whole pipeline post-consensus. Prevents the self-inflicted divergence at the source and pauses (instead of discarding work + FAILing) on the rare residual case. Source prevention (the structural fix): agents can no longer git-push `.egg-state/contracts/*` in refine/plan (gateway/phase_filter.py + .egg/phase-permissions.json). The orchestrator is the sole writer of contracts — agents mutate them through the contract API, never git — so removing the push permission makes the agent-pushed stale contract that drove the divergence structurally impossible. The post-phase rebase then only ever replays disjoint paths (orchestrator contracts/brc vs agent drafts/outputs) and reconciles cleanly. An explicit contract push now gets a 403-with-hint instead of triggering a destructive reset. Non-destructive reconcile: when the rebase autoresolve can't reconcile a divergence it has already aborted back to the clean local HEAD (the autostash is reapplied), so the orchestrator's committed work is intact. `_sync_worktree_with_remote` now leaves the worktree there, pins a backup ref as a stable operator handle, and reports `diverged_unreconciled` instead of `git reset --hard origin`. The `rev_list_failed` fall-through that reset with an unknown ahead-count (no backup) also bails non-destructively. The Step-4 reset now only runs for local-behind / prior-phase-failed-discard, neither of which can lose un-pushed work. Pause, don't FAIL: the two in-loop phase-boundary callers set AWAITING_HUMAN and block on a reconcile HITL (the proven phase-gate pause), then re-run the sync and resume the phase's post-processing inline once the operator acks — no full re-run, no re-divergence loop. The non-blocking populate_contract route returns HTTP 409 (`divergence_reconcile_unacked`) + AWAITING_HUMAN for the operator to re-run after reconciling. Retires the #2792/#2797 destructive machinery this replaces: the hard-reset recovery HITL + FAILED helper, the `hard_reset_recovery:` decision dispatch hook, and the restart_phase-based resume/abort helpers. README worktree-sync contract reconciled to the landed behavior. Relationship: composes with #2980 (#2972), which makes the adjacent push-failed branch non-destructive — different hunks of the same helper.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Resolved conflicts: - orchestrator/README.md: kept HEAD's new non-destructive worktree-sync description (this PR IS the redesign main referenced as upcoming). - orchestrator/routes/decisions.py: kept #2978's _normalize_choice_resolution helper (still needed for Restart agent + conditional-ACK envelope unwrap), dropped main's hard_reset_recovery dispatch handler (#2979 removes it). Refreshed _handle_conditional_ack_gate docstring to drop dead reference to _handle_hard_reset_recovery_resolution. Fixed auto-merge semantic drift: - orchestrator/tests/test_hard_reset_recovery.py: removed TestDispatchResolutionChoiceEnvelope class auto-merged from main; it tested the _handle_hard_reset_recovery_resolution function this PR removes. Kept TestNormalizeChoiceResolution (#2978 helper survives). - orchestrator/tests/test_sync_worktree.py: removed two `assert outcome.hard_reset_performed is False` lines auto-merged from main; the field was removed from WorktreeSyncOutcome. - orchestrator/tests/test_contract_preserved_across_post_phase_sync.py: same hard_reset_performed cleanup. - shared/egg_restrictions/phase_patterns.py: dropped `.egg-state/contracts/*` from refine and plan allowed_patterns so the mirror tracks the gateway (#2979 moved contract writes to the contract API; the gateway JSON config was already updated in HEAD). - shared/tests/test_phase_patterns.py: flipped `test_refine_allows_contracts` to `test_refine_blocks_contracts` and added `test_plan_blocks_contracts` to match the new phase-layer policy.
Merge conflict resolution —
|
| File | Resolution |
|---|---|
orchestrator/README.md |
Kept HEAD's new "Worktree Sync" wording — main's text described the old destructive behavior with a note that "a redesign is coming"; this PR is that redesign, so HEAD's description is the correct destination. |
orchestrator/routes/decisions.py |
Composed both PRs: kept #2978's _normalize_choice_resolution helper (still consumed by _handle_conditional_ack_gate and the resolve_decision boundary), and dropped main's _handle_hard_reset_recovery_resolution dispatch handler which #2979 removes. Refreshed the _handle_conditional_ack_gate docstring to drop the now-dead reference to the removed handler. |
Auto-merge semantic drift fixed
Git's three-way merge happily textually merged main's additions into files this PR had rewritten — leaving tests and patterns that reference symbols the PR deleted. Cleaned up:
| File | Drift |
|---|---|
orchestrator/tests/test_hard_reset_recovery.py |
Removed TestDispatchResolutionChoiceEnvelope class (from main) — tests the _handle_hard_reset_recovery_resolution function this PR deletes. Kept TestNormalizeChoiceResolution (#2978 helper survives). |
orchestrator/tests/test_sync_worktree.py |
Removed two assert outcome.hard_reset_performed is False lines — the field was removed from WorktreeSyncOutcome in HEAD. |
orchestrator/tests/test_contract_preserved_across_post_phase_sync.py |
Same hard_reset_performed cleanup. |
shared/egg_restrictions/phase_patterns.py |
Dropped .egg-state/contracts/* from refine & plan allowed_patterns so the sandbox-side mirror tracks the gateway. HEAD's .egg/phase-permissions.json already removed contracts from refine/plan (contracts mutate through /api/v1/contract/mutate API, not git, per #2979); main shipped this new mirror file with the old permissive entry. |
shared/tests/test_phase_patterns.py |
Flipped test_refine_allows_contracts → test_refine_blocks_contracts and added test_plan_blocks_contracts to match the new phase-layer policy. |
Local validation
ruff check+ruff format --checkon all six resolved files — clean.pytest gateway/tests/test_phase_filter_restrictions.py shared/tests/test_phase_patterns.py— all 115 pass (including theTestPhaseLayerSharedMirrorParitydrift guard).pytest orchestrator/tests/test_decisions_routes.py test_hard_reset_recovery.py test_conditional_ack_hitl_gate.py test_restart_agent.py— 190 pass.pytest orchestrator/tests/test_sync_worktree.py test_contract_preserved_across_post_phase_sync.py— 45 pass; 4 fail solely because the sandbox gateway blocksgit init --bare. Sandbox-environmental, not semantic — CI will run unrestricted.
Per the workflow constraint, did not run the full make test suite (10-15 min, risks timing out the wrapper). All semantic checks for the conflict zone are green.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
Autofix tracking{"Test/Unit Tests": 2} |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
#2979 routed the post-BRC worktree-sync through the new _sync_worktree_reconciling_divergence wrapper but test_sync_worktree_with_remote_is_wrapped still searched for the old direct _sync_worktree_with_remote call name. The try/except invariant from #2219 is still satisfied — the regex just needed updating for the new helper and the tuple-unpack assignment form.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Verdict: Approve with notes
The non-destructive contract is well realized. The orchestrator can no longer reset --hard over committed work; it pauses on AWAITING_HUMAN (not FAILED), pins a backup ref, and blocks the _run_pipeline loop on the same proven wait_for_decision primitive the phase-approval gate uses. The 3-pause budget bounds operator loops. The gateway-side phase filter blocks .egg-state/contracts/* pushes in refine/plan so the divergence is prevented at the source — defense in depth.
Verified:
- The lock-spanning invariant is preserved everywhere —
pipeline.status = AWAITING_HUMANand the HITL persist happen under the sameget_pipeline_state_lock(...)(reentrant) in both the in-loop wrapper atorchestrator/routes/pipelines.py:14388-14408and the non-blocking_emit_divergence_reconcile_hitlpath thatpopulate_contractuses. - Backup ref still pinned in the non-destructive branch — operators retain the offline-recovery handle they had under the destructive path.
- Resume path correctly flips back to
RUNNING(pipeline + phase execution) before re-running the sync; a still-divergent worktree re-pauses rather than silently failing. - The two
_run_pipelinecallers (phase-start atpipelines.py:20869and post-phase atpipelines.py:22227) handle(outcome, aborted)correctly;post_phase_sync_outcomeis initialized toNonebefore the try at:22209so the abort check at:22253can'tAttributeErroron the exception path. _divergence_reconcile_is_abortdefaults to "resume" on ambiguous resolutions — the correct conservative default now that resume is non-destructive. JSON-envelope unwrap ({"action": "select", "selected": ...}) handled.- Test coverage is comprehensive: divergence pause / resume / abort, budget exhaustion, JSON envelope,
populate_contractsurfacing. Thetest_sync_worktree.pyassertions explicitly check noreset --hardis issued after a rebase fail. - All CI green (Python, Unit Tests, Integration Tests, Security Scan, Docker, full lint suite).
Notes (non-blocking)
1. Stale rationale comment in the post-phase sync block. orchestrator/routes/pipelines.py:22205 still reads:
"This must run BEFORE _populate_contract_from_plan and _sync_pipeline_decisions_to_contract — otherwise git reset --hard in _sync_worktree_with_remote would revert their on-disk modifications."
After this PR _sync_worktree_with_remote no longer issues git reset --hard on divergence — the rationale ("the hard reset would revert their modifications") is stale. The ordering is still correct (the sync still applies remote state via the autoresolve rebase), but the comment now misrepresents why. Worth a one-line cleanup in a follow-up: replace the git reset --hard clause with the actual current reason (the autoresolve rebase needs to land remote state before the populate reads .egg-state/).
2. populate_contract can accumulate duplicate divergence HITLs. _emit_divergence_reconcile_hitl → _persist_hitl_decision → pipeline.add_decision() at orchestrator/models.py:1239 unconditionally appends; there is no dedupe on context. If the operator re-POSTs /phase/populate-contract against an already-paused pipeline (e.g. via the /sdlc UI without resolving the prior HITL, or an automated retry), each call emits a fresh pending decision. The route itself doesn't guard against this — it re-runs the sync and re-emits unconditionally. UX nit (not a correctness bug; the abort path still works on the most recent decision), but consider either dedup-by-context in add_decision, or having populate_contract early-return 409 when a divergence_reconcile_unacked decision is already pending. Low-priority follow-up.
3. Mid-pause exception leaves pipeline AWAITING_HUMAN with no waiter. Theoretical edge case in _sync_worktree_reconciling_divergence: if an exception surfaces between the AWAITING_HUMAN write and wait_for_decision (e.g. inside dq.wait_for_decision itself, or in report_pipeline_status), the post-phase caller's outer try/except at :22239 catches and continues, but the on-disk pipeline status remains AWAITING_HUMAN while the orchestrator thread moves on to the next phase. The exception path is narrow (the lock-spanning save already succeeded), but if you want to harden, a try/finally around the post-flip-to-AWAITING_HUMAN section that resets to RUNNING on unexpected error would close it. Not blocking.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Addresses the three non-blocking notes from the egg-reviewer review on #2986: 1. Stale rationale comment at pipelines.py:22205 — replaced the git reset --hard would revert justification with the actual modern reason: the autoresolve rebase needs to land remote state before populate reads .egg-state/. The destructive reset is gone after #2979, so the comment now matches the code. 2. populate_contract dedupes the reconcile HITL — set a stable context='divergence_reconcile_unacked' on emitted reconcile HITLs, and have the non-blocking route early-return 409 referencing the existing decision when a prior populate already paused the pipeline on a still-unacked reconcile HITL. Keeps the route idempotent under operator retries (e.g. /sdlc refresh before resolving, automated retry loop) so pipeline.decisions does not accumulate duplicates. 3. _sync_worktree_reconciling_divergence reverts on mid-pause error — wrapped the post-AWAITING_HUMAN wait-and-resolve span in a try/except. An unexpected exception (broadcast IO error, decision-queue runtime error, transient get_decision failure) now reverts the on-disk pipeline status from AWAITING_HUMAN back to RUNNING before re-raising, so the outer _run_pipeline try/except doesn't strand the pipeline on a never-acked HITL with no waiter. The abort path (operator chose Abort pipeline) still returns normally with aborted=True and is unaffected. Tests: - TestEmitDivergenceReconcileHitl now asserts context is set. - New test: populate_contract early-returns 409 when a pending reconcile HITL already exists, without re-running sync or re-emit. - New test: a wait_for_decision exception inside _sync_worktree_reconciling_divergence reverts AWAITING_HUMAN to RUNNING before propagating. All 27 tests in test_hard_reset_recovery.py pass.
Feedback addressedPushed
All 27 tests in — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Verdict: Approve (re-review)
The new commit ab65188 addresses all three non-blocking notes from my prior review on 0b1dfaf. Each fix lands correctly:
Note 1 — Stale rationale comment (pipelines.py:22270-22282). Replaced the git reset --hard clause with the modern reason (autoresolve rebase must land remote state before populate reads .egg-state/), with a parenthetical preserving the historical why-it-once-read-this-way. Reads cleanly.
Note 2 — populate_contract duplicate-HITL accumulation. Route-side dedupe via _find_pending_divergence_reconcile_decision (pipelines.py:14140-14152) is the right call — model-side dedupe would have been more invasive. Verified:
_DIVERGENCE_RECONCILE_HITL_CONTEXT = "divergence_reconcile_unacked"threaded consistently through both the non-blocking_emit_divergence_reconcile_hitl(pipelines.py:14274) and the in-loop_sync_worktree_reconciling_divergence(pipelines.py:14432). Even though only the route uses the dedupe, setting context on both paths is correct: a future in-loop emission that lands while the operator is reconciling against an existing pending HITL would still be discoverable as the same kind.- Early-return 409 short-circuits before sync/emit/populate (
phases.py:1321-1349), with the existingdecision.idsurfaced indetailsso the operator's/sdlcUI can route them to the right pending decision. _find_pending_divergence_reconcile_decisionreadspipeline.get_pending_decisions()which filters byDecisionStatus.PENDING— once the operator resolves the prior HITL, the next call proceeds normally. No stale-decision deadlock.- Test
test_already_paused_returns_409_without_resyncing_or_re_emittingassertsmock_sync,mock_emit, andmock_populateare not called, locking the idempotence contract.
Note 3 — Mid-pause exception leaves pipeline AWAITING_HUMAN with no waiter (pipelines.py:14466-14524). The try/except around the post-AWAITING_HUMAN span (broadcast → wait_for_decision → get_decision → resume-write) reverts on disk before re-raising. Verified:
- The revert is correctly guarded (
if pipeline.status == AWAITING_HUMAN) so a successful resume save followed by a later exception doesn't accidentally flip RUNNING back to anything. - Phase-execution status is reverted symmetrically, also guarded on
== AWAITING_HUMAN. - The abort path's
return outcome, Truereturns normally without triggering the revert — correct, because the caller_fail_pipeline_after_divergence_abortowns the FAILED transition. The pipeline staying briefly at AWAITING_HUMAN here is fine. - The inner
try/exceptaround the revert itself (the_revert_failedlog) swallows secondary failures so the original exception still propagates to the caller's outertry/exceptatpipelines.py:22313. Correct precedence. - Test
test_wait_for_decision_raise_reverts_awaiting_human_to_runningexercises the revert under aRuntimeErrorfromwait_for_decision, asserting both states are persisted in order.
Notes (non-blocking)
1. Stale test comment. orchestrator/tests/test_hard_reset_recovery.py:247-249 still says:
"The persisted decision carries the canonical options + a question that names the backup ref (no dispatch context — resume is manual re-run for the route path)."
The new assertion three lines below (assert persist_kwargs.get("context") == "divergence_reconcile_unacked") directly contradicts the "no dispatch context" clause. The comment block above the assertion was added alongside the new check, but the old comment wasn't removed. Worth a follow-up cleanup — strip the parenthetical from the older comment block so a reader doesn't have to reconcile the contradiction. Cosmetic, not blocking.
2. Orphaned pending HITL after revert is recoverable but discoverable. After the exception-revert path runs, the pending HITL remains in pipeline.decisions while the pipeline is RUNNING. The dedupe at populate_contract would then 409 against the orphan on the next operator retry, surfacing the decision ID so /sdlc can show it. The operator resolves it (no-op since no waiter is registered), and the next populate_contract proceeds. This is acceptable trade-off given the alternative (silently failing on a stuck AWAITING_HUMAN) is strictly worse. No action needed.
CI is green across Python, Unit Tests, Integration Tests, Docker, Security Scan, and the full lint suite.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The comment block at TestEmitDivergenceReconcileHitl said 'no dispatch context — resume is manual re-run for the route path', but the new assertion three lines below checks that context == 'divergence_reconcile_unacked'. Merged the two comment blocks into one that covers both the question/options and the context, so a reader no longer has to reconcile the contradiction.
Feedback addressedPushed
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — ACK
The single new commit since ab65188 (4ca99b3 — "Fix stale test comment contradicting new context assertion") cleanly addresses the one actionable item from my prior review.
Verification of the fix
Delta: 12 lines in orchestrator/tests/test_hard_reset_recovery.py:243-257, comment-only, no assertion or production-code changes.
- Before: Two comment blocks. The first stated "no dispatch context — resume is manual re-run for the route path", which directly contradicted the assertion three lines below:
assert persist_kwargs.get("context") == "divergence_reconcile_unacked". - After: Single unified comment that accurately describes the three
persist_kwargschecks immediately below it — canonical options, the backup-ref-naming question, and the stable string context used bypopulate_contractfor 409 dedupe.
The new comment text faithfully matches what the three assertions actually verify; no contradiction remains.
Trade-off acknowledgment
The previously-noted "orphaned pending HITL after revert is recoverable but discoverable" item was explicitly classified as a no-action acceptable trade-off in my prior review, and the author correctly left it as-is — the populate_contract dedupe path surfaces the orphan decision ID on the next operator retry, which is the right discoverability hook.
Risk
Comment-only test edit. No security, correctness, robustness, or design implications. Tests still pass per CI (Unit Tests green on this HEAD).
— Authored by egg
|
egg review completed. View run logs 12 previous review(s) hidden. |
…updater] (#2988) * docs: update divergence HITL section for non-destructive reconcile (#2979) Replace the stale "Hard-Reset Recovery" section in docs/hitl-decisions.md with accurate documentation of the non-destructive reconcile approach introduced by #2979: - Rename section and remove references to the destructive hard-reset path - Update pipeline status from FAILED → AWAITING_HUMAN - Update HITL context key: hard_reset_recovery → divergence_reconcile_unacked - Update options table: "Continue with post-reset state" → "Reconciled — resume" - Rewrite recovery steps: operator now manually reconciles the worktree first - Remove the "doubly-failed case" (no longer possible; the sync never hard-resets) - Update populate_contract HTTP 409 reason codes and note idempotency Triggered by: d40243d (fix(orchestrator): non-destructive worktree-divergence reconcile (#2979) (#2986)) Authored-by: egg * docs(hitl): address review feedback on divergence reconcile section Address three non-blocking suggestions from the review on #2988: - Clarify helper attribution: steps 1-3 happen in _sync_worktree_with_remote; step 4 lives in _sync_worktree_reconciling_divergence (blocking, _run_pipeline) or _emit_divergence_reconcile_hitl (non-blocking, populate_contract) - Note that auto-resume on 'Reconciled — resume' only applies to the two _run_pipeline fire sites; the populate_contract site requires the operator to re-POST against the reconciled worktree - Document the pause budget: 3 unresolved resume attempts mark the pipeline FAILED with reason '…the reconcile pause budget was exhausted' Authored-by: egg --------- Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Closes #2979.
Stops reconciling sync divergence destructively and stops failing the whole pipeline post-consensus. Prevents the self-inflicted divergence at the source, and pauses (instead of discarding committed work + FAILing) on the rare residual case — matching the issue's desired outcome.
This is the verified direction from the audit comment on the issue.
What changed
1. Source prevention — agents can't git-push contracts (the structural fix)
gateway/phase_filter.py+.egg/phase-permissions.json: removed.egg-state/contracts/*from the refine and plan allowlists. The orchestrator is the sole writer of contracts — agents mutate them through the/api/v1/contract/mutateAPI, never git. Permitting the push let a broadgit addland a stale contract snapshot on origin that then conflicted with the orchestrator's authoritative contract commit at the post-phase sync. That was the only non-agent-outputspath both sides touched on the work branch, so it was the driver of the divergence.With the push blocked, the post-phase rebase only ever replays disjoint paths (orchestrator contracts/brc-history vs agent drafts/outputs/reviews) and reconciles cleanly — making the destructive path structurally unreachable in normal operation. Safe because agents push specific paths (
git add <files>, notgit add -A) and no test relies on git-pushing contracts; an explicit contract push now gets a 403-with-hint instead of triggering a destructive reset.2. Non-destructive reconcile (
_sync_worktree_with_remote)When the rebase autoresolve can't reconcile a divergence, it has already run
git rebase --abort(reapplying the autostash), so the worktree is back at the clean local HEAD with the orchestrator's committed work intact. The helper now leaves it there, pins arefs/egg-backup/sync-recovery/<id>/<ts>ref as a stable operator handle, and returns adivergence_unreconciledoutcome — instead ofgit reset --hard origin(which #2792/#2797 did, discarding work to a backup ref and FAILing the pipeline).The
rev_list_failedfall-through that reset with an unknown ahead-count and no backup ref now bails non-destructively too. The Step-4 reset is now only reached for local-behind / prior-phase-failed-discard, neither of which can lose un-pushed committed work. Invariant: the orchestrator neverreset --hardover a commit not provably already on origin.3. Pause, don't FAIL (+ inline resume)
The two in-loop phase-boundary callers (post-phase, phase-start) now set
AWAITING_HUMANand block on a reconcile HITL (the same proven pause primitive the plan-approval gate uses), then re-run the sync and resume the phase's post-processing inline once the operator acks — no full re-run, no re-divergence loop. Bounded pause budget guards against a never-reconciled loop. The non-blockingpopulate_contractroute returns HTTP 409divergence_reconcile_unacked+AWAITING_HUMANfor the operator to re-run after reconciling.4. Retired the destructive #2792/#2797 machinery this replaces
_fail_pipeline_and_emit_hard_reset_recoveryhard_reset_recovery:<phase>decision-resolution dispatch hookrestart_phase-basedresume_pipeline_after_hard_reset_ack/abort_pipeline_after_hard_reset_ackhelpersSyncRebaseAndResetFailedError(the reset is gone, so the doubly-failed case can't occur)Net:
pipelines.pyshrinks (~600 lines of destructive machinery removed, ~370 of pause/resume added).5. README
orchestrator/README.mdworktree-sync contract reconciled to the landed non-destructive behavior (its line was stale — it still described the pre-#2792 "leave unchanged" behavior).Testing
make lint✅make test-allleft for CI):test_sync_worktree.py,test_hard_reset_recovery.py(rewritten for the new behavior),test_pipeline_failure_path.py,test_contract_preserved_across_post_phase_sync.py,test_decisions_routes.py, gatewaytest_phase_filter*.py,test_gateway.py— 572 passed.New/updated coverage: refine/plan contract pushes are blocked (+ atomic mixed-push rejection); divergence-rebase-fail leaves the worktree intact (no reset) and reports
divergence_unreconciled;rev_list_failedbails non-destructively; the reconcile HITL question/options/abort-detector;_emit_divergence_reconcile_hitl(AWAITING_HUMAN, not FAILED);_fail_pipeline_after_divergence_abort; and the_sync_worktree_reconciling_divergencepause→resume / abort / budget-exhausted loop.Relationships
Out of scope (follow-ups)
APPLY-phase contract pushes left as-is (the Jira applier is a separate flow not analyzed here)._rebase_pipeline_branch_onto_baseconfused-HEAD, PR-open refresh) — verify-on-origin hardening is separate.