Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d68e5aa
Initialize SDLC contract for issue #3393
Jul 1, 2026
0082a5e
Refine analysis for issue #3393 — multi-repo pipelines
Jul 1, 2026
1d784ee
Simplifier: plain-language analysis summary for issue #3393
Jul 1, 2026
1cf2721
Simplifier v2: three repos[0] collapse sites, not two (reviewer_refin…
Jul 1, 2026
6de331a
Refine analysis v2 for issue #3393 — address all three v1 NACKs
Jul 1, 2026
3cbe231
Persist agent statefile writes before refine sync
Jul 1, 2026
75f804d
Persist statefiles after refine phase
Jul 1, 2026
a7725c1
Simplifier v3: render resolved HITL cq-1 + refiner-v2 per-repo conven…
Jul 1, 2026
ec86622
Refine analysis v3 for issue #3393 — fold in operator's cq-1 resolution
Jul 1, 2026
ffe14d5
Restore simplifier's 3393-analysis-human.md (undo v3 clobber)
Jul 1, 2026
7188c68
Persist agent statefile writes before refine sync
Jul 1, 2026
46524ae
Persist statefiles after refine phase
Jul 1, 2026
be7a70e
Simplifier iteration-2: operator ratifications rendered in plain terms
Jul 1, 2026
e0365ad
Refine analysis v5 for issue #3393 — record iteration-1 operator ruli…
Jul 2, 2026
ae64cf5
Simplifier iteration-2 v2: restore primary-repo escape hatch in namin…
Jul 2, 2026
28bb119
Persist statefiles after refine phase
Jul 2, 2026
1f26506
Persist HITL resolution after refine phase gate
Jul 2, 2026
e82a8df
Architect plan for issue #3393 — multi-repo pipelines: data-model roo…
Jul 2, 2026
29bd6cb
plan(risk_analyst): risk assessment for multi-repo pipelines #3393
Jul 2, 2026
8f05e17
Plan for issue #3393 — multi-repo pipelines (6-slice linear chain)
Jul 2, 2026
be27854
Simplifier: plain-language plan summary for issue #3393
Jul 2, 2026
a3ea1e4
Plan v2 for #3393 — realign slice-1 to two-layer resolver design (ris…
Jul 2, 2026
1bd0957
Plan v3 for #3393 — pin slice-5 merge-gate failure semantics (reviewe…
Jul 2, 2026
5c4b4e3
Persist statefiles after plan phase
Jul 2, 2026
f162bf2
Persist HITL resolution after plan phase gate
Jul 2, 2026
d779b0c
Slice-1 (#3393): repo dimension in contract & pipeline schema
Jul 2, 2026
5a00e0c
test(#3393): slice-1 repo-dimension tests (task-1-3)
Jul 2, 2026
d1becb0
test(#3393): realign stale schemaVersion pins to 1.4 (slice-1, task-1-3)
Jul 2, 2026
797a590
Slice-1 (#3393): mirror schemaVersion 1.4 default into contract.schem…
Jul 2, 2026
c78ed67
Persist BRC history for slice-1 (#2548)
Jul 2, 2026
ccf113d
Merge main into slice-1: take main's contract state (newer, post-#3418)
jwbron Jul 2, 2026
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
5,250 changes: 5,250 additions & 0 deletions .egg-state/brc-history/3393-implement-slice-1.json

Large diffs are not rendered by default.

5,081 changes: 5,081 additions & 0 deletions .egg-state/brc-history/3393-implement-slice-1.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .egg/schemas/contract.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"type": "string",
"description": "Schema version for migrations",
"pattern": "^[0-9]+\\.[0-9]+$",
"default": "1.3"
"default": "1.4"
},
"issue": {
"oneOf": [
Expand Down
119 changes: 118 additions & 1 deletion orchestrator/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from typing import Any, Literal, NamedTuple

from agent_model_resolution import OVERSEER_TIER_MODELS
from egg_contracts.models import PipelinePhase
from egg_contracts.models import PipelinePhase, Slice
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from slice_id_validation import SLICE_ID_PATTERN

Expand Down Expand Up @@ -1117,6 +1117,32 @@ def validate_concurrent_phases(cls, v: list[str]) -> list[str]:
return v


class RepoSpec(BaseModel):
"""One repository a pipeline operates in (#3393, multi-repo pipelines).

A pipeline carries a *list* of these on ``Pipeline.repos`` — one entry
per repository the run coordinates PRs across. Each repo pins its own
``base_branch`` (PRs in that repo are opened against it), so the repo
set is genuinely list-shaped: there is no primary+secondary shape baked
into the data model, and nothing may assume ``len(repos)`` ∈ {1, 2}.

The ``repo`` is ``owner/name``-shaped — the full slug, not the bare
short name — so two repos with the same short name under different
owners stay distinct downstream (the worktree map is re-keyed by the
full slug in slice 3).
"""

repo: str = Field(..., description="Repository in owner/name format")
base_branch: str | None = Field(
default=None,
description=(
"Base branch PRs in this repo are opened against. When None, "
"auto-detected from the repo's default branch (mirrors the "
"legacy ``Pipeline.base_branch`` semantics)."
),
)


class Pipeline(BaseModel):
"""Complete state of an SDLC pipeline execution.

Expand All @@ -1134,6 +1160,23 @@ class Pipeline(BaseModel):
default=None,
description="Base branch for PR creation. When None, auto-detected from repo's default branch.",
)
repos: list[RepoSpec] = Field(
default_factory=list,
description=(
"The full list of repositories this pipeline coordinates PRs "
"across (#3393, multi-repo pipelines). List-shaped end to end — "
"an arbitrary number of repos, each with its own ``base_branch``; "
"no two-repo special case and no primary+secondary shape is baked "
"in. The legacy singleton ``repo``/``base_branch`` scalars above "
"are kept in sync by ``_sync_repos_and_legacy_singleton``: a "
"pipeline persisted before this field existed synthesizes "
"``repos=[RepoSpec(repo, base_branch)]`` from the singleton on "
"load, and ``repos[0]`` is mirrored back onto the scalars so "
"legacy readers keep working until slice 3 rewires them. Use the "
"``primary_repo`` property (not ``repos[0]``) for naming/"
"defaulting; use ``resolve_slice_repo`` to resolve a slice's repo."
),
)
prompt: str | None = Field(default=None, description="User prompt for prompt-driven pipelines")
status: PipelineStatus = Field(
default=PipelineStatus.PENDING, description="Overall pipeline status"
Expand Down Expand Up @@ -1366,6 +1409,80 @@ def resolve_decision(self, decision_id: str, resolution: str) -> HITLDecision |
return decision
return None

@model_validator(mode="after")
def _sync_repos_and_legacy_singleton(self) -> Pipeline:
"""Keep ``repos`` and the legacy ``repo``/``base_branch`` in sync (#3393).

Back-compat bridge for the multi-repo migration. Runs at every load
(``mode="after"``) and is idempotent:

* **Synthesize (legacy → list):** when ``repos`` is empty but the
legacy singleton ``repo`` is set — a pipeline persisted before the
``repos`` field existed — synthesize
``repos=[RepoSpec(repo=self.repo, base_branch=self.base_branch)]``
so list-shaped consumers see the repo.
* **Mirror (list → legacy):** when ``repos`` is non-empty, mirror
``repos[0]`` back onto the legacy ``repo``/``base_branch`` scalars
so legacy readers (and the three ``repos[0]`` collapse sites that
slice 3 has not yet rewired) keep working.

No behavioural change for N=1 pipelines: a single-repo pipeline's
singleton and its one-element ``repos`` list agree after this runs.
A repo-less pipeline (local mode, ``repo is None``) is left untouched
— ``repos`` stays empty and the scalars stay ``None``.

The absent-``Slice.repo``⇒primary default is NOT resolved here; that
lives in ``resolve_slice_repo`` (a slice needs the pipeline as a
second input, which only the orchestrator layer has).
"""
if not self.repos:
if self.repo is not None:
self.repos = [RepoSpec(repo=self.repo, base_branch=self.base_branch)]
else:
primary = self.repos[0]
self.repo = primary.repo
self.base_branch = primary.base_branch
return self

@property
def primary_repo(self) -> str | None:
"""The pipeline's primary repo — ``repos[0].repo`` (#3393).

The INTENTIONAL named-primary accessor for naming and defaulting.
Explicitly NOT one of the three ``repos[0]`` collapse sites removed
in slice 3: those collapse the agent-facing repo *set* to a single
repo, discarding the others; this exposes a named primary while the
full ``repos`` list stays available to every other consumer.

Returns ``None`` only for a repo-less pipeline (local mode with no
``repo`` and no ``repos``) — the ``_sync_repos_and_legacy_singleton``
validator guarantees a singleton-only pipeline has a populated
``repos`` by the time this is read, so the ``self.repo`` fallback is
just belt-and-braces for a pre-validation read.
"""
if self.repos:
return self.repos[0].repo
return self.repo


def resolve_slice_repo(slice: Slice, pipeline: Pipeline) -> str | None:
"""Resolve the repository a slice operates in (#3393, multi-repo pipelines).

This is the RUNTIME home of the absent-``Slice.repo``⇒primary default —
the contract migration deliberately leaves ``Slice.repo`` as ``None`` on a
legacy load because the ``Contract`` model has no repo field and cannot see
the orchestrator ``Pipeline`` (risk_analyst R1 / architect aeb3528). This
resolver takes the pipeline as a second input, so it is the correct layer
to apply the default:

* an explicit ``slice.repo`` (``owner/name``) wins, else
* fall back to ``pipeline.primary_repo``.

For an N=1 pipeline every slice's ``repo`` is ``None`` and this returns the
single repo — behaviourally identical to the pre-multi-repo world.
"""
return slice.repo if slice.repo else pipeline.primary_repo


class PipelineEvent(BaseModel):
"""Event emitted during pipeline execution."""
Expand Down
155 changes: 155 additions & 0 deletions orchestrator/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from datetime import UTC, datetime

import pytest
from egg_contracts.models import Slice
from models import (
PHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN,
AgentExecution,
Expand All @@ -20,7 +21,9 @@
PipelineConfig,
PipelinePhase,
PipelineStatus,
RepoSpec,
resolve_consensus_timeout_minutes,
resolve_slice_repo,
)


Expand Down Expand Up @@ -1206,3 +1209,155 @@ def test_apply_phase_exists(self):
assert PipelinePhase.APPLY == "apply"
# StrEnum: value-equal to string for serialisation symmetry.
assert PipelinePhase("apply") == PipelinePhase.APPLY


# ---------------------------------------------------------------------------
# Multi-repo pipelines (#3393, slice-1) — orchestrator layer.
#
# The ORCHESTRATOR half of the two-layer repo-dimension design: RepoSpec,
# Pipeline.repos (+ the legacy-singleton sync validator), the primary_repo
# accessor, and the runtime resolve_slice_repo default. The CONTRACT half
# (Slice.repo + the schema 1.4 migration) is covered in
# shared/egg_contracts/tests/test_models.py.
# ---------------------------------------------------------------------------


class TestRepoSpec:
"""``RepoSpec`` carries an owner/name repo + an optional base branch."""

def test_repo_required(self):
spec = RepoSpec(repo="jwbron/egg")
assert spec.repo == "jwbron/egg"

def test_base_branch_defaults_to_none(self):
spec = RepoSpec(repo="jwbron/egg")
assert spec.base_branch is None

def test_base_branch_can_be_set(self):
spec = RepoSpec(repo="jwbron/egg", base_branch="main")
assert spec.base_branch == "main"


class TestPipelineReposSynthesisAndMirror:
"""``_sync_repos_and_legacy_singleton`` bridges the legacy scalar ⇄ list (AC-e)."""

def test_legacy_singleton_synthesizes_one_element_repos(self):
# A pipeline persisted before the ``repos`` field existed: only the
# legacy ``repo``/``base_branch`` singleton is set. The validator
# synthesizes a one-element list from it.
pipeline = Pipeline(id="issue-3393", repo="jwbron/egg", base_branch="main")
assert len(pipeline.repos) == 1
assert pipeline.repos[0].repo == "jwbron/egg"
assert pipeline.repos[0].base_branch == "main"

def test_explicit_repos_mirror_back_onto_legacy_singleton(self):
# When ``repos`` is provided, repos[0] is mirrored back onto the
# legacy scalars so the not-yet-rewired (slice-3) readers keep working.
pipeline = Pipeline(
id="issue-3393",
repos=[
RepoSpec(repo="jwbron/egg", base_branch="main"),
RepoSpec(repo="jwbron/other", base_branch="develop"),
],
)
assert pipeline.repo == "jwbron/egg"
assert pipeline.base_branch == "main"
# The full list is preserved — nothing collapses to a single repo.
assert [r.repo for r in pipeline.repos] == ["jwbron/egg", "jwbron/other"]

def test_repo_less_pipeline_left_untouched(self):
# Local-mode pipeline (no repo, no repos): stays empty, scalars None.
pipeline = Pipeline(id="local-run")
assert pipeline.repos == []
assert pipeline.repo is None
assert pipeline.base_branch is None

def test_synthesis_is_idempotent_on_reload(self):
pipeline = Pipeline(id="issue-3393", repo="jwbron/egg", base_branch="main")
reloaded = Pipeline.model_validate(pipeline.model_dump())
assert len(reloaded.repos) == 1
assert reloaded.repos[0].repo == "jwbron/egg"
assert reloaded.repo == "jwbron/egg"
assert reloaded.base_branch == "main"

def test_arbitrary_repo_count_supported(self):
# No two-repo special case: an arbitrary-length list round-trips.
specs = [RepoSpec(repo=f"jwbron/r{i}", base_branch="main") for i in range(5)]
pipeline = Pipeline(id="issue-3393", repos=specs)
assert [r.repo for r in pipeline.repos] == [f"jwbron/r{i}" for i in range(5)]


class TestPrimaryRepo:
"""``primary_repo`` is the intentional named-primary accessor (AC-f)."""

def test_primary_repo_is_repos_zero(self):
pipeline = Pipeline(
id="issue-3393",
repos=[
RepoSpec(repo="jwbron/egg", base_branch="main"),
RepoSpec(repo="jwbron/other", base_branch="develop"),
],
)
assert pipeline.primary_repo == pipeline.repos[0].repo == "jwbron/egg"

def test_primary_repo_from_legacy_singleton(self):
pipeline = Pipeline(id="issue-3393", repo="jwbron/egg")
assert pipeline.primary_repo == "jwbron/egg"

def test_primary_repo_none_for_repo_less_pipeline(self):
pipeline = Pipeline(id="local-run")
assert pipeline.primary_repo is None


class TestResolveSliceRepo:
"""``resolve_slice_repo`` applies the absent-Slice.repo⇒primary default (AC-d)."""

def test_explicit_slice_repo_wins(self):
pipeline = Pipeline(
id="issue-3393",
repos=[
RepoSpec(repo="jwbron/egg", base_branch="main"),
RepoSpec(repo="jwbron/other", base_branch="develop"),
],
)
slice_ = Slice(id="slice-1", name="in the secondary repo", repo="jwbron/other")
assert resolve_slice_repo(slice_, pipeline) == "jwbron/other"

def test_absent_slice_repo_falls_back_to_primary(self):
pipeline = Pipeline(
id="issue-3393",
repos=[
RepoSpec(repo="jwbron/egg", base_branch="main"),
RepoSpec(repo="jwbron/other", base_branch="develop"),
],
)
slice_ = Slice(id="slice-1", name="no explicit repo")
assert slice_.repo is None
assert resolve_slice_repo(slice_, pipeline) == "jwbron/egg"

def test_absent_slice_repo_with_repo_less_pipeline_is_none(self):
pipeline = Pipeline(id="local-run")
slice_ = Slice(id="slice-1", name="no repo anywhere")
assert resolve_slice_repo(slice_, pipeline) is None


class TestN1BackCompat:
"""N=1 pipelines behave identically to the pre-multi-repo world (AC-g)."""

def test_single_repo_pipeline_resolves_to_that_repo(self):
pipeline = Pipeline(id="issue-3393", repo="jwbron/egg", base_branch="main")
# One synthesized repo, primary is it, and every (repo-less) slice
# resolves to the single repo — no behavioural change.
assert len(pipeline.repos) == 1
assert pipeline.primary_repo == "jwbron/egg"
slice_ = Slice(id="slice-1", name="the only slice")
assert resolve_slice_repo(slice_, pipeline) == "jwbron/egg"

def test_single_repo_pipeline_round_trips_unchanged(self):
pipeline = Pipeline(id="issue-3393", repo="jwbron/egg", base_branch="main")
dumped = pipeline.model_dump()
reloaded = Pipeline.model_validate(dumped)
assert reloaded.repo == "jwbron/egg"
assert reloaded.base_branch == "main"
assert len(reloaded.repos) == 1
assert reloaded.primary_repo == "jwbron/egg"
Loading
Loading