Recover context PR on create_context_branch divergence - #2579
Conversation
When the context PR hook partially fails between pushing the artifact commit and persisting `contract.pr.context_pr_number`, the next tick's `create_context_branch` raises on branch divergence (existing_sha = base_sha+1, base = base_sha) and the hook's outer broad-`except` swallowed it without invoking `_recover_existing_context_pr` — wedging the pipeline so no context PR is ever opened despite one being live on GitHub. Fix: typed `ContextBranchDiverged(GatewayError)` subclass, raised from the gateway primitive when divergence is detected. The hook now catches it specifically, runs the existing recovery helper, and persists the salvaged linkage so subsequent ticks idempotent-skip at the top. Non-divergence `GatewayError`s continue to fail-soft via the existing broad `except`. The persistence tail (save_contract + commit + push) is extracted into `_persist_context_pr_linkage_on_contract` so the new recovery path and the existing happy/create_pr-recovery paths share one implementation. Tests cover: divergence with recoverable PR, divergence with no recoverable PR, divergence with mismatched base_ref, and non-divergence GatewayErrors still failing-soft.
There was a problem hiding this comment.
Reviewed and verified locally — 51 tests pass. This is a clean, well-scoped fix for the durability gap PR #2575's reviewer flagged.
The design choices are sound:
ContextBranchDivergedsubclassesGatewayErrorso broad-catch callers keep working — backward-compatible.- The new
except ContextBranchDivergedbranch sits before the broadexcept Exception, so the divergence path takes precedence; a non-divergenceGatewayErrorstill falls through to fail-soft. Pinned bytest_other_gateway_errors_still_fail_soft. - Extracting
_persist_context_pr_linkage_on_contractremoves duplication cleanly — the divergence branch and the post-create_prhappy path share the same persistence tail with no behavioral drift. - The new path correctly skips the artifact file-copy /
_commit_statefiles_to_worktree/push_worktree_branchfor the context branch — that work was already done by the prior tick — and only writes the contract linkage on the work branch. - The four new tests cover the matrix: recovers when PR exists; returns None when none exists; rejects mismatched
base_ref; non-divergence GatewayError still fails soft.
I traced the divergence-recovery flow end-to-end and verified the test assertions against the actual control flow. The list_open_prs filter on head_ref + base_ref correctly guards against salvaging an unrelated stale PR.
Non-blocking suggestions
1. Adjacent durability gap: branch pushed but create_pr failed
The PR scopes itself to the "post-create_pr, pre-save_contract" failure. A closely related sub-case is still wedged after this fix:
- Tick 1:
create_context_branchsucceeds → artifact commit pushed →create_prraises (or returns no URL) →_recover_existing_context_prreturnsNone(no PR exists yet) → hook returnsNone. - Tick 2:
create_context_branchraisesContextBranchDiverged(branch exists at our pushed SHA ≠ base_sha) → new code calls_recover_existing_context_pr→ stillNone→ hook returnsNoneagain.
The pipeline is stuck — the branch is already on origin, all that's missing is the PR, but the divergence-recovery path never reaches create_pr. The test test_returns_none_when_diverged_and_no_existing_pr explicitly pins this "return None" behavior with the rationale "somebody else pushed to our branch shape" — but because the branch name is pipeline-id-prefixed (egg/<pipeline_id>/context), a more likely cause is our own prior tick's create_pr failure, not third-party interference.
This was already broken before this PR (the broad except Exception did the same thing), and addressing it requires a different code shape (call create_pr directly when divergence + no recoverable PR), so it's reasonably out of scope. Worth tracking as a follow-up issue.
2. Class placement
ContextBranchDiverged is defined at line 3090 but referenced from create_context_branch at line 2180. Forward references work fine in Python because the reference is inside a method body, but co-locating it with GatewayError (right after, ~line 3088) avoids the "where is this class?" hunt. Optional, defer to author preference.
3. Pre-existing pattern note (not in this PR)
While reading surrounding code I noticed except ValueError, TypeError: (gateway_client.py:2298), except KeyError, ValueError, TypeError: (pipelines.py:9484), and similar at pipelines.py:17455/17483. These parse and work on Python 3.14 as implicit-tuple form (verified via dis.dis), but they're not idiomatic — PEP 8 expects except (X, Y, Z): with explicit parens, and the implicit-tuple form is easy to misread as Python 2 except X, name: syntax. Pre-existing across the codebase and not introduced by this PR — purely flagging in case there's appetite for a cleanup pass. The PR's new code uses correct except Exception: / except ContextBranchDiverged: so it's not at risk.
Verification
- Ran
pytest orchestrator/tests/test_context_pr.py orchestrator/tests/test_create_context_branch.py— 51/51 passed. - Traced control flow through
_open_context_pr_for_pipeline,_recover_existing_context_pr,_persist_context_pr_linkage_on_contract, and the gateway-sidecreate_context_branchraise path. - Verified backward compatibility:
isinstance(ContextBranchDiverged(...), GatewayError)holds. - Verified no other production call sites of
create_context_branchexist (only the one in_open_context_pr_for_pipeline). - Confirmed the call-site wrapper at pipelines.py:19689 catches any exception that escapes the hook, so even if
_persist_context_pr_linkage_on_contractraised unexpectedly the plan→implement transition is not blocked.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Thanks for the careful review — verifying the new branch precedes the broad 1. Adjacent durability gap (post-
|
|
Feedback contract violation detected. The response comment posted during this run violates the contract in Violations: — Authored by egg |
This comment has been minimized.
This comment has been minimized.
* Fix #2582: unify context-PR idempotency at top of hook Replaces the two `except`-handler recovery branches around `create_pr` with a single GitHub-state idempotency check at the top of `_open_context_pr_for_pipeline`. The post-`create_pr`-raised and post-`create_pr`-returned-no-URL recovery branches are deleted; the top-of-hook `list_open_prs` call drives a three-way decision (full match → salvage, head-only match → fail-soft, no match → proceed). Also closes the #2582 wedge: when `create_context_branch` raises divergence AFTER the top-level check has confirmed no PR exists on our head, the divergence is by elimination our prior tick's artifact push that never reached `create_pr`. The hook falls through to the existing fast-forward / no-op push and opens the missing PR. The new typed `ContextBranchDiverged` subclass of `GatewayError` carries the divergence metadata (existing_sha, base_sha) and lets the hook distinguish this case from other gateway failures, which still fail soft as before. Supersedes PR #2579, which targeted the same wedge with an additional layered exception handler. The unified top-level check costs one extra `list_open_prs` round-trip per tick until `contract.pr.context_pr_number` is durably persisted (after which the contract-state fast path skips the lookup entirely), and replaces three recovery handlers with zero — net less code, no layered recovery, and the head-only-mismatch + divergence-with-no-PR cases are now first-class branches of one switch rather than emergent properties of nested exception handling. Tests: - `TestOpenContextPRTopLevelIdempotency` covers the four lookup outcomes plus the divergence-fallthrough wedge fix. - Existing happy-path / short-circuit / fail-soft / durability / adversarial coverage continues to pass unmodified. - `test_raises_when_existing_branch_diverges` now asserts the typed `ContextBranchDiverged` subclass and its recovery metadata, while still preserving the broad `except GatewayError` contract via the subclass relationship. Authored-by: egg * Address review feedback on #2600 - Drop dead try/except in _lookup_existing_context_pr: list_open_prs already filters out items without 'number' and casts to int, so pr['number'] is always a present int by the time we iterate. Trust the producer's contract. (Reviewer suggestion 2.) - Make salvage path's return value isomorphic with the contract-state fast path: both now return 'contract.pr.context_branch or context_branch'. Pure readability nit — both branches return the same value functionally. (Reviewer suggestion 3.) - Document why "by elimination" holds for the ContextBranchDiverged fallthrough: the gateway restricts pushes to egg/-prefixed branches bound to a per-session token, and pipeline_id carries a UUID component, so a divergent SHA on our context branch can only have been produced by a prior tick of this pipeline. (Reviewer suggestion 4.) --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: Escalating to human reviewThis PR has merge conflicts with Root causeWhile this PR was open, #2600 was merged to
The conflicts:
The first three test conflicts are docstring/wording only — both versions assert the same behavior on Why I can't auto-resolveOption A: accept main's version of the
|
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: Aborted — needs human judgmentI attempted to merge Conflicting files
Why this is semantic, not mechanicalPR #2600's commit message (863d299) is explicit:
The two PRs solve the same
These can't be mechanically merged because:
What needs to happenThis PR appears to be superseded rather than complementary. Options for the author/reviewer:
I cannot pick between these without product/architecture context. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: Aborted — PR Superseded by #2600I attempted to merge Root cause: PR #2600 (commit 863d299, "Fix #2582: unify context-PR idempotency at top of hook") merged into
#2600 fixes the same durability gap this PR addresses, but via a different strategy:
Conflicts encountered
The pipelines.py semantic conflict is the load-bearing one — the rest follow from how it's decided. What I need from youThe decision isn't between "your code" vs "their code" — it's a product/architecture decision:
Merge state is fully reset; nothing was pushed. Awaiting direction. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: EscalationI attempted to merge Conflicts found
Why this needs human reviewThis PR appears to have been superseded by #2600 (merged 2026-05-11). The merge commit message of #2600 says so explicitly:
#2600 ships the same Recommended actionClose this PR — the durability gap from #2575 review issue 1 is fixed in main via #2600. If the operator disagrees and wants to keep #2579's layered-handler approach, the merge needs to be hand-resolved by a human who can decide which of the two recovery strategies to keep, since the choice is not derivable from the diffs alone. What I did locally
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: Escalating to Human ReviewI attempted to merge Files with conflicts
Why I'm not auto-resolvingThis PR adds a recovery path inside except ContextBranchDiverged as branch_err:
# ... call _recover_existing_context_pr, persist linkage, returnMain's #2582 took a different approach to closing the same gap: it added a top-of-hook Concretely on main:
Both scenarios — including the one this PR was specifically designed to fix — are now handled by main's structure. The PR's
Mechanically merging would require either:
There's also a non-trivial test rewrite implied: What I need from a humanA scope decision: Is this PR superseded by #2582, and should it be closed? Or is there a defensive layer worth keeping that I'm missing? If keeping, the resolution probably needs the author to:
I aborted the merge ( — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: Aborted — Semantic SupersessionAttempted to merge What happenedThree files conflicted:
Additionally, the auto-merge produced two problems even before conflict resolution:
Why this is not auto-resolvableThe two branches solve the same durability gap (post-push, pre-contract-persist partial failure) with different, overlapping designs:
Main's design is a strict superset of this PR's scenarios:
If I take main's version in every conflict (the natural "merge supersedes" resolution), the resulting tree is byte-identical to RecommendationOne of:
Awaiting direction. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: Escalating — PR superseded by #2600I attempted to merge The semantic collisionPR #2600 ("Fix #2582: unify context-PR idempotency at top of hook", commit 863d299) landed on main after this PR was opened. Its commit message states:
Both PRs target the same
The top-of-hook Conflicting files
Decision neededThis is not a conflict an automated resolver can decide. Options:
Per the conventions: semantic conflicts where both sides modify the same logic differently → escalate. I'm leaving the branch unchanged ( — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: Aborted — Human Review NeededAttempted a The conflictThree files conflicted: This PR (
#2600's commit message explicitly names this wedge:
Why the two approaches don't composeIf we keep this PR's recovery at step 3 on top of
That short-circuits the hook before the fallthrough push + Files that conflicted
What to decideThis PR predates #2600 (#2600 merged 2026-05-11; this PR's commit is from 2026-05-10). The blocking durability issue described in the PR body is real, but
I can't pick between these without product input, so leaving the branch untouched. No commits pushed. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution: Escalating — semantic conflictTried merging Conflicts found
Additionally, git auto-merge produced a duplicate Why this is semantic, not mechanicalThis PR addresses the durability gap by catching
HEAD's The decision neededHas main's PR #2582 superseded this PR?
I'm not equipped to make that product-level call — closing a PR or restructuring it on top of a newer architecture is your decision, not a mechanical merge fix. What I didRan — Authored by egg |
|
egg conflict resolution completed. View run logs |
# Conflicts: # orchestrator/gateway_client.py # orchestrator/routes/pipelines.py # orchestrator/tests/test_create_context_branch.py
There was a problem hiding this comment.
Re-review: nothing left to review. The PR is closed (no merge), and the May 12 merge from main at 9319ad8d took main's side of the conflicts entirely — the merge result tree is identical to its main-side parent cf0f9aa4. The PR's original contributions to gateway_client.py, routes/pipelines.py, tests/test_context_pr.py, and tests/test_create_context_branch.py are no longer present on this branch.
Verification
$ git diff cf0f9aa4 9319ad8d # main parent of merge → PR head
(empty)
$ git log origin/main..pr-2579 --oneline
9319ad8d Merge remote-tracking branch 'origin/main' into pr-2579
$ git log pr-2579..origin/main --oneline
2eff2820 docs: Update build context docs for #2499 fix [doc-updater] (#2637)
The branch is exactly main-at-cf0f9aa4 (one commit behind current main, with the #2637 doc-updater commit missing). There is no PR-authored code delta to scrutinize.
Context (matches the conflict-resolution comment already on this PR)
#2600 ("Fix #2582: unify context-PR idempotency at top of hook", merged as 863d299e) supersedes this PR with a different mechanism for the same wedge — top-of-hook list_open_prs check, then fall through to push + create_pr on ContextBranchDiverged (push is idempotent over the prior tick's commit). The ContextBranchDiverged class and its raise site in create_context_branch are present on main — they originated in this PR's predecessor work but landed via #2600's slightly different docstring shape.
If the author still wants the four divergence-recovery tests as regression coverage for the "branch pushed, PR never created" path (the case the bot's prior conflict comment flagged as still uncovered), they would need to be rewritten against #2600's fall-through behavior in a fresh PR. That work is not on this branch.
Recommendation
No action on this PR. Closed status is correct. Any follow-up regression coverage should land on main in a new PR.
— Authored by egg
|
egg review completed. View run logs 20 previous review(s) hidden. |
Summary
Closes the durability gap called out in PR #2575 review (blocking-severity issue 1).
When the context PR hook partially fails between pushing the artifact commit and persisting
contract.pr.context_pr_number, the next tick'screate_context_branchraises on branch divergence (existing_sha = base_sha+1vsbase = base_sha). The hook's outerexcept Exceptionswallowed it without invoking_recover_existing_context_pr— the pipeline silently never opens a context PR for the rest of its lifetime, even though one is live on GitHub.The narrow real-world trigger is post-push, pre-contract-persist failure (e.g.
save_contractraises after a successfulcreate_pr, orpush_worktree_branchraises after a successful push). Severity is doc-only / fail-soft — slices still ship — but the recovery code was specifically designed for this case (see commit message on #2578) and was half-complete relative to its stated purpose.Changes
orchestrator/gateway_client.py: newContextBranchDiverged(GatewayError)subclass.create_context_branchraises this specifically on existing-SHA divergence instead of the bareGatewayError. Callers that broadly catchGatewayErrorcontinue to work via the subclass relationship.orchestrator/routes/pipelines.py::_open_context_pr_for_pipeline: catchContextBranchDivergedahead of the broadexcept, run_recover_existing_context_pr, persist the salvaged linkage (so subsequent ticks idempotent-skip), and return the branch name. A non-divergenceGatewayErrorstill routes through the broadexceptand fails-soft as today._persist_context_pr_linkage_on_contractso the new recovery path and the existing happy/create_pr-recovery paths share one implementation. No behavior change to the existing paths.TestOpenContextPRRecoverAfterBranchDiverged(4 cases): divergence with a recoverable PR, divergence with no recoverable PR, divergence with mismatchedbase_ref, and non-divergenceGatewayErrorstill fails-soft (no recovery attempted).test_create_context_branch::test_raises_when_existing_branch_diverges: tightened to assert the typedContextBranchDivergedsubclass + its recovery-side metadata (context_branch, existing_sha, base_branch, base_sha), while keeping the broad-catch contract viaisinstance(..., GatewayError).Test plan
.venv/bin/pytest orchestrator/tests/test_context_pr.py orchestrator/tests/test_create_context_branch.py— 51 passed (40 existing + 4 new context-PR + 7 gateway).venv/bin/ruff checkon touched files — cleanOut of scope
The PR #2575 review listed two adjacent observations (logging the swallowed
KeyErrorin_recover_existing_context_pr, and tightening the torn-write claim in_commit_slice_brc_history_to_integration_branch's docstring). Keeping this PR scoped to the blocking durability fix — those can be separate.