Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions orchestrator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,16 +420,23 @@ def cmd_pipelines_list(args: argparse.Namespace) -> int:

def cmd_pipelines_create(args: argparse.Namespace) -> int:
"""Create a new pipeline."""
from routes.pipelines import _ensure_pipeline_work_ref
from state_store import get_state_store

repo_path = Path(args.repo_path) if args.repo_path else Path.cwd()
store = get_state_store(repo_path)

# Route through the same normalisation as the HTTP `create_pipeline`
# endpoint so a CLI-provisioned pipeline gets the ``<branch>/work``
# shape and slice integration branches at ``<branch>/slice-N`` can
# coexist as siblings (#2399).
branch = _ensure_pipeline_work_ref(args.branch or f"egg/issue-{args.issue}")

try:
pipeline = store.create_pipeline(
issue_number=args.issue,
repo=args.repo,
branch=args.branch or f"egg/issue-{args.issue}",
branch=branch,
)

if args.json:
Expand Down Expand Up @@ -956,7 +963,10 @@ def create_parser() -> argparse.ArgumentParser:
create_parser = pipelines_subparsers.add_parser("create", help="Create a pipeline")
create_parser.add_argument("--issue", type=int, required=True, help="Issue number")
create_parser.add_argument("--repo", required=True, help="Repository (owner/repo)")
create_parser.add_argument("--branch", help="Branch name (default: egg/issue-N)")
create_parser.add_argument(
"--branch",
help="Branch name (default: egg/issue-N; normalised to egg/issue-N/work)",
)
create_parser.add_argument("--repo-path", help="Repository path")
create_parser.add_argument("--json", action="store_true", help="Output as JSON")
create_parser.set_defaults(func=cmd_pipelines_create)
Expand Down
44 changes: 32 additions & 12 deletions orchestrator/concurrent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def get_logger(name: str, **kwargs) -> logging.Logger: # type: ignore[misc]
get_peer_consensus_tracker,
)
from review_graph import ReviewGraph, get_review_graph_for_phase
from slice_id_validation import SLICE_ID_PATTERN

logger = get_logger("orchestrator.concurrent_executor")

Expand Down Expand Up @@ -270,8 +271,20 @@ def get_worktree_branch(
# cannot see in the slice PR's diff. We honour the
# pipeline's existing branch as the issue prefix when set,
# otherwise fall back to the issue-number / pipeline id.
#
# The pipeline tip is pushed to ``egg/<id>/work`` (#2399), so
# the slice integration branch lives as a sibling of ``/work``
# under ``egg/<id>/`` — strip the trailing ``/work`` from the
# pipeline branch to get the namespace root.
issue = self.pipeline.issue_number or self.pipeline.id
issue_branch = self.pipeline.branch or f"egg/issue-{issue}"
# Structural check (≥2 slashes, last segment ``work``) — see
# ``_slice_namespace_root`` in ``routes/pipelines.py`` for
# the matching helper. A degenerate single-segment input
# like ``egg/work`` is treated as the root itself rather
# than collapsing to ``egg``.
if issue_branch.count("/") >= 2 and issue_branch.rsplit("/", 1)[1] == "work":
issue_branch = issue_branch.rsplit("/", 1)[0]
normalised_slice = slice_id if slice_id.startswith("slice-") else f"slice-{slice_id}"
# Defense-in-depth: re-validate the normalised slice id
# shape before embedding it in a git ref. The contract-
Expand All @@ -281,10 +294,10 @@ def get_worktree_branch(
# validation must not be able to smuggle path separators
# or shell metacharacters in via this seam (per the
# security reviewer's defense-in-depth suggestion on the
# v1 BRC review).
import re

if not re.fullmatch(r"slice-[0-9]+", normalised_slice):
# v1 BRC review). The pattern is the canonical one shared
# with the signal handlers (#2403) and the operator restart
# route (#2410) — see ``slice_id_validation``.
if not SLICE_ID_PATTERN.fullmatch(normalised_slice):
raise ValueError(
f"slice_id={slice_id!r} does not match the canonical shape ``slice-<N>``"
)
Expand All @@ -298,21 +311,28 @@ def get_worktree_branch(
def get_slice_integration_branch(self, slice_id: str) -> str:
"""Return the shared integration branch for a slice's BRC.

Each slice has its own integration branch under the pipeline
branch — ``egg/issue-N/slice-M`` — that the per-role work
branches rebase onto. Roots base off the pipeline branch
directly; child slices base off their parent slice's
integration branch.
Each slice has its own integration branch as a sibling of the
pipeline tip under ``egg/<id>/`` — ``egg/issue-N/slice-M`` —
that the per-role work branches rebase onto. Roots base off the
pipeline branch directly (``egg/issue-N/work``); child slices
base off their parent slice's integration branch.

The pipeline tip is pushed to ``egg/<id>/work`` (#2399), so the
slice integration branch lives as a sibling of ``/work`` under
``egg/<id>/`` — strip the trailing ``/work`` from the pipeline
branch to get the namespace root.

The slice id is regex-validated for defense-in-depth (see
``get_worktree_branch``).
"""
issue = self.pipeline.issue_number or self.pipeline.id
issue_branch = self.pipeline.branch or f"egg/issue-{issue}"
# Structural check (≥2 slashes, last segment ``work``) — see
# ``_slice_namespace_root`` in ``routes/pipelines.py``.
if issue_branch.count("/") >= 2 and issue_branch.rsplit("/", 1)[1] == "work":
issue_branch = issue_branch.rsplit("/", 1)[0]
normalised_slice = slice_id if slice_id.startswith("slice-") else f"slice-{slice_id}"
import re

if not re.fullmatch(r"slice-[0-9]+", normalised_slice):
if not SLICE_ID_PATTERN.fullmatch(normalised_slice):
raise ValueError(
f"slice_id={slice_id!r} does not match the canonical shape ``slice-<N>``"
)
Expand Down
Loading
Loading