Fix #2582: unify context-PR idempotency at top of hook - #2600
Conversation
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
There was a problem hiding this comment.
Review summary
No blocking issues. This is a clean structural improvement: three layered except recovery handlers around create_pr collapse into a single top-of-hook GitHub-state lookup with a four-way switch. The control flow is easier to follow, the test coverage maps directly onto each leg of the switch, and the typed ContextBranchDiverged is exactly the right granularity — narrow enough that "broad-catch callers keep working" isn't a hand-wave, and the recovery metadata is reusable by other future callers.
I traced the data flow end-to-end (_open_context_pr_for_pipeline at orchestrator/routes/pipelines.py:9733, _lookup_existing_context_pr at :9508, _persist_context_pr_linkage_on_contract at :9563, create_context_branch at orchestrator/gateway_client.py:2089, and the call site at orchestrator/routes/pipelines.py:19915) and walked the race / restart / divergence cases the docstrings claim. They check out.
Approving with non-blocking suggestions below.
Non-blocking suggestions
1. Non-idiomatic except clauses in code this PR touches
orchestrator/routes/pipelines.py:9556 (_lookup_existing_context_pr):
except KeyError, ValueError, TypeError:This is valid Python 3 — the parser treats KeyError, ValueError, TypeError as a tuple expression in except position — but it visually overlaps with the Python 2 form except E, name: (which is a SyntaxError in Py3), so every reader pauses. Prefer the explicit-parens form for clarity:
except (KeyError, ValueError, TypeError):Same pattern exists at orchestrator/gateway_client.py:2294 in list_open_prs (the function this PR's new lookup helper consumes). Worth fixing in the same area while you're here — the project review rules call out that pre-existing inconsistencies in code a PR touches are worth fixing at the point you're already in the area. Two-line change.
2. The defensive except in _lookup_existing_context_pr may be dead code
The try/except around int(pr["number"]) (orchestrator/routes/pipelines.py:9554-9557) covers KeyError / ValueError / TypeError. But the producer side (orchestrator/gateway_client.py:2302-2317) already filters out items where number is None or not head_ref and casts via int(number) itself, so by the time _lookup_existing_context_pr iterates open_prs, pr["number"] is always a present int. Either:
- Drop the try/except entirely and trust the producer's contract (this matches the simplification spirit of the PR), or
- Keep the defensive cast but tighten the comment to acknowledge it's belt-and-braces over an already-normalised input.
Neither is a correctness issue — just dead code to clean up.
3. Minor: return-value drift between fast path and salvage path
Fast path (:9888):
return contract.pr.context_branch or context_branchSalvage path (:9928):
return context_branchAfter _persist_context_pr_linkage_on_contract succeeds on the salvage path, contract.pr.context_branch == context_branch, so both paths return the same value. If persistence fails, salvage returns the locally-computed name and the in-memory contract still has the name written via save_contract regardless (the helper's commit/push failures don't unwind the in-memory mutation). So no functional drift. But since the call site at :19915 discards the return value, this is purely a readability nit — consider returning contract.pr.context_branch or context_branch from the salvage path too, just to keep the two branches isomorphic.
4. The "by elimination, this is our own prior tick's push" assumption
The ContextBranchDiverged fallthrough at :9948 asserts in prose that divergence + no-open-PR implies our own prior artifact push. This holds because:
- Gateway restricts pushes to
egg/-prefixed branches and binds them to a session token, so no agent in another pipeline can write toegg/<pipeline_id>/context. - Pipeline IDs include a UUID component, so cross-pipeline collisions are vanishingly unlikely.
Both invariants are external to this hook. Worth a one-line comment pointing at them so a future reader who's wondering "what if it's not my prior tick" has the answer next to the assumption. As-is, the comment says "by elimination" but doesn't show the elimination.
5. PR-body / out-of-scope ack
PR description says it supersedes #2579 and that #2579 should be closed when this lands. Worth doing in the same merge — the typed exception is introduced here, so leaving #2579 open creates two paths to the same wedge fix in the index.
What I checked
_lookup_existing_context_prcorrectly distinguishes full-match / head-only / no-match / error. ✓- Salvage path persists via the shared helper before returning. ✓
ContextBranchDivergedfallthrough proceeds to fetch → worktree add → copy → commit → push → create_pr; subsequent push is a fast-forward over the prior tick's commit so the assumption that "this is recoverable in-band" holds. ✓ContextBranchDivergedsubclassesGatewayError, so the broad-catch test atorchestrator/tests/test_create_context_branch.py:172still matches via subclass — confirmed by the updated assertion at line 199. ✓- Concurrent two-tick race past the contract-state fast path converges: both tick-A and tick-B's
create_context_branchcalls no-op-on-same-SHA or raise divergence, one winscreate_pr, the other fails-soft and the next tick salvages. ✓ - The recovery loop is bounded: the hook only runs once per pipeline at plan→implement, so wedge cases that survive a tick require orchestrator restart in the plan phase. This is a pre-existing limitation, unchanged by this PR.
list_open_prslimit of 200 (gateway_client.py:2242) is a pre-existing cap; not introduced by this PR but worth noting that on a repo with >200 open PRs the lookup could miss a same-head match. Thehead=context_branchfilter wasn't pushed down to theghcall (it's a post-fetch filter in_lookup_existing_context_pr) so this is a latent risk — out of scope here but worth tracking if egg ever runs against a repo that busy.- Tests cover every leg of the new switch (full match / head-only / list raises / divergence fallthrough / non-divergence error). Existing
test_create_context_branch.pyregression test updated to assert the typed subclass and metadata.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- 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.)
|
Thanks for the thorough review. Per-item disposition below. Non-blocking suggestions1. Non-idiomatic So the comma form is the enforced style here, not a pre-existing 2. The defensive 3. Return-value drift between fast path and salvage path — 4. The "by elimination, this is our own prior tick's push" assumption —
5. PR-body / out-of-scope ack (close #2579 when this lands) — What I checked
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review summary
Approving. The delta since 9fe5c23 is a single commit (4671f25) that addresses three of the five non-blocking suggestions from the prior review. The other two were declined with reasons I find defensible. No new code paths or blocking issues introduced.
Per-item verification
1. except KeyError, ValueError, TypeError: style — disagree, accepted
The producer's claim that ruff format strips the parens under target-version = "py314" is consistent with PEP 758 (parens-free except in Python 3.14) and with the existing codebase: orchestrator/gateway_client.py:2294 uses the same comma form (except ValueError, TypeError:). The style is project-enforced, not a pre-existing inconsistency to fix in passing. Closing this thread.
2. Dead try/except in _lookup_existing_context_pr — fixed
orchestrator/routes/pipelines.py:9554-9558: the try/except KeyError, ValueError, TypeError: is gone, replaced with a single pr_number = int(pr["number"]) and a four-line comment pointing at gateway_client.py:2302-2317.
I re-verified the invariant the comment depends on:
list_open_prs(gateway_client.py:2302-2317) filters items wherenumber is None or not head_ref(line 2309) and casts viaint(number)before appending (line 2313).- If the inner
int(number)ever raised, the outerexcept Exceptionat line 2319 catches everything and returns[]. - So by the time
_lookup_existing_context_priteratesopen_prs, every entry haspr["number"]as a presentint.
The int(pr["number"]) is now redundant-but-harmless (the cast is a no-op on an already-int), and the comment correctly attributes the invariant to the producer. ✓
Defense-in-depth note: the surrounding _open_context_pr_for_pipeline call site at :19925 is wrapped in a broad except Exception (:19931), so even if list_open_prs's contract is ever violated and the cast raises, the hook still fails-soft. Not a concern.
3. Salvage-path return-value alignment — fixed
orchestrator/routes/pipelines.py:9929: salvage path now returns contract.pr.context_branch or context_branch, matching the fast path at :9889. Functionally equivalent (the outer contract was loaded with context_pr_number is None, and any prior write to contract.pr.context_branch would have used the same f"egg/{pipeline_id}/context" formula), so the or falls through to the local context_branch in every reachable case. The only production writer to pr.context_branch is _persist_context_pr_linkage_on_contract at :9612, which writes the same value computed at :9879. Schema check (shared/egg_contracts/models.py:432) confirms the field is str | None. ✓
4. "By elimination" justification — fixed
orchestrator/routes/pipelines.py:9957-9965: the new paragraph explains the two invariants — gateway push restriction to egg/-prefixed branches under a per-session token, and the UUID component of pipeline_id. Invariant (b) alone is sufficient even without (a), but spelling out both is fine. Reasonable docstring hardening. ✓
5. Closing #2579 on merge — operator action
Producer correctly noted this is outside agent scope. Flagging again here so it doesn't get lost: on merge, please close #2579 with a pointer to this PR, since the typed ContextBranchDiverged exception lands here and #2579 would carry duplicate intent against the same wedge.
What I checked on the new delta
- The diff is exactly
orchestrator/routes/pipelines.py(+15/-5) pergh api .../compare. No drift into unrelated files. list_open_prsnormalisation invariant still holds atgateway_client.py:2302-2317. ✓- Tests in
TestOpenContextPRTopLevelIdempotencystill pin every leg of the lookup switch — none of them touched malformednumbervalues, so dropping the try/except doesn't slacken coverage. ✓ test_recovers_when_pr_already_exists_full_matchstill assertsresult == "egg/issue-2548/context"after the return-value change at:9929— confirmed via the test body at:643. ✓
— Authored by egg
This comment has been minimized.
This comment has been minimized.
| egg is addressing review feedback... |
|
Thanks for the re-review. No new actionable items to address — confirming the disposition against the per-item verification in the re-review:
No code changes this round — working tree is clean against — Authored by egg |
|
egg feedback addressed. View run logs 4 previous review(s) hidden. |
Summary
Closes #2582 by lifting context-PR idempotency to the top of
_open_context_pr_for_pipelineand driving it from GitHub state rather than contract state. This supersedes #2579 with the same end-state and a smaller surface — three recovery handlers replaced with one switch.What changes
In
orchestrator/routes/pipelines.py::_open_context_pr_for_pipeline:contract.pr.context_pr_numberfast-path, calllist_open_prs(head=context_branch)once.create_pr-raised and post-create_pr-returned-no-URL recovery branches.)base_ref) → fail-soft, return None. (Preserves the no-duplicate semantics that the old recovery code enforced.)create_context_branch+ push +create_pras today.create_context_branchdivergence after step 2 confirms no PR exists is by elimination our prior tick's artifact push that never reachedcreate_pr. New typedContextBranchDiverged(GatewayError)lets the hook fall through to the artifact push (fast-forward / no-op over the prior tick's commit) andcreate_pr. Any other gateway error fromcreate_context_branchstill fails-soft.create_pr— they're unreachable now that step 2 catches both states.In
orchestrator/gateway_client.py:ContextBranchDivergedsubclassesGatewayErrorso existing broad-catch callers (and the test intest_create_context_branch.py) keep working unchanged.create_context_branchraises the typed subclass withexisting_sha/base_shametadata.Extracted
_persist_context_pr_linkage_on_contractso the salvage and happy paths share one implementation.Why not the narrow patch (#2579's shape)?
#2579 closes the wedge by adding a third
excepthandler aroundcreate_context_branch. That makes a 2-week-old hook carry three recovery branches for the same underlying problem (the two-step push+create-pr API has no atomicity). The unified top-level check addresses the root cause: ask GitHub up-front whether a PR exists on our head, and decide once. No layered exception handling, no nested recovery, the head-only-mismatch and divergence-with-no-PR cases become first-class branches of one switch rather than emergent properties of nested handlers.Cost: one extra
list_open_prsround-trip per tick untilcontext_pr_numberis durably persisted. After that, the contract-state fast path short-circuits the lookup. Net: less code, no layered recovery.Test plan
.venv/bin/pytest orchestrator/tests/test_context_pr.py orchestrator/tests/test_create_context_branch.py— 47 passed (5 new top-level-idempotency tests + 42 existing).make lint— clean (no new warnings).make test— 4194 passed (changeset-aware sweep).TestOpenContextPRTopLevelIdempotency:test_recovers_when_pr_already_exists_full_match— the contract-not-persisted partial failure.test_head_only_match_against_different_base_fails_soft— preserves no-duplicate semantics from the old recovery path.test_list_open_prs_raises_fails_soft— defensive fail-soft when the GH-state lookup itself errors.test_opens_pr_when_create_context_branch_diverges_with_no_pr— the Context PR hook: open PR after divergence when no recoverable PR exists #2582 wedge fix: divergence after a confirmed no-PR check falls through to create_pr.test_non_divergence_create_context_branch_error_fails_soft— non-divergence gateway errors keep their fail-soft semantics.test_raises_when_existing_branch_divergesupdated to assert the typedContextBranchDivergedsubclass and its recovery metadata while still preserving the broadexcept GatewayErrorcontract via the subclass relationship.Out of scope
list_open_prs's best-effort-empty-on-error semantics (other callers depend on it).Relationship to #2579
This PR supersedes #2579. Once this lands, #2579 should be closed — the typed
ContextBranchDivergedexception is introduced here, and the unified idempotency layer covers both partial-failure cases #2579 was scoped to address (contract-not-persisted and the divergence wedge in #2582).Authored-by: egg