Fix #2368: synthetic-session exemption for slice integration-branch pushes - #2370
Conversation
…ushes Multi-slice implement phases were stranded because two correct-in-isolation behaviours collided: * #2028 (gateway): every pipeline-session push without `consensus_push=true` is rejected unless the target is in `INFRASTRUCTURE_BRANCHES`. * #2220 (orchestrator): `create_slice_integration_branch` registers a synthetic pipeline session and pushes `parent:refs/heads/<integration>` via `/api/v1/git/push` so the slice PR's diff is non-empty before agents spawn. Result: every multi-slice pipeline 403'd on the per-slice integration push and 15 slices failed before any agent ran (latent since #2220, only unmasked once #2337 stopped silently demoting multi-slice contracts to monolithic implement). Fix: add a path + flag exemption to the gateway's infrastructure-push bypass — a session whose `synthetic` flag is `True` (only settable by the launcher, since `/api/v1/sessions/create` is gated on `require_launcher_auth`) targeting `egg/<base>/(slice|phase)-N` is treated as orchestrator infrastructure. No new orchestrator-role push surface is introduced; agent BRC enforcement is unchanged. Bonus: the slice integration-branch name now derives from `pipeline.branch` directly, so a qualifier suffix (`-v3`, `-backend`) is preserved. Two qualified pipelines for the same issue would otherwise collide in the `egg/issue-N/slice-M` namespace. Tests cover the four parent shapes (issue / qualified-issue / JIRA / legacy `phase-N`), confirm a non-synthetic session is still blocked on slice-shaped branches (the synthetic flag is load-bearing), and add an orchestrator regression assert that `create_slice_integration_branch` precedes `_run_concurrent_phase` so a future refactor can't re-introduce the spawn-then-push ordering bug.
There was a problem hiding this comment.
Review summary
The synthetic-session exemption in gateway.py is sound: Session.synthetic is only ever set through /api/v1/sessions/create, which is gated by @require_launcher_auth, and the regex check provides defense-in-depth. The is True identity comparison correctly defends against MagicMock truthiness shenanigans. The qualifier-preservation fix at routes/pipelines.py:10989 is the right call — pipeline_branch is the canonical source.
But this PR ships with a cross-module silent no-op in stacked_pr_reconciler.py that this PR's own changes expose. Requesting changes.
BLOCKING
orchestrator/stacked_pr_reconciler.py:181 — qualifier dropped, orphan detection silently never matches for qualified pipelines
The reconciler computes the issue branch like this:
issue_number = contract.issue.number if contract.issue is not None else None
pipeline_id = contract.contract_key
issue_branch = f"egg/issue-{issue_number}" if issue_number else f"egg/{pipeline_id}"For a qualified pipeline (pipeline_id="issue-2261-v3", issue.number=2261), this resolves to egg/issue-2261 — the unqualified form. But after this PR, the orchestrator's slice loop creates branches as egg/issue-2261-v3/slice-1 (qualifier preserved — that's literally what routes/pipelines.py:10989 was just fixed to do). So the lookup at line 189:
slice_branch = f"{issue_branch}/{slice_.id}" # → "egg/issue-2261/slice-1"
pr = pr_by_head.get(slice_branch) # never matches; real head is egg/issue-2261-v3/slice-1… returns None for every qualified-pipeline slice, the orphan loop continues, and find_orphaned_child_prs silently returns []. No log line, no alert, no orphan ever healed. For any qualified or -backend-style pipeline (which is exactly the use case pipeline_branch was added for), the stacked-PR auto-rebase you wired in #2137 is dead.
This is the textbook "cross-module silent no-op" pattern: each module's branch derivation is internally consistent — the slice creator uses pipeline.branch, the reconciler uses issue.number — but they disagree on shape. Both compile. Both have passing tests. The wiring dead-ends.
This PR is in the area (pipeline_branch propagation is the bonus fix), so per "pre-existing issues are blocking when the PR modifies the surrounding code," it must be fixed here.
Suggested fix:
issue_branch = f"egg/{contract.contract_key}"Contract.contract_key already returns the canonical pipeline_id for all three pipeline shapes (issue-driven issue-N, qualified issue-N-vX, JIRA ENG-1234). The two-branch ternary becomes obsolete. Then add a regression test in test_stacked_pr_reconciler* that builds a contract with pipeline_id="issue-N-v3" and asserts the orphan is detected when the head is egg/issue-N-v3/slice-X.
NON-BLOCKING
gateway/gateway.py:1084 — regex broader than the documented branch shape
_SLICE_INTEGRATION_BRANCH_RE = re.compile(r"^egg/[A-Za-z0-9][A-Za-z0-9_/-]*/(?:slice|phase)-\d+$")The character class includes /, so the regex matches egg/foo/bar/baz/slice-1 — multi-segment bases the orchestrator never produces. Not exploitable today (g.session.synthetic is True is the load-bearing check, and synthetic sessions only come from launcher-auth code paths), but the documented shape is egg/<base>/(slice|phase)-N where <base> is one segment. Tighten to:
_SLICE_INTEGRATION_BRANCH_RE = re.compile(r"^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\d+$")Drop the / from the second character class. Same defense-in-depth; less surface.
orchestrator/tests/test_slice_run_loop_integration.py:1533 — confusing-looking except syntax
except AttributeError, ValueError:
passThis actually parses on Python 3.14 — it's an ExceptHandler(type=Tuple(...)), not the Python-2 except E, name: syntax — but it reads like a parse error. Standard idiom is except (AttributeError, ValueError):. Also worth checking whether the branch is even reachable: setattr on a Pydantic BaseModel raises ValidationError, not AttributeError/ValueError. If this is dead code, drop the try/except entirely. (The same shape exists at _make_pipeline() line 91 — that's the precedent, but precedent doesn't make it less confusing for the next reader.)
orchestrator/tests/test_slice_run_loop_integration.py — test setup duplicated from _make_pipeline()
The new test_qualified_pipeline_branch_propagates_to_slice_branches duplicates the PipelineConfig/Contract boilerplate from _make_pipeline(). Factor a parameterized helper that takes pipeline_id and returns the configured pipeline, so the qualified case is one line.
Dual audit events on one push
A successful slice-integration push emits both push_slice_integration_exempt and push_infrastructure_exempt (with exempt_type="slice_integration_branch"). The PR description frames this as intentional for distinct operator tracing, which is fine — but document it in the audit-log reference (docs/reference/audit-log.md if it exists) or in a comment near the second audit_log call so an operator grepping for push_infrastructure_exempt doesn't conclude this was an infra push.
What's right
syntheticfield validation atgateway.py:8113correctly usesisinstance(..., bool)— defends against truthy ints.- The exemption is applied before the pipeline-session push block (line 1392) and the push-target enforcement (line 1441), so
is_infrastructure_push=Truecleanly short-circuits both. File restrictions (1569) and agent restrictions (1774) also correctly skip on this flag. - The 7 gateway tests cover the matrix that matters: standard issue, qualified, JIRA, legacy
phase-N, non-synthetic blocked, synthetic-but-non-slice blocked, and audit event verification. - The qualifier fix at
routes/pipelines.py:10989is structurally correct —pipeline_branchis the canonical source. The bug it fixes is a real one.
Once the reconciler issue_branch is fixed and there's a regression test for it, this is good to go.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The blocking fix: stacked_pr_reconciler.find_orphaned_child_prs derived
issue_branch from contract.issue.number, producing 'egg/issue-N' even
when the contract carried a qualifier (e.g. pipeline_id='issue-N-v3').
Since this PR's bonus fix made create_slice_integration_branch preserve
the qualifier ('egg/issue-N-v3/slice-M'), the reconciler's per-slice
lookup never matched and orphan detection silently no-op'd for every
qualified pipeline. Switch to 'egg/{contract.contract_key}' which
returns the canonical pipeline-id for all three shapes (issue-driven,
qualified, JIRA).
Non-blocking nits from the review:
- Tighten _SLICE_INTEGRATION_BRANCH_RE to single-segment bases (drop /
from the second character class).
- Replace dead Python-2-shaped 'except E1, E2' try/except in the
slice-loop test helper with a direct PipelineConfig kwarg
construction; refactor the new qualified-pipeline test to reuse the
helper instead of duplicating the boilerplate.
- Document the intentional dual audit emission
(push_slice_integration_exempt + push_infrastructure_exempt with
exempt_type=slice_integration_branch) inline so operators don't
conclude the latter was an infra push.
Tests: regression coverage for the qualifier-preservation bug
(qualified-pipeline orphan detection + walk-up resolver) and for the
tightened regex (multi-segment base rejected).
|
Thanks for the careful review. Pushed DispositionBLOCKING
NON-BLOCKING
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review summary
The blocking issue from the prior review — the qualifier-dropping silent no-op in stacked_pr_reconciler.py — is correctly fixed at orchestrator/stacked_pr_reconciler.py:190:
issue_branch = f"egg/{contract.contract_key}"I verified Contract.contract_key (shared/egg_contracts/models.py:764-775): it returns pipeline_id directly when set (covering qualified issue-N-v3 and JIRA ENG-1234) and synthesises issue-{issue.number} for legacy contracts. The producer side at orchestrator/routes/pipelines.py:10989 (issue_branch = pipeline_branch) and the consumer side now agree on shape for all three documented pipeline kinds. Both regression tests trace correctly:
test_qualified_pipeline_id_preserves_qualifier_in_issue_branch— slice-2-only contract, walk-up falls back toegg/issue-2137-v3(qualified pipeline branch), notegg/issue-2137. ✓test_qualified_pipeline_id_walks_up_to_qualified_ancestor— 3-level chain, slice-1's qualified branch surfaces fromextant_branches. ✓
The other four review items are also resolved:
- Regex tightening (
gateway/gateway.py:1086):[A-Za-z0-9_-]*(no/) — the documented single-segment shape, withtest_synthetic_session_multi_segment_base_blockedlocking the rejection ofegg/foo/bar/slice-1. except E, F:syntax: removed; replaced with directPipelineConfig(...)kwargs in both call sites.- Test setup duplication:
_make_pipeline(pipeline_id=..., issue_number=...)collapses the qualified case to one line;branchis now derived frompipeline_idso the helper is correct for qualified and JIRA pipelines too. All 27 existing_make_pipeline()callers retain identical behaviour (defaultpipeline_id="issue-9999"→branch="egg/issue-9999"). - Dual audit-event documentation: inline comment added at
gateway/gateway.py:1348-1356. Wording is slightly tangled — the rationale given (orchestrator-specific path "also visible") really applies to the first event, not the generic one being commented on — but the operator-guidance ("filter viaexempt_type") is the practically useful part. Non-blocking.
Non-blocking observations
orchestrator/routes/pipelines.py:10982 — degenerate fallback shape mismatches the reconciler
The slice loop's third fallback computes pipeline_branch = f"egg/{pipeline_id}/work" when both pipeline.branch and pipeline.issue_number are unset. The reconciler would derive issue_branch = f"egg/{contract.contract_key}" = egg/{pipeline_id} (no /work), so producer creates egg/{pipeline_id}/work/slice-N but consumer looks up egg/{pipeline_id}/slice-N — same cross-module silent no-op shape as the bug just fixed.
This path is degenerate (it only fires when neither branch nor issue_number is set on the pipeline, which doesn't happen for issue-driven, qualified, or JIRA pipelines), and the regex would reject the /work/ shape from the synthetic-session exemption path anyway, so the slice-integration push would 403 before it hit the reconciler. Pre-existing and out of scope, but worth flagging as latent technical debt: either the fallback should drop /work, or it should be removed entirely (it's not clear who relies on it).
Comment wording at orchestrator/stacked_pr_reconciler.py:186 — "pre-#2137 split"
The historical reference to "#2137" is a stretch — the bug is the issue-number-based derivation that this PR introduced and removed in the same lifetime, not a pre-#2137 artefact. Re-reading the comment, I'd drop the "pre-#2137" framing and just describe the bug ("the prior f"egg/issue-{issue_number}" ternary hard-coded the unqualified shape"). Cosmetic.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Thanks for the re-review. The PR is approved; I'm following up on the two non-blocking observations. DispositionNON-BLOCKING
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review summary
The delta since 47c9f56 is a single commit (cbd60fe) that rewrites the comment block at orchestrator/stacked_pr_reconciler.py:179-189. No behavioural changes; the substantive fixes (synthetic-session exemption in gateway.py, qualifier preservation in routes/pipelines.py, reconciler contract_key-based derivation, regression tests) are unchanged from the approved revision.
The comment fix correctly addresses the cosmetic non-blocking observation I raised:
- Drops the misleading "pre-#2137 split" framing — the issue-number ternary it described was both introduced and removed inside PR #2370, not a pre-#2137 artefact.
- Describes the prior code directly:
f"egg/issue-{issue_number}"ternary hard-coded the unqualified shape, silently no-op'ing orphan detection on qualified pipelines. - Preserves the load-bearing technical claim — that
contract_keyaligns the consumer's lookup shape with the producer'spipeline.branchpropagation — which is what justifies the one-line behavioural fix immediately below.
Re-verified the surrounding logic is still correct: issue_branch = f"egg/{contract.contract_key}" at line 190 returns the canonical pipeline id for all three pipeline shapes (verified against shared/egg_contracts/models.py Contract.contract_key), and the regression tests in test_stacked_pr_reconciler.py exercise the qualified-pipeline path end-to-end.
The non-blocking observation about the egg/{pipeline_id}/work degenerate fallback in routes/pipelines.py:10982 was deferred to #2371 with rationale; that's a fine disposition for a latent issue that doesn't fire in any documented pipeline shape and is gated by the regex anyway.
Approving.
— Authored by egg
|
egg review completed. View run logs 6 previous review(s) hidden. |
…ushes (#2370) * Fix #2368: synthetic-session exemption for slice integration-branch pushes Multi-slice implement phases were stranded because two correct-in-isolation behaviours collided: * #2028 (gateway): every pipeline-session push without `consensus_push=true` is rejected unless the target is in `INFRASTRUCTURE_BRANCHES`. * #2220 (orchestrator): `create_slice_integration_branch` registers a synthetic pipeline session and pushes `parent:refs/heads/<integration>` via `/api/v1/git/push` so the slice PR's diff is non-empty before agents spawn. Result: every multi-slice pipeline 403'd on the per-slice integration push and 15 slices failed before any agent ran (latent since #2220, only unmasked once #2337 stopped silently demoting multi-slice contracts to monolithic implement). Fix: add a path + flag exemption to the gateway's infrastructure-push bypass — a session whose `synthetic` flag is `True` (only settable by the launcher, since `/api/v1/sessions/create` is gated on `require_launcher_auth`) targeting `egg/<base>/(slice|phase)-N` is treated as orchestrator infrastructure. No new orchestrator-role push surface is introduced; agent BRC enforcement is unchanged. Bonus: the slice integration-branch name now derives from `pipeline.branch` directly, so a qualifier suffix (`-v3`, `-backend`) is preserved. Two qualified pipelines for the same issue would otherwise collide in the `egg/issue-N/slice-M` namespace. Tests cover the four parent shapes (issue / qualified-issue / JIRA / legacy `phase-N`), confirm a non-synthetic session is still blocked on slice-shaped branches (the synthetic flag is load-bearing), and add an orchestrator regression assert that `create_slice_integration_branch` precedes `_run_concurrent_phase` so a future refactor can't re-introduce the spawn-then-push ordering bug. * Address #2370 review: fix qualifier-dropping orphan no-op + nits The blocking fix: stacked_pr_reconciler.find_orphaned_child_prs derived issue_branch from contract.issue.number, producing 'egg/issue-N' even when the contract carried a qualifier (e.g. pipeline_id='issue-N-v3'). Since this PR's bonus fix made create_slice_integration_branch preserve the qualifier ('egg/issue-N-v3/slice-M'), the reconciler's per-slice lookup never matched and orphan detection silently no-op'd for every qualified pipeline. Switch to 'egg/{contract.contract_key}' which returns the canonical pipeline-id for all three shapes (issue-driven, qualified, JIRA). Non-blocking nits from the review: - Tighten _SLICE_INTEGRATION_BRANCH_RE to single-segment bases (drop / from the second character class). - Replace dead Python-2-shaped 'except E1, E2' try/except in the slice-loop test helper with a direct PipelineConfig kwarg construction; refactor the new qualified-pipeline test to reuse the helper instead of duplicating the boilerplate. - Document the intentional dual audit emission (push_slice_integration_exempt + push_infrastructure_exempt with exempt_type=slice_integration_branch) inline so operators don't conclude the latter was an infra push. Tests: regression coverage for the qualifier-preservation bug (qualified-pipeline orphan detection + walk-up resolver) and for the tightened regex (multi-segment base rejected). * Drop pre-#2137 framing from reconciler comment Reviewer noted the historical reference is misleading — the issue-number-based derivation that this comment documents was introduced and removed in the same PR (#2370), not a pre-#2137 artefact. Re-frame as describing the prior ternary directly. Cosmetic; no behaviour change. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
…llowlist (#2381) * Fix #2372: exempt synthetic slice integration pushes from role-path allowlist Synthetic-session slice integration-branch creation pushes (#2368) bypass the pipeline-session push block (#2370) but were still hitting the role-based path-allowlist check at gateway/gateway.py:1515 because that gate was the only file-restriction gate not keyed on ``is_infrastructure_push``. When ``get_changed_files_in_push`` falls back to a ``main``-based diff (the target ref doesn't exist yet on origin for a branch-creation push), the changed-files set surfaces every file modified on the parent branch's history — drafts, contracts, brc-history — none of which the ``coder`` role can write, so the push is falsely blocked. Mirror the existing ``not is_infrastructure_push`` gate already present on the anchor (l.1785), phase (l.1822), and agent-restriction (l.1580) checks. The gate is set by the synthetic-session + slice-shape branch exemption (l.1316-1318), so this only widens the exemption surface for pushes that already cleared the launcher-secret check. * Clarify infrastructure-push exemption motivations in role-check comment Per egg-reviewer feedback on PR #2381: the comment block at gateway.py:1511 conflated two distinct motivations for skipping the role-based path-allowlist on infrastructure pushes. Split them out: (1) checkpoint/pipeline-state branches are orphan/disjoint-history infrastructure writes where role restrictions don't conceptually apply, and (2) slice integration-branch creation pushes diff against `main` because the target ref doesn't exist yet, which would surface the parent branch's full history. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
…2395) * Fix #2393: push slice integration branch by SHA, not parent ref name The orchestrator's per-pipeline worktree is checked out on `<branch>/work` and has no local ref matching `<parent_branch>` — only `refs/remotes/origin/<parent_branch>` after a fetch. The old refspec `<parent_branch>:refs/heads/<integration_branch>` resolved the source side against the worktree's local refs and failed every slice push with `src refspec X does not match any` (the fourth latent regression in the slice-DAG creation chain after #2369, #2370, #2372). Fix: fetch the parent into the local odb, resolve to a SHA on origin via `git ls-remote`, then push `<sha>:refs/heads/<integration_branch>`. Pushing an explicit SHA bypasses local ref-name resolution entirely and surfaces "parent missing on origin" as a clear failure instead of git's confusing src-refspec error. * Rename test_fetch_failure_is_non_fatal to be precise about what it pins The reviewer on #2395 noted the original name over-promised: it implies fetch failure is recoverable in production, but the test only verifies that create_slice_integration_branch doesn't short-circuit when fetch_branch returns False — the SHA must still happen to be in the local odb for the subsequent push to succeed. Rename to test_fetch_returning_false_does_not_short_circuit and clarify the docstring with the production-failure note. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Summary
Closes #2368.
Multi-slice implement phases were stranded because two correct-in-isolation behaviours collided:
consensus_push=trueis rejected unless the target is inINFRASTRUCTURE_BRANCHES(onlyCHECKPOINT_BRANCHandPIPELINE_STATE_BRANCH).create_slice_integration_branchregisters a synthetic pipeline session and pushesparent:refs/heads/<integration>via/api/v1/git/pushso the slice PR's diff is non-empty before agents spawn.Every multi-slice pipeline 403'd on the per-slice integration push and 15 slices failed before any agent ran. Latent since #2220 — only unmasked once #2337 stopped silently demoting multi-slice contracts to monolithic implement.
Changes
gateway/gateway.py— extend the infrastructure-push bypass: a session whosesyntheticflag isTrue(identity check, not truthy — defends against MagicMock-style fakes and any future non-bool storage) targetingegg/<base>/(slice|phase)-Nis treated as orchestrator infrastructure. Thesyntheticflag can only be set by the launcher (/api/v1/sessions/createis gated onrequire_launcher_auth), so a sandboxed agent's session token cannot reach this exemption. Audit emits a distinctpush_slice_integration_exemptevent and tags the existingpush_infrastructure_exemptwithexempt_type=slice_integration_branch.orchestrator/gateway_client.py— docstring ofcreate_slice_integration_branchnow points at the actual exemption mechanism (it previously referenced "decision-15 / orchestrator role's existing prefix-allowlist," which never gated the push).orchestrator/routes/pipelines.py— bonus fix from Multi-slice implement phase fails: create_slice_integration_branch blocked by pipeline-session push enforcement #2368: slice integration-branch derives frompipeline.branch, so a qualifier suffix (-v3,-backend) is preserved. Two qualified pipelines for the same issue would otherwise collide in theegg/issue-N/slice-Mnamespace.No new orchestrator-role push surface is introduced; agent BRC enforcement is unchanged.
Tests
test_pipeline_push_block.py::TestSliceIntegrationBranchExemption): 7 new cases — issue / qualified-issue / JIRA / legacyphase-Nparent shapes accepted; non-synthetic session on slice-shaped branch still blocked (the synthetic flag is load-bearing); synthetic session on a non-slice branch still blocked (the regex is load-bearing); audit event emits the expected exempt type.test_slice_run_loop_integration.py::TestSliceIntegrationBranchPrecedesAgentSpawn,TestSliceIntegrationBranchQualifierPreserved): assertcreate_slice_integration_branchprecedes_run_concurrent_phase(catches the Slice the implement phase into a DAG of independent units #2220-style ordering regression directly), assert_run_concurrent_phaseis NOT invoked when integration-branch creation fails, assert qualified-pipeline branches propagate toparent_branchandintegration_branch.Recovery for
issue-2261-v3Once this lands:
restart_phase implementreruns the slice loop with eachcreate_slice_integration_branchcall now succeeding. No data migration required.Test plan
make test— 4654 passed (3 pre-existing path-traversal failures intest_phase_api.py::TestPathTraversalProtectionare unrelated and present onmain).make lint— clean.