diff --git a/.egg-state/agent-outputs/2548-architect-output.json b/.egg-state/agent-outputs/2548-architect-output.json new file mode 100644 index 0000000000..6aa6975d0c --- /dev/null +++ b/.egg-state/agent-outputs/2548-architect-output.json @@ -0,0 +1,454 @@ +{ + "issue": 2548, + "phase": "plan", + "agent": "architect", + "title": "Context PR for refine/plan artifacts + per-slice implement-phase BRC histories", + "summary": "Architecture analysis for landing refine/plan analysis docs, agent transcripts, and refine/plan BRC histories on a dedicated 'context PR' (egg//context, base=) that slice-1 stacks on top of, plus splitting the implement-phase BRC history at write time into per-slice files (-implement-slice-.{json,md}) committed to each slice's integration branch before its PR opens. Hard switchover for new pipelines only; no backfill (HITL decision-4).", + + "problem_statement": { + "description": "Slice PRs today carry only the slice's code diff; reviewers cannot see the refine/plan analysis docs, the planner's plan, the agent transcripts, or the BRC consensus history (proposals/NACKs/ACKs/CONFIRMs) that produced any of it. All those artifacts live on egg//work which is not in any slice PR's review surface against main. There is also no automatic mechanism today to merge egg//work into main at all — analysis/plan/BRC artifacts never reach main under the current setup.", + "goals": [ + "Every PR reviewers approach carries (or links to) enough in-tree, in-diff context to understand the strategic decision (refine + plan) and the consensus that approved each artifact.", + "Refine/plan analysis docs, agent transcripts, and refine/plan BRC histories durably reach main via a reviewable PR.", + "Each slice PR's diff carries its own slice-specific implement-phase BRC consensus record so the consensus history is reviewable on the slice PR itself.", + "Close the 'work-branch never reaches main' gap by anchoring the slice stack on a context branch that does target main (per HITL feedback Q1)." + ], + "non_goals": [ + "Backfilling in-flight pipelines (HITL decision-4: hard switchover, new pipelines only).", + "BRC-reviewing the context PR itself — it is doc-only auto-open, humans review on the PR (HITL decision-3).", + "Merge-gating on context PR before slicing starts — the pipeline does not block on its merge (HITL decision-3)." + ] + }, + + "hitl_resolutions": { + "decision-1": { + "question": "Where should refine/plan analysis docs and BRC consensus history live so they are reviewable on PRs targeting main?", + "selected": "Dedicated context PR (new egg//context branch based on main; slice-1 stacks on top of it)", + "implication": "Introduce a new branch egg//context and a new orchestrator step that creates it + opens a doc-only PR after plan_gate approval and before slicing starts." + }, + "decision-2": { + "question": "How should the implement-phase BRC consensus history be split so each slice PR carries its own slice's history?", + "selected": "Split file at write time: orchestrator writes to .egg-state/brc-history/-implement-slice-.{json,md} (one per slice; no aggregate file)", + "implication": "_write_brc_history() (and its callers) must be slice-aware in the implement phase. The legacy -implement.{json,md} aggregate path is removed for new pipelines (hard switchover per decision-4). _rewrite_brc_history_for_pr() must enumerate per-slice files." + }, + "decision-3": { + "question": "Should the new context PR go through BRC review, or land as a doc-only PR auto-merged after plan_gate approval?", + "selected": "Doc-only auto-open (orchestrator opens; humans review on the PR; pipeline does not block on its merge before slicing)", + "implication": "No new BRC roster for the context PR; no plan_gate dependency on context PR merge; the orchestrator just opens the PR and proceeds to spawn slices. The context PR may be merged before, after, or never relative to slice PRs (humans choose)." + }, + "decision-4": { + "question": "Rollout scope: which pipelines should the context-PR / per-slice BRC mechanism apply to?", + "selected": "Hard switchover, no backfill / no backwards compat", + "implication": "Implementation does not need code paths for in-flight pipelines that pre-date the change. Pydantic schema additions still need default=None so on-disk contracts from before the change can still be loaded for read-only inspection, but no migration path is required." + }, + "decision-5": { + "question": "Where in the stack should the context PR sit (and what should slice-1's PR base be)?", + "selected": "Context PR base= (NOT hardcoded main), slice-1 base=egg//context. Slice-1 stacks on context; context merges first, then slices cascade-merge.", + "implication": "All branch-base resolution must read from pipeline.base_branch / contract.repo.base_branch — never hardcode 'main'. The orchestrator already exposes this via Pipeline.repo / contract.base_branch; reuse it." + }, + "feedback-1.Q1": { + "question": "Slice PRs target egg//work today and there's no automatic 'merge work to main' PR. Is the work-branch-as-base intentional and out of scope, or should this issue fix it?", + "answer": "In scope — fix as part of #2548. The work→main gap is the deeper problem; closing it via the context PR mechanism is on-scope.", + "implication": "After this lands, the slice stack anchors on egg//context (which targets ) instead of egg//work. The terminal slice's eventual cascade-merge reaches the base branch through context. egg//work continues to exist as the agent-output integration branch but no longer needs a 'merge to main' path." + }, + "feedback-1.Q2": { + "question": "Should the context PR title/body reuse contract.pr.{title,description} or get separate context_title / context_description fields?", + "answer": "Add separate contract.pr.context_title / pr.context_description fields. Lets context framing differ from slice framing.", + "implication": "PRMetadata gains optional context_title and context_description fields. The planner emits both. Slice PRs continue to use contract.pr.{title,description,test_plan,manual_steps}; the context PR uses contract.pr.{context_title,context_description}." + }, + "feedback-1.Q3": { + "question": "Should the context PR include per-phase agent transcripts (.egg-state/agent-outputs/-{refine,plan}-*.{md,json}) or only final analysis.md / plan.md / BRC histories?", + "answer": "Include agent transcripts as well (.egg-state/agent-outputs/-refine-*.md and similar). Maximum transparency.", + "implication": "The context-branch commit includes .egg-state/drafts/-{analysis,plan}.md, .egg-state/brc-history/-{refine,plan}.{json,md}, AND .egg-state/agent-outputs/-{refine,plan}-*.{md,json}." + }, + "feedback-1.Q4": { + "question": "Who authors the final 'commit BRC history to slice integration branch' commit?", + "answer": "Orchestrator-authored. Matches existing _commit_statefiles_to_worktree pattern; coder/tester gateway boundaries forbid them from pushing under .egg-state/brc-history/ anyway.", + "implication": "Reuse _commit_statefiles_to_worktree() (or a thin wrapper) and the orchestrator's existing synthetic-session push pattern. Do NOT route through coder/tester sessions." + }, + "feedback-1.Q5": { + "question": "Should the implement-phase aggregate BRC also live on the context PR, or is per-slice sufficient?", + "answer": "Per-slice BRC only — each slice PR carries its own; no cross-slice aggregate file (consistent with decision-4).", + "implication": "No cross-slice aggregate -implement.{json,md} is written. Each slice PR carries -implement-slice-.{json,md}. The context PR carries refine/plan BRC only." + } + }, + + "current_architecture": { + "branch_topology_today": { + "pipeline_branch": "egg//work — checked out as the orchestrator's per-pipeline worktree (after #2399). Receives all .egg-state writes (drafts, brc-history, contracts) and is where slice-1 currently bases from.", + "slice_branches": "egg//slice-N — created by gateway.create_slice_integration_branch() with parent_branch resolved as egg//work for slice-1, egg//slice-(N-1) for slice-N>1. Slice PRs base on parent_branch (line 12638 of orchestrator/routes/pipelines.py).", + "merge_target": "Slice PRs target egg//work or another slice branch — never main. egg//work itself is never automatically merged anywhere. Analysis/plan/BRC artifacts therefore never reach the base branch under current behavior.", + "orphan_fallback": "stacked_pr_reconciler._resolve_extant_new_base() (orchestrator/stacked_pr_reconciler.py:87-132) walks up dependencies[0] looking for an extant ancestor; ultimate fallback is pipeline_branch (egg//work)." + }, + "brc_history_today": { + "writer": "_write_brc_history() (orchestrator/routes/pipelines.py:8110-8260) writes .egg-state/brc-history/-.{md,json} from the message store, keyed only by phase name (no slice context).", + "shape": "One file per phase: -refine.{md,json}, -plan.{md,json}, -implement.{md,json} (cross-slice aggregate today), -pr.{md,json}.", + "persistence": "_persist_phase_brc_history() (lines 8355-8400) writes + commits via _commit_statefiles_to_worktree() at phase boundaries; called from complete_phase REST handler and the _run_pipeline auto-advance block.", + "pr_rewrite": "_rewrite_brc_history_for_pr() (lines 8265-8330) iterates completed phases and rewrites each phase's BRC history file before PR creation, then commits." + }, + "statefile_commit_primitive": { + "function": "_commit_statefiles_to_worktree() at orchestrator/routes/pipelines.py:7179-7330. Stages files matching the pipeline_identifier prefix or pipeline_id, commits idempotently. All call sites are orchestrator-internal (lines 7593, 8311, 8392, 15909, 16453, 16917, 17096, 17466, 18117).", + "push_pattern": "Caller invokes spawner.gateway.push_worktree_branch() afterwards under a synthetic launcher-authenticated session. The pattern is the canonical 'orchestrator commits .egg-state and pushes' flow; coder/tester gateway boundaries forbid them from this surface." + }, + "gateway_endpoints": { + "create_slice_integration_branch": "orchestrator/gateway_client.py:1696-1830. Pushes :refs/heads/ via a synthetic launcher session. Gated by _SLICE_INTEGRATION_BRANCH_RE in gateway/gateway.py matching egg//(slice|phase)-N — the regex does NOT today admit egg//context.", + "create_pr": "orchestrator/gateway_client.py:1160-1252. Generic PR creation (used by _auto_create_pr today). Accepts title/body/head/base; reusable for the context PR.", + "create_slice_pr": "orchestrator/gateway_client.py:1257-1330. Slice-specific PR creation with program-narrative wiring (post-#2541, #2543). Not reused for context PR — different framing." + }, + "contract_schema": { + "PRMetadata": "shared/egg_contracts/models.py:371-426. Fields: title, description, test_plan, manual_steps, deferred_actions. No context_title / context_description / context_pr_number / context_branch fields today.", + "Slice": "shared/egg_contracts/models.py:230-303. Carries id, name, dependencies, parent_branch_at_creation, commit, review_feedback. No per-slice BRC path / per-slice PR metadata fields.", + "no_SliceMetadata": "There is no SliceMetadata model. Per-slice BRC paths are implicit in the filename convention." + }, + "phase_transition_path": { + "complete_phase": "orchestrator/routes/phases.py:769 (the REST handler). Resolves the 3-way HITL gate (#2004); on approve+accept, transitions through PHASE_TRANSITIONS (line 56-61) to the next phase.", + "auto_advance": "_run_pipeline (orchestrator/routes/pipelines.py:15429+) auto-advances phases via PHASE_HANDLERS once HITL clears.", + "implement_entry": "_run_implement_phase_slices() (line 12230) is the entry point that drives the slice DAG. The hook for 'create context PR' must run AFTER plan completion + plan_gate approval, BEFORE this function spawns the first slice." + }, + "decomposition_constraint": "orchestrator/routes/pipelines.py is ~16,400 lines and being decomposed in #2261 (slice-15). Pre-allocated submodule clusters are documented in orchestrator/CLAUDE.md: _run_loop/, _concurrent_phase/, _pr_lifecycle/, _worktree_ops/. New code from this issue should slot into the appropriate cluster boundaries so it lands cleanly when #2261 slice-15 ships, OR ride alongside the existing monolith with clear function names." + }, + + "constraints": { + "technical": [ + "Gateway file-boundary enforcement: only the orchestrator (and refine/plan agents writing to .egg-state/drafts) can push under .egg-state/brc-history/. The 'commit per-slice BRC history before slice PR opens' step MUST be orchestrator-authored.", + "Branch base must be pipeline.base_branch (or contract.repo.base_branch), NOT hardcoded 'main' (HITL decision-5).", + "_SLICE_INTEGRATION_BRANCH_RE in gateway/gateway.py currently admits egg//(slice|phase)-N but NOT egg//context — a regex extension or a new gateway endpoint is required.", + "Stacked-PR reconciler invariants: _resolve_extant_new_base() falls back to pipeline_branch when the ancestor chain is gone. With slice-1 anchored on context branch, the fallback should be context_branch (or pipeline.base_branch) — but only if the contract carries a context_branch reference; otherwise the legacy fallback should still apply.", + "BRC message store is keyed by phase name today. Per-slice splitting requires slice context to be available at write time. The implement-phase _write_brc_history() callers must be enriched to pass slice_id, OR the message-store filter must include slice metadata.", + "_rewrite_brc_history_for_pr() must enumerate per-slice files. The 'completed phases' iteration becomes per-(phase, slice_id) for the implement phase only.", + "Pydantic v2 schema additions on PRMetadata must default to None so loading older on-disk contracts during read-only inspection doesn't fail (decision-4 hard switchover means writes assume new fields, but reads of historic contracts must remain non-fatal)." + ], + "business_scope": [ + "Hard switchover (decision-4): no backfill for in-flight pipelines. issue-2474-v2 (the reproduction case) does NOT get a retroactive context PR.", + "Doc-only auto-open (decision-3): the context PR opens automatically and the pipeline does NOT block on its human merge. Slice-1 spawns immediately after the context PR is opened, regardless of merge state.", + "Adjacent issues already merged: #2541 (slice PRs attributed to orchestrator), #2543 (every slice carries program narrative), #2538 (slice PR titles cleaned). These remain authoritative for slice PR title/body composition; the context PR is independent." + ], + "dependencies": [ + "_commit_statefiles_to_worktree() is the canonical statefile commit primitive — reuse, don't replace.", + "gateway.create_slice_integration_branch() pattern is the canonical 'create branch on origin' primitive — extend the regex (or add a sibling endpoint) rather than introduce a new push pathway.", + "gateway.create_pr() is reusable for the context PR — no new gateway endpoint needed for PR creation itself.", + "Pipeline's base_branch must be reachable at the orchestrator's context-branch creation step. It is — Pipeline.repo carries it via the contract / pipeline metadata." + ] + }, + + "design": { + "summary": "Add a new orchestrator post-plan-gate step that (1) creates egg//context from , (2) commits the refine/plan artifacts to it, (3) opens a doc-only PR with title/body from contract.pr.{context_title,context_description}, and (4) records the resulting PR number + branch on contract.pr. Modify _run_one_slice_inner so root slices base on context_branch (when present) instead of pipeline_branch. Modify _write_brc_history() and its implement-phase callers to emit per-slice files. Modify the orphan reconciler fallback to prefer context_branch over pipeline_branch when the chain is gone.", + + "components": [ + { + "id": "C1", + "name": "Context branch + context PR creation", + "responsibility": "After plan completion and plan_gate approval, create egg//context from on origin, commit refine/plan artifacts and agent transcripts to it, open a doc-only PR.", + "key_files": [ + "orchestrator/routes/pipelines.py — new function _create_context_pr(pipeline, worktree_repo_path, spawner, gateway_mode); call site in the auto-advance block in _run_pipeline (after _persist_phase_brc_history for plan, before _run_implement_phase_slices)", + "orchestrator/gateway_client.py — extend create_slice_integration_branch to admit a context branch name OR add a sibling create_branch_from_remote(parent_branch, branch_name) helper", + "gateway/gateway.py — extend _SLICE_INTEGRATION_BRANCH_RE (or a new exemption regex) to admit egg//context" + ], + "behavior": [ + "Resolve base_branch = pipeline.base_branch or contract.repo.base_branch (NEVER hardcode 'main').", + "Create egg//context on origin from base_branch via gateway (synthetic launcher session, idempotent).", + "Materialize a worktree on egg//context (mirror of orchestrator's per-pipeline worktree pattern). Copy + commit the artifact set (analysis.md, plan.md, refine BRC, plan BRC, refine+plan agent transcripts) directly. The artifacts are already on egg//work — copy from the work worktree's filesystem.", + "Push egg//context to origin via the orchestrator's existing synthetic-session push.", + "Call gateway.create_pr(repo=pipeline.repo, title=contract.pr.context_title, body=contract.pr.context_description, head='egg//context', base=base_branch, agent_role='orchestrator', mode=gateway_mode).", + "Persist the PR URL/number on contract.pr.context_pr_number and the branch on contract.pr.context_branch via egg-contract / Pydantic update." + ], + "failure_handling": "If context branch creation OR PR open fails, log and continue WITHOUT setting context_pr_number — slice-1 then falls back to pipeline_branch (legacy behavior). Do NOT block slicing — decision-3 says doc-only auto-open. Surface the failure as a STATUS broadcast / overseer alert." + }, + { + "id": "C2", + "name": "Per-slice implement-phase BRC history", + "responsibility": "Split implement-phase BRC history at write time into -implement-slice-.{json,md} per slice. Drop the cross-slice aggregate.", + "key_files": [ + "orchestrator/routes/pipelines.py — _write_brc_history() (lines 8110-8260): add a slice_id parameter (Optional[str]) that, when set + phase=='implement', changes the output filename to -implement-slice-.{md,json}", + "orchestrator/routes/pipelines.py — _persist_phase_brc_history() (lines 8355-8400): when phase=='implement', iterate over slices in scope and call _write_brc_history(slice_id=...) per slice; do NOT write the aggregate", + "orchestrator/routes/pipelines.py — _rewrite_brc_history_for_pr() (lines 8265-8330): for the implement phase, enumerate per-slice files instead of the single aggregate", + "orchestrator/routes/pipelines.py — slice-completion path in _run_one_slice_inner (around line 12631 before create_slice_pr): write the slice's BRC history, commit via _commit_statefiles_to_worktree, push to the slice integration branch BEFORE create_slice_pr opens the PR" + ], + "behavior": [ + "Filter the message store by (phase=='implement', slice_id=) — slice attribution comes from message metadata. Verify the message store carries slice metadata; if not, the producer/reviewer roles must include it on every implement-phase message they emit.", + "Filename: -implement-slice-.{md,json}. No aggregate file written.", + "Commit timing: per-slice BRC is committed to the slice's integration branch as a final commit BEFORE create_slice_pr opens the PR for that slice. This puts the BRC history in the slice PR's diff.", + "The orchestrator's _run_one_slice_inner thread is the natural commit author — it already manages the slice integration branch and synthetic-session push pattern." + ], + "open_concern": "Verify whether the BRC message store today tags messages with slice_id during the implement phase. If not, message_store.get_messages() filtering must be extended OR the orchestrator's BRC-routing logic must inject slice_id into message metadata. Worth a quick spike during plan refinement before implementation begins." + }, + { + "id": "C3", + "name": "Slice-1 base = context branch", + "responsibility": "When the contract carries a context_branch (i.e. context PR was created), slice-1's parent_branch resolves to that branch instead of pipeline_branch.", + "key_files": [ + "orchestrator/routes/pipelines.py — _run_one_slice_inner (lines 12407-12410): change root-slice resolution to prefer contract.pr.context_branch when present, else pipeline_branch", + "orchestrator/routes/pipelines.py — _run_implement_phase_slices (lines 12230+): pass context_branch through to _run_one_slice_inner closure" + ], + "behavior": [ + "Root slices (parent_slice_id is None) use parent_branch = contract.pr.context_branch if context_branch is present, else pipeline_branch.", + "Slice-N>1 unchanged: parent_branch = f'{issue_branch}/{parent_slice_id}'.", + "parent_branch_at_creation persistence captures the new value so the orphan reconciler can walk up correctly." + ] + }, + { + "id": "C4", + "name": "Stacked-PR reconciler context-branch awareness", + "responsibility": "When the entire ancestor chain of an orphaned root slice is gone, fall back to context_branch (if present) instead of pipeline_branch.", + "key_files": [ + "orchestrator/stacked_pr_reconciler.py — _resolve_extant_new_base() (lines 87-132): final fallback (line 132) becomes context_branch when present and extant, else pipeline_branch", + "orchestrator/stacked_pr_reconciler.py — caller of _resolve_extant_new_base must pass the context_branch reference (read from contract.pr.context_branch) into the helper signature" + ], + "behavior": [ + "If context_branch is in extant_branches, return it as the fallback for root slices.", + "If context_branch is gone (e.g. the human merged + deleted it), fall back to pipeline.base_branch — root slices targeting the base branch are stable regardless.", + "Preserves existing behavior for pipelines that pre-date the change (no context_branch in contract → legacy fallback)." + ] + }, + { + "id": "C5", + "name": "Contract schema: PRMetadata.context_*", + "responsibility": "Extend PRMetadata with optional context-PR fields the planner populates and the orchestrator persists.", + "key_files": [ + "shared/egg_contracts/models.py — PRMetadata class (lines 371-426): add context_title (str, default=''), context_description (str, default=''), context_pr_number (int | None, default=None), context_branch (str | None, default=None)", + "shared/egg_contracts/contract.py / contract_io.py — ensure load_contract / save_contract round-trip the new fields without errors", + "shared/agent_prompts/planner-* — planner's PRMetadata-emission prompt + JSON schema gain context_title and context_description fields" + ], + "behavior": [ + "Defaults of None / '' make Pydantic load older on-disk contracts non-fatally even though decision-4 says new pipelines only.", + "Planner is responsible for generating context_title (e.g. 'Context: ') and context_description (e.g. 'Strategic plan and BRC consensus history for issue #N. See and downstream slices for the implementation.')." + ] + }, + { + "id": "C6", + "name": "Doc-updater / docs", + "responsibility": "Document the new branch topology, the per-slice BRC convention, the context PR's role, and the planner's context_title/context_description fields.", + "key_files": [ + "docs/guides/sdlc-pipeline.md — branch topology section (today only mentions egg//work and egg//slice-N): add egg//context", + "docs/architecture/orchestrator.md — phase-transition diagram: add 'create context PR' step between plan completion and implement-phase slice spawning", + "docs/reference/agent-roles.md or wherever planner output schema lives: document context_title / context_description", + "shared/agent_prompts/planner-* prompts: instruct planner to emit context_title / context_description" + ] + }, + { + "id": "C7", + "name": "Tests", + "responsibility": "Verify each component in isolation and the integration of context PR + slice-1 stacking.", + "key_files": [ + "orchestrator/tests/test_pipelines_*.py — new tests for _create_context_pr (happy path + branch-creation-fail + PR-creation-fail)", + "orchestrator/tests/test_brc_history.py — _write_brc_history slice_id parameter, per-slice file naming, no aggregate file written", + "orchestrator/tests/test_run_implement_slices.py — slice-1 root resolution prefers context_branch when present, falls back to pipeline_branch when absent", + "orchestrator/tests/test_stacked_pr_reconciler.py — _resolve_extant_new_base context_branch fallback ordering", + "shared/egg_contracts/tests/test_models.py — PRMetadata new fields round-trip, defaults", + "integration_tests/ — end-to-end test where a multi-slice pipeline lands and we assert (a) the context PR exists with expected files in its diff, (b) slice-1 PR's base is the context branch, (c) each slice PR's diff carries its -implement-slice-.{json,md}" + ] + } + ], + + "artifact_set_for_context_pr": { + "core_docs": [ + ".egg-state/drafts/-analysis.md", + ".egg-state/drafts/-plan.md" + ], + "brc_histories": [ + ".egg-state/brc-history/-refine.{md,json}", + ".egg-state/brc-history/-plan.{md,json}" + ], + "agent_transcripts": [ + ".egg-state/agent-outputs/-refine-*.md", + ".egg-state/agent-outputs/-refine-*.json", + ".egg-state/agent-outputs/-plan-*.md", + ".egg-state/agent-outputs/-plan-*.json" + ], + "explicitly_excluded": [ + "Implement-phase BRC history (per-slice; lives on slice PRs).", + "Cross-slice aggregate -implement.{json,md} (not produced under decision-2).", + "Contract files (.egg-state/contracts/) — already merged on egg//work via existing mechanisms; not re-committed to context branch." + ] + }, + + "branch_topology_after_change": [ + " (e.g. main)", + " ↑", + "egg//context (NEW: doc-only PR, base=, head=egg//context)", + " ↑", + "egg//slice-1 (PR base=egg//context, head=egg//slice-1)", + " ↑", + "egg//slice-2 (PR base=egg//slice-1, head=egg//slice-2)", + " ↑", + "...", + "", + "egg//work persists as the orchestrator's per-pipeline integration worktree but is no longer the merge anchor for the slice stack. It still receives all .egg-state/ writes during refine/plan; the context branch is materialized by copying those artifacts onto egg//context (created from base_branch on origin)." + ], + + "alternatives_considered": [ + { + "name": "Embed in slice-1's diff (Option B from refine analysis)", + "rejected_because": "HITL decision-1 selected Option A. Operator preferred separation of strategic context from slice-1's code review." + }, + { + "name": "Render context into slice PR bodies (Option D from refine analysis)", + "rejected_because": "Not durable in git log / git blame; the issue's auditability requirement is unsatisfied." + }, + { + "name": "Keep aggregate -implement.{json,md} alongside per-slice files", + "rejected_because": "HITL decision-2 selected the 'split at write time' option (no aggregate) — simplifies _rewrite_brc_history_for_pr and avoids dual-write bugs." + }, + { + "name": "BRC-review the context PR with reviewer_refine + reviewer_plan", + "rejected_because": "HITL decision-3 selected 'doc-only auto-open' — the context PR is informational; the strategic plan was already ACK'd in refine + plan BRC." + }, + { + "name": "Hardcode 'main' as base for context PR", + "rejected_because": "HITL decision-5 explicitly amends Option 1 with base= (NOT hardcoded). Pipelines may target develop / staging / other branches." + } + ] + }, + + "implementation_plan": { + "workstream_order": [ + "WS1: PRMetadata schema additions (foundational; small surface; no behavior change)", + "WS2: _write_brc_history slice-aware writes (foundational for C2; unblocks WS5)", + "WS3: _create_context_pr + gateway exemption + planner context_title/context_description (the big new feature)", + "WS4: _run_one_slice_inner root-slice resolution + reconciler fallback (consumes context_branch from WS3)", + "WS5: Per-slice BRC commit before slice PR open (consumes WS2's slice-aware writer)", + "WS6: Doc updates (after behavior is stable)", + "WS7: Tests interleaved with each WS" + ], + "estimated_slices": 4, + "slice_proposal": [ + { + "slice": "slice-1", + "name": "Schema + per-slice BRC writer", + "scope": "PRMetadata.context_* fields (default-tolerant), _write_brc_history slice_id parameter, message-store filtering verification.", + "rationale": "Smallest blast radius. Unlocks WS2-WS5 without changing visible behavior." + }, + { + "slice": "slice-2", + "name": "Context PR creation + planner emission", + "scope": "_create_context_pr, gateway exemption for egg//context, planner prompt updates to emit context_title/context_description, contract persistence of context_pr_number/context_branch.", + "rationale": "Net-new feature; behavior gated on contract.pr.context_branch presence so it's safe to land before slice-3." + }, + { + "slice": "slice-3", + "name": "Slice-1 root resolution + reconciler awareness + per-slice BRC commit", + "scope": "_run_one_slice_inner uses context_branch when present; stacked_pr_reconciler fallback prefers context_branch; per-slice BRC commit happens before create_slice_pr.", + "rationale": "Wires the new context branch into the slice flow. Must follow slice-2; before it, the context_branch field is unused at slice spawn." + }, + { + "slice": "slice-4 (terminal)", + "name": "Docs + integration tests + cleanup of legacy aggregate write", + "scope": "docs/guides/sdlc-pipeline.md, docs/architecture/orchestrator.md, end-to-end integration test, removal of aggregate -implement.{json,md} writes.", + "rationale": "Stabilization slice; the legacy aggregate-file write is removed only once the new path is proven." + } + ], + "key_files_touched": [ + "shared/egg_contracts/models.py (MODIFY) — PRMetadata.context_* additions", + "shared/agent_prompts/planner-*.md (MODIFY) — emit context_title / context_description", + "orchestrator/routes/pipelines.py (MODIFY) — _write_brc_history, _persist_phase_brc_history, _rewrite_brc_history_for_pr, _run_implement_phase_slices, _run_one_slice_inner, NEW _create_context_pr", + "orchestrator/gateway_client.py (MODIFY) — extend create_slice_integration_branch admission OR add helper for context branch creation", + "gateway/gateway.py (MODIFY) — extend _SLICE_INTEGRATION_BRANCH_RE (or sibling exemption) for egg//context", + "orchestrator/stacked_pr_reconciler.py (MODIFY) — _resolve_extant_new_base context_branch fallback", + "docs/guides/sdlc-pipeline.md (MODIFY) — branch topology + context PR step", + "docs/architecture/orchestrator.md (MODIFY) — phase transition diagram", + "orchestrator/tests/test_*.py (NEW + MODIFY) — see C7", + "integration_tests/ (NEW) — multi-slice pipeline end-to-end with context PR assertion" + ] + }, + + "risks": [ + { + "id": "R1", + "risk": "Context branch creation fails (gateway exemption regex / network error / parent SHA missing).", + "severity": "MEDIUM", + "mitigation": "Failure is non-fatal: log + STATUS broadcast + overseer alert; slice-1 falls back to pipeline_branch (legacy behavior). The doc-only auto-open posture (decision-3) explicitly tolerates this — the human can re-run or manually open the context PR. Track via a contract.pr.context_pr_creation_error field for audit if useful." + }, + { + "id": "R2", + "risk": "BRC message store does not tag implement-phase messages with slice_id today, breaking the per-slice filter.", + "severity": "MEDIUM", + "mitigation": "Verify during plan-refine via a quick spike (grep for slice_id in message metadata in routes/signals + peer_consensus). If absent, slice-1 of this issue (schema + per-slice BRC writer) must include the metadata-injection fix on the producer/reviewer message-emission path. Tester role validates by inspecting captured messages." + }, + { + "id": "R3", + "risk": "Stacked-PR reconciler walks up to a deleted context branch when human has merged + deleted it before all slices land.", + "severity": "LOW", + "mitigation": "_resolve_extant_new_base()'s walk + extant_branches check already handles deleted ancestors gracefully. Final fallback when context_branch is gone is pipeline.base_branch — root slices targeting the base branch directly are always stable. Add a unit test for this exact scenario." + }, + { + "id": "R4", + "risk": "The orchestrator's per-pipeline worktree is checked out on egg//work; materializing egg//context requires either a second worktree or pushing a synthesized commit object via gateway.", + "severity": "MEDIUM", + "mitigation": "Two viable approaches: (a) add a temporary worktree at egg//context, copy the artifact set from the work worktree's filesystem, commit + push, drop the worktree; (b) build a tree object via git low-level commands and push the synthesized commit. Approach (a) is simpler and matches existing patterns. Spike during slice-2 planning to confirm worktree-add through the gateway is permitted from the orchestrator's session." + }, + { + "id": "R5", + "risk": "Hard switchover breaks operators relying on the aggregate -implement.{json,md} for offline analysis or historical tooling.", + "severity": "LOW", + "mitigation": "decision-4 explicitly accepts hard switchover. Document the rename in docs/guides/sdlc-pipeline.md and the release notes for the change. Existing files on disk in older pipelines are preserved (read-only)." + }, + { + "id": "R6", + "risk": "orchestrator/routes/pipelines.py is at 16K+ lines and being decomposed in #2261; large additions risk merge conflicts with that effort.", + "severity": "MEDIUM", + "mitigation": "Coordinate with #2261 owners (probably overseer/operator). New code should slot into the pre-allocated _pr_lifecycle/ and _concurrent_phase/ submodule clusters per orchestrator/CLAUDE.md so that #2261 slice-15 absorbs them cleanly. Where decomposition is incomplete, place new functions immediately adjacent to their existing siblings (e.g. _create_context_pr next to _auto_create_pr at line 8870)." + }, + { + "id": "R7", + "risk": "Planner role does not yet emit context_title / context_description; existing pipelines without them open a context PR with empty title/body.", + "severity": "LOW", + "mitigation": "PRMetadata defaults context_title to '' and context_description to ''. _create_context_pr falls back to a sensible default ('Context: ' + contract.pr.title; 'Strategic plan and BRC consensus history for issue #N. See linked slice PRs for implementation.') when the planner-supplied fields are empty. Slice-2 of the implementation includes the planner prompt update." + }, + { + "id": "R8", + "risk": "Cherry-pick / merge ordering: if humans merge the context PR while slice-1 is still mid-review, slice-1 PR base becomes a deleted branch.", + "severity": "LOW", + "mitigation": "The stacked-PR reconciler (R3 mitigation) already handles this via the orphan-rebase mechanism; once context PR merges into base_branch, slice-1 reconciler retargets slice-1 to base_branch. Verify the cascade by an integration test that explicitly merges context first." + } + ], + + "open_questions": [ + { + "id": "Q1", + "question": "Does the BRC message store today carry slice_id metadata on every implement-phase message? If not, who injects it (producer agents, the orchestrator's BRC routing layer)?", + "context": "Per-slice BRC filtering depends on this. C2 mitigation R2 calls for a spike during plan-refine; surfacing here so the planner / task_planner can size the work.", + "recommendation": "task_planner: include a short spike task in slice-1 to confirm. If metadata is missing, add a sub-task to inject slice_id at message-emission time." + }, + { + "id": "Q2", + "question": "Should context_branch be persisted on a per-pipeline state file (e.g. a contract.pr.context_branch field) or computed deterministically from pipeline_id (egg//context) at every read site?", + "context": "Deterministic computation (egg//context) is simpler and avoids a contract write. Persisting allows future flexibility (e.g. multi-context-branch scenarios) but introduces a write-and-read invariant.", + "recommendation": "Compute deterministically from pipeline_identifier. Add the contract field only as a presence flag (context_pr_number != None ⇒ context branch was created; consumers compute the branch name)." + }, + { + "id": "Q3", + "question": "What happens if the operator manually closes the context PR (without merging) mid-pipeline? Does slice-1 (already opened with base=context_branch) fail to merge later?", + "context": "PR closed without merge → context branch still exists on origin (not deleted automatically) → slice-1's base remains valid and merges into it normally.", + "recommendation": "No additional handling needed. Document the behavior. Optionally, the overseer monitor can flag a closed-without-merge context PR as an OVERSEER_ALERT for human attention." + } + ], + + "metrics": { + "estimated_workstreams": 7, + "estimated_slices": 4, + "estimated_files_modified": 9, + "estimated_files_created": 3, + "estimated_lines_added": 800, + "blast_radius": "MEDIUM — touches branch topology, BRC history persistence, slice spawning, contract schema, gateway exemption regex; gated on plan_complete + context_branch presence so legacy/in-flight pipelines are unaffected." + }, + + "consensus_inputs_for_reviewer_plan": { + "what_to_check": [ + "All five HITL decisions and all five feedback answers are reflected in the design (cross-reference hitl_resolutions section).", + "No hardcoded 'main' anywhere — every base reference reads pipeline.base_branch / contract.repo.base_branch (decision-5).", + "Per-slice BRC writes go to -implement-slice-.{json,md} only; no aggregate -implement.{json,md} (decision-2).", + "Context PR is doc-only auto-open with no BRC roster and no merge gate (decision-3).", + "Hard switchover for new pipelines only — schema additions are default-tolerant but no backfill code path (decision-4).", + "Orchestrator-authored commits for both context-branch artifacts and per-slice BRC histories (Q4).", + "Context PR's artifact set includes refine + plan analysis + BRC + agent transcripts (Q3); excludes implement BRC and contract files.", + "Planner emits context_title / context_description as separate fields (Q2)." + ], + "what_to_NACK_on": [ + "Any branch-base resolution that hardcodes 'main'.", + "Any code path that reintroduces the cross-slice -implement.{json,md} aggregate file.", + "Any blocking on context PR merge before slice-1 spawns (would violate decision-3).", + "Any backfill / migration code path for in-flight pipelines (would violate decision-4).", + "Coder/tester role authoring the BRC history commit (would violate gateway boundaries + Q4)." + ] + } +} diff --git a/.egg-state/agent-outputs/2548-risk_analyst-output.json b/.egg-state/agent-outputs/2548-risk_analyst-output.json new file mode 100644 index 0000000000..3291c1b5f6 --- /dev/null +++ b/.egg-state/agent-outputs/2548-risk_analyst-output.json @@ -0,0 +1,383 @@ +{ + "issue": 2548, + "phase": "plan", + "role": "risk_analyst", + "pipeline_id": "issue-2548", + "summary": "Risk assessment for the 'context PR + per-slice BRC history' restructure of the egg slice-PR train. The change is internal to the orchestrator + gateway + contract schema (no third-party dependency upgrades), so all risks are integration / invariant / operator-experience class. The HITL operator has selected hard-switchover (decision-4): no backwards compat, no backfill. That single choice dominates the risk surface — every legacy artifact, in-flight pipeline, and persisted contract that predates the change is on the operator's manual-cleanup list. The recommended posture is 'land behind a launch checklist that drains in-flight pipelines, ship the gateway allowlist update first, and gate any in-flight pipeline restart on a contract-schema reload pass'.", + "scope": { + "external_dependencies_changed": false, + "internal_change_only": true, + "research_notes": "Change is purely internal to egg's orchestrator/gateway/contract layers. No new PyPI deps, no SDK upgrades, no GH API surface changes (gh pr create call shape is unchanged — only base/head differ). External research (CVEs, deprecations) skipped per the role brief: 'Skip external research for purely internal changes.'" + }, + "decision_inputs": { + "decision_1": "Dedicated context PR (egg//context branch)", + "decision_2": "Split BRC history at write time into per-slice files; no aggregate", + "decision_3": "Doc-only auto-open; no merge gate; not BRC-reviewed", + "decision_4": "Hard switchover — no backfill, no backwards compat", + "decision_5": "Context PR base = pipeline.base_branch (NOT hardcoded main); slice-1 base = egg//context", + "feedback_q1": "Work→main gap is in scope for #2548", + "feedback_q2": "New contract.pr.context_title / pr.context_description fields", + "feedback_q3": "Include agent transcripts (.egg-state/agent-outputs/-refine-*.md, etc.)", + "feedback_q4": "Orchestrator-authored final commit on slice integration branches", + "feedback_q5": "Per-slice BRC only on slice PRs; no cross-slice aggregate file in repo" + }, + "risks": [ + { + "id": "R1", + "title": "Gateway _SLICE_INTEGRATION_BRANCH_RE rejects egg//context push", + "category": "compatibility", + "subcategory": "gateway-policy", + "description": "Gateway's regex (gateway/gateway.py:1085) is `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\\d+$`. Branch name `egg//context` does not match the slice-or-phase suffix, so the synthetic-launcher push that creates the context branch will NOT receive the slice-integration exemption. The push falls through to the agent-session push enforcement (#2028) and is rejected. Symptom: orchestrator's context-branch creation step 500s on push; pipeline blocks at end of plan phase before slice-1 can be provisioned.", + "impact": "high", + "likelihood": "near-certain", + "blast_radius": "every new pipeline that runs after the orchestrator change merges before the gateway change merges — all of them stall at plan→implement transition", + "evidence": [ + "gateway/gateway.py:1085 — _SLICE_INTEGRATION_BRANCH_RE", + "gateway/gateway.py:1296–1332 — synthetic-session exemption is gated on the regex matching" + ], + "mitigations": [ + "Extend the regex to `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\\d+|context$` (or a separate allowlist branch for `context`).", + "Land the gateway change in its own slice and BEFORE any orchestrator slice that calls create_context_branch(). Treat the gateway slice as a hard prerequisite in slice ordering — task_planner should put it as slice-1.", + "Add a unit test in gateway tests that asserts a synthetic-session push to `egg/issue-9999/context` succeeds, and a non-synthetic push to the same branch is rejected (parity with the slice variant)." + ], + "rollback": "Revert the gateway regex extension; orchestrator falls back to NotImplementedError on context-branch create, pipelines pin at plan-end. Operator triage: cancel in-flight pipelines or hand-create the context branch via the launcher.", + "needs_human_review": false + }, + { + "id": "R2", + "title": "Hard switchover breaks in-flight pipelines mid-stream", + "category": "compatibility", + "subcategory": "migration", + "description": "Decision-4 explicitly chose 'no backfill/backwards compat. Hard switchover'. Any pipeline whose contract was written before the new PRMetadata fields exist will, on the first reload after the orchestrator upgrade, either (a) silently lose the new fields if defaults are added, or (b) hard-fail Pydantic validation if the new fields are required. In-flight pipelines that have already reached implement and are mid-slice stack will also be reading old slice base-branch resolution from contract.slices[i].parent_branch_at_creation and don't know about the new context branch. Symptom: orchestrator restart after upgrade picks up legacy contract → either crashes on validation or silently mis-targets slice base.", + "impact": "high", + "likelihood": "high", + "blast_radius": "every in-flight pipeline at the time of upgrade (typically 1–3 in this repo's cadence)", + "evidence": [ + "shared/egg_contracts/models.py:615 — Contract.schemaVersion is a single string, no per-field versioning", + "shared/egg_contracts/models.py:682–709 — existing migration shim _migrate_phases_to_slices is the established pattern; absence of an equivalent shim for new PRMetadata fields means hard breakage", + "Decision-4 resolution text: 'no backfill/backwards compat. Hard switchover'" + ], + "mitigations": [ + "Pre-upgrade drain: operator runbook MUST list 'finish or cancel all in-flight pipelines before merging the slice that introduces the new contract fields'. Add this to the slice PR body's Manual Steps section.", + "Even though decision-4 says no backfill, the new PRMetadata fields MUST have safe defaults (default=None / default_factory=list) so that old contracts loaded post-upgrade fail gracefully — Pydantic validation should not crash the orchestrator on legacy contract load.", + "Bump Contract.schemaVersion to '1.1' (or similar). Add a model_validator that emits a single ERROR-level log line — not a crash — when schemaVersion < the current contract-PR support level AND the contract has slice metadata, so operators can grep for stranded pipelines.", + "Add a `egg-orch pipeline drain` operator command that lists in-flight pipelines that need to finish before upgrade. Even if backfill is out of scope, visibility into 'which pipelines will break' is in scope." + ], + "rollback": "Revert the orchestrator slice; contract loads return to the pre-change schema. Any contracts that were rewritten post-upgrade with the new fields will load cleanly on the rolled-back orchestrator only if the new fields were never required (covered by mitigation #2). If they were required, manually edit the contract JSON files to strip the new fields. Hard switchover means rollback is also a manual operator event — not automatic.", + "needs_human_review": true, + "human_review_reason": "Operator-runbook ergonomics: the operator has already chosen hard switchover but probably didn't anticipate the in-flight pipeline drain requirement. Surface this explicitly so they can pick (a) drain-then-upgrade, (b) cancel-then-upgrade, or (c) a one-shot CLI to clean up stranded contracts." + }, + { + "id": "R3", + "title": "Stacked-PR reconciler fallback still points at pipeline_branch (egg//work)", + "category": "compatibility", + "subcategory": "orphan-recovery", + "description": "stacked_pr_reconciler.py:_resolve_extant_new_base() walks Slice.dependencies up the DAG to find the first ancestor whose branch still exists on origin; the fallback when the entire ancestor chain is gone is `pipeline_branch` (line ~132). After the change, slice-1's parent is `egg//context` (NOT pipeline_branch). If slice-1 merges and is later reverted, or if the context PR is closed-without-merging while slice-1 is still open, the reconciler will retarget orphan slices to pipeline_branch — which is (a) the wrong branch under the new model, and (b) potentially an empty branch (no commits) if the orchestrator stopped using pipeline_branch as the work tree. Symptom: orphaned slice PRs cascade-rebase onto a dead branch, GitHub closes them as 'no commits to merge', operator has to hand-rebase.", + "impact": "medium", + "likelihood": "medium", + "blast_radius": "any pipeline where (a) a slice PR is closed without merging or (b) the context PR is closed before all slices merge", + "evidence": [ + "orchestrator/stacked_pr_reconciler.py:87–132 — _resolve_extant_new_base()", + "orchestrator/stacked_pr_reconciler.py:10–11 — 30s default cadence (env EGG_ORCH_STACKED_PR_RECONCILER_INTERVAL_SECONDS)" + ], + "mitigations": [ + "Update _resolve_extant_new_base() so when the ancestor chain is exhausted, the new fallback is `contract.pr.context_branch` (or the new field name) — and only if THAT is also gone, fall back to `pipeline.base_branch`. Pipeline_branch is no longer the right last-resort target.", + "Add a reconciler regression test: simulate a stack where context-branch and slice-1 are both deleted while slice-2 is still open; assert reconciler retargets slice-2 to `pipeline.base_branch`, not `egg//work`.", + "Add a metrics counter `egg_reconciler_fallback_to_base_branch_total` so operators can see how often this path fires post-deploy." + ], + "rollback": "Reconciler change is a single function; revert is straightforward and does not require contract migration.", + "needs_human_review": false + }, + { + "id": "R4", + "title": "Doc-only context PR with no merge gate creates merge-order ambiguity", + "category": "operator-experience", + "subcategory": "merge-flow", + "description": "Decision-3 chose 'doc-only auto-open' — pipeline does NOT block on context-PR merge before slicing. So slice-1 spawns immediately after plan_gate, with base=egg//context. If the human merges slice-1 BEFORE merging the context PR, GitHub auto-detects the missing context commits and the slice-1 merge brings them along (technically OK), but the context PR's diff vs main shrinks to empty (its commits are now on main via slice-1) and GitHub auto-closes it. Symptom: context PR gets auto-closed without a 'merged' badge, even though all its commits did reach main. Reviewers approaching # later see a closed PR with no merge marker and may assume the context was rejected.", + "impact": "low", + "likelihood": "high", + "blast_radius": "every merged pipeline whose human merger doesn't follow the bottom-up merge order", + "evidence": [ + "Decision-3 resolution: 'Doc-only auto-open (orchestrator opens; humans review on the PR; pipeline does not block on its merge before slicing)'", + "GitHub behavior: a PR whose head branch's commits all already exist on the base is auto-closed with 'no commits between ...' rather than 'merged'" + ], + "mitigations": [ + "Render a clear 'merge order' note into the body of every PR in the stack ('merge order: this context PR → slice-1 → slice-2 → ... → slice-N'). The terminal slice already carries similar language post-#2543; extend it.", + "Have the orchestrator's stacked-PR reconciler detect 'context PR auto-closed because subsumed' and POST a comment to the now-closed context PR explaining the situation, so future reviewers don't read it as a rejection.", + "Optional follow-up issue: introduce a `merge_state: subsumed` GitHub-Actions label that the orchestrator applies to context PRs that auto-close due to upstream merging. Out of scope for this slice but worth noting for visibility." + ], + "rollback": "Cosmetic only; no rollback action needed if the feature ships and the comment-on-close mitigation is added later.", + "needs_human_review": true, + "human_review_reason": "Confirm that 'merge order: context first' should be advisory text only, OR if the operator wants the orchestrator to enforce merge order via a GitHub branch protection rule. The latter is a bigger surface area." + }, + { + "id": "R5", + "title": "Context PR ships agent transcripts that may contain sensitive content", + "category": "security", + "subcategory": "data-exposure", + "description": "Feedback Q3 chose 'include agent transcripts' for maximum transparency. Agent transcripts under `.egg-state/agent-outputs/-refine-*.md` and similar are LLM-authored summaries of the refine/plan phases. They can contain (a) reflexive snippets of repo source code that the agent quoted while reasoning, (b) verbatim portions of the issue body — which itself may contain user-pasted secrets the operator didn't realize they were pasting, (c) reasoning that mentions internal-only code paths, error messages, or system internals that the operator might not want public. If the repo is public (egg's repo IS public on GitHub), the context PR's diff puts all of that on a public PR diff against main.", + "impact": "medium", + "likelihood": "low-to-medium", + "blast_radius": "any pipeline where the issue body, prior brc-history, or agent reasoning quoted something sensitive", + "evidence": [ + "Feedback Q3 answer: 'Include agent transcripts as well (.egg-state/agent-outputs/-refine-*.md and similar). Maximum transparency.'", + "egg's GitHub repo (jwbron/egg) is public — every PR diff is public", + "No existing scrubber exists today for `.egg-state/agent-outputs/` content" + ], + "mitigations": [ + "Add a content scrubber (regex / entropy-based) over agent transcripts before commit-to-context-branch. Mirror the existing secret-detection used in pre-commit hooks. Reject the context-PR creation if a match is found and surface a HITL question to the operator.", + "Add a per-pipeline opt-OUT flag (contract.pr.include_transcripts: bool, default true honoring decision Q3). Even if the operator picked 'maximum transparency' in this issue's resolution, individual pipelines may need to opt out for sensitivity reasons.", + "Document the exposure in the PR body of every context PR ('This PR includes agent reasoning transcripts. Review for any sensitive content before merging.'). Make it explicit so reviewers know to look.", + "Operator-runbook entry: 'before merging a context PR, scan the diff for any unintended secret leakage'." + ], + "rollback": "Trivial — flip the include-transcripts flag default to false, or remove the transcript-copy step from the orchestrator's context-branch builder.", + "needs_human_review": true, + "human_review_reason": "The operator picked transparency in feedback-1; before shipping this for the egg repo (public) we should confirm they understand that includes implementation-detail leakage and want a content scrubber wired in." + }, + { + "id": "R6", + "title": "BRC history split breaks read-side renderers that assume {id}-implement.{json,md}", + "category": "compatibility", + "subcategory": "internal-api", + "description": "_rewrite_brc_history_for_pr() (orchestrator/routes/pipelines.py:8265–8328) currently iterates pipeline phases and writes/reads BRC history per phase keyed by the file pattern `-.{json,md}`. Decision-2 chose 'split at write time, no aggregate file'. Every site that READS BRC history — PR-body-renderer, audit log emitters, checkpoint exporters, and the `mcp__brc__read_peer_artifact` MCP tool — must learn that for phase=='implement' the file naming includes a slice qualifier. Symptom: if any read site is missed, slice PR bodies render an empty 'BRC history' section, audit grep on `-implement.json` returns nothing, and the brc-history MCP returns {}.", + "impact": "medium", + "likelihood": "medium", + "blast_radius": "every implement-phase read site in the orchestrator + MCP", + "evidence": [ + "orchestrator/routes/pipelines.py:8110–8235 — _write_brc_history()", + "orchestrator/routes/pipelines.py:8265–8328 — _rewrite_brc_history_for_pr()", + "mcp__brc__read_peer_artifact tool surface — reads from .egg-state/brc-history/-.json", + "Decision-2 resolution: 'Split file at write time: orchestrator writes to .egg-state/brc-history/-implement-slice-.{json,md} (one per slice; no aggregate file)'" + ], + "mitigations": [ + "Centralize the file-naming logic in a single helper (`_brc_history_path(identifier, phase, slice_id=None)`) so all read/write sites go through it. Audit every existing call site.", + "Add a static-analysis test: grep for the pattern `brc-history/.*-implement` and assert every match calls the helper — fail CI if a hardcoded path slips in.", + "Bump the `mcp__brc__read_peer_artifact` schema to include an optional slice_id parameter and update its tool description; without it, callers still get phase-only files (refine/plan), which preserves the contract for non-implement phases.", + "Add an integration test: drive a 2-slice pipeline through implement, assert that two distinct `.egg-state/brc-history/-implement-slice-{1,2}.json` files exist on disk and on the slice integration branches." + ], + "rollback": "Reverting the orchestrator slice restores aggregate-file naming. Slice-PR bodies pre-rollback will reference filenames that no longer exist, but the audit value is in the diff itself — operators can hand-grep the slice integration branches.", + "needs_human_review": false + }, + { + "id": "R7", + "title": "Reconciler cadence amplification: extra PR layer = extra reconcile pass on every parent merge", + "category": "performance", + "subcategory": "scheduler-load", + "description": "Stacked-PR reconciler runs every 30s by default. Today, on slice-1 merge, the reconciler does 1 retarget pass to walk slice-2 down to pipeline_branch. After the change, slice-1 merging requires the reconciler to recognize that slice-1's old parent (egg//context) was a context PR and slice-2's new parent should be `pipeline.base_branch` (since context already merged into base before slice-1). That's not just renaming a fallback — it's a state-machine transition the reconciler must learn ('parent was context PR, now context PR has merged, so my new parent is base_branch'). If the reconciler doesn't know this, it retargets slice-2 to a deleted egg//context branch. The reconciler runs every 30s, so the window for incorrect retarget is short, but in a busy repo with multiple reconciler-watched stacks, the extra pass adds load.", + "impact": "low", + "likelihood": "medium", + "blast_radius": "all pipelines using the new context-PR mechanism, post-context-PR-merge", + "evidence": [ + "orchestrator/stacked_pr_reconciler.py:10–11 — default 30s cadence", + "orchestrator/stacked_pr_reconciler.py:87–132 — _resolve_extant_new_base() walks dependency chain" + ], + "mitigations": [ + "Extend the reconciler's parent-resolution logic so it knows about the context PR explicitly: `if old_parent == context_branch and context_branch is gone, retarget to pipeline.base_branch`. Add a unit test for the transition.", + "Add a metric `egg_reconciler_retarget_total{from='context',to='base_branch'}` so operators can watch the transition fire.", + "If reconcile load becomes an issue, raise the cadence to 45s — but only as a follow-up if metrics show degradation. Not a launch blocker." + ], + "rollback": "Reconciler change is independent of the contract schema change; can be rolled back separately if it misbehaves.", + "needs_human_review": false + }, + { + "id": "R8", + "title": "Contract schema additions (context_title, context_description, context_branch, context_pr_number) require defaults; missing defaults break legacy contract load", + "category": "compatibility", + "subcategory": "schema", + "description": "Feedback Q2 mandates new fields contract.pr.context_title and contract.pr.context_description. The change also implicitly needs contract.pr.context_branch and contract.pr.context_pr_number (so slice provisioning and reconciler can find the context PR). PRMetadata in shared/egg_contracts/models.py:371–427 has no schema versioning per field. If new fields are added without `default=None` (or `default_factory=...`), Pydantic v2 will raise ValidationError when the orchestrator tries to load any contract written by the pre-upgrade orchestrator.", + "impact": "high", + "likelihood": "high (without mitigation)", + "blast_radius": "every contract on disk at upgrade time", + "evidence": [ + "shared/egg_contracts/models.py:371–427 — current PRMetadata definition has no context_* fields", + "shared/egg_contracts/models.py:682–709 — existing _migrate_phases_to_slices migration is the established pattern for forward-compat", + "Pydantic v2 default behavior: fields without defaults are required and raise on load if missing" + ], + "mitigations": [ + "REQUIRED: every new PRMetadata field MUST be Optional with a sensible default (None for strings, [] for lists). This is not optional — even with hard switchover (decision-4), contract-load crashes on upgrade are a different class of failure than 'old contracts produce no context PR' and are not what the operator chose.", + "Bump Contract.schemaVersion default to '1.1' and add a model_validator that, when loading a contract with schemaVersion '1.0' AND the new fields are absent, leaves them at default (no migration, no error — just default-fill).", + "Add a unit test: round-trip a v1.0 contract JSON (synthetic legacy fixture committed under tests/fixtures/) through Contract.model_validate — must not raise.", + "Document in the schema CHANGELOG (shared/egg_contracts/CHANGELOG.md if it exists, else inline in models.py docstring) that v1.1 added context_* fields." + ], + "rollback": "Defaults make rollback trivial — orchestrator pre-change ignores fields it doesn't know about (Pydantic's default `extra='ignore'` if set, else explicit allow in BaseModel.model_config).", + "needs_human_review": false + }, + { + "id": "R9", + "title": "Hard switchover + decision-4 means in-flight pipelines see contract field drift", + "category": "operator-experience", + "subcategory": "migration", + "description": "Even with safe defaults (R8 mitigation), an in-flight pipeline started pre-upgrade has Slice[0].parent_branch_at_creation = `egg//work`. Post-upgrade, the orchestrator's slice provisioning is rewritten to assume Slice[0].parent_branch_at_creation = `egg//context`. If the in-flight pipeline reaches implement post-upgrade, the orchestrator will either (a) honor the persisted parent_branch_at_creation and use the OLD scheme (mixed-mode pipeline), or (b) ignore it and try to rebase slice-1 onto a context branch that was never created.", + "impact": "high", + "likelihood": "medium", + "blast_radius": "in-flight pipelines at upgrade time", + "evidence": [ + "orchestrator/routes/pipelines.py:_run_one_slice_inner() persists Slice.parent_branch_at_creation per slice — the value at slice-creation time is sticky", + "Decision-4 explicitly says no backfill; no mid-flight migration" + ], + "mitigations": [ + "Operator runbook (REQUIRED): 'Before merging the slice that flips slice-provisioning to context-PR mode, drain or cancel all in-flight pipelines that have not yet reached implement.' Add this to the implement-flipping slice's PR body Manual Steps.", + "Add an orchestrator-side guard that detects 'Slice.parent_branch_at_creation == egg//work AND pipeline is post-upgrade' and surfaces an OVERSEER_ALERT explaining the operator's options (cancel, hand-rebase, force-restart).", + "Document the failure mode explicitly in the PR description so reviewers can ask the right questions before merging.", + "Optional: provide an operator CLI `egg-orch pipeline force-restart ` that drops the in-flight pipeline's slice metadata and re-emits from plan with the new context-PR scheme. This is OUT of decision-4's 'no backfill' choice but a usability nice-to-have. Surface as a HITL question." + ], + "rollback": "Roll back the orchestrator slice; in-flight pipelines resume with the old scheme. This works because the contract schema (with R8 defaults) doesn't crash on the rolled-back code reading the new fields — they just sit unused.", + "needs_human_review": true, + "human_review_reason": "Decision-4 says 'hard switchover'. Confirm with operator: do you want a force-restart escape hatch for stranded pipelines, or do you actually want to abandon them?" + }, + { + "id": "R10", + "title": "Context PR introduces a new merge-order assumption for HITL plan_gate timing", + "category": "compatibility", + "subcategory": "hitl-flow", + "description": "Today, plan_gate fires at end-of-plan-phase. After plan_gate ACK, the orchestrator transitions to implement and starts spawning slice agents. Decision-3 chose 'auto-open, no merge gate' for the context PR — meaning the context PR is created at end-of-plan-phase BEFORE slice-1 spawns. This adds a new step to plan→implement transition that can fail (gateway push, GitHub PR create, GH API rate-limit). If context PR creation fails, the orchestrator must EITHER block plan→implement (which contradicts decision-3 'no merge gate') OR proceed without a context PR (which silently breaks the discoverability promise and surprises the operator). The current code does not have a 'best-effort, log-and-continue' pattern for PR creation in the plan→implement transition.", + "impact": "medium", + "likelihood": "low-medium", + "blast_radius": "plan→implement transitions where context-PR creation hits a transient GH error", + "evidence": [ + "Decision-3: 'pipeline does not block on its merge before slicing' — but says nothing about creation failure", + "Decision-3 vs decision-1 tension: decision-1 says 'context PR exists', decision-3 says 'don't block on it'" + ], + "mitigations": [ + "Define explicit failure semantics for context-PR creation: fail-loud (block plan→implement transition with operator alert) is recommended even though it's stricter than the literal text of decision-3 — the 'don't block on merge' is about merging, not creation.", + "Add a retry-with-backoff on gh PR create (mirror existing slice-PR-create retry logic in create_slice_pr).", + "Add a HITL question to the planner: 'on context-PR-creation failure, should the orchestrator (a) block the pipeline, (b) emit OVERSEER_ALERT and continue, or (c) auto-retry forever?' This is a decision the operator owns.", + "If the orchestrator does decide to continue on creation failure (option b), the slice PR bodies' 'Strategic context: #' link must gracefully render 'context PR creation failed; see .egg-state/drafts/-analysis.md on the work branch' rather than a broken link." + ], + "rollback": "Removing the context-PR step from plan→implement is a single-function change.", + "needs_human_review": true, + "human_review_reason": "Operator-owned decision: what should happen if context-PR creation fails? Decision-3 covers merge, not creation." + }, + { + "id": "R11", + "title": "Per-slice BRC commit by orchestrator on slice integration branches conflicts with existing 'commit only state files' allowlist", + "category": "compatibility", + "subcategory": "gateway-policy", + "description": "Feedback Q4 confirmed orchestrator-authored commit of `.egg-state/brc-history/-implement-slice-.{json,md}` to slice integration branches. The orchestrator already does this on the work branch via _commit_statefiles_to_worktree(). On the slice integration branch, the gateway must allow the orchestrator to push these files specifically — not arbitrary writes. Today the synthetic-session slice-integration-branch creation push is exempt (gateway/gateway.py:1296–1332), but a SECOND push to the same slice branch (the BRC-history append commit) needs the same exemption. Symptom: orchestrator's BRC-history append on slice integration branch is rejected by the gateway because it's not the initial-create push.", + "impact": "medium", + "likelihood": "medium", + "blast_radius": "every slice's PR open step", + "evidence": [ + "gateway/gateway.py:1296–1332 — synthetic-session push exemption, scoped to specific event audit", + "Feedback Q4: 'Orchestrator-authored. Matches existing _commit_statefiles_to_worktree pattern; coder/tester gateway boundaries forbid them from pushing under .egg-state/brc-history/ anyway.'" + ], + "mitigations": [ + "Verify that the synthetic-session exemption (is_slice_integration_push) covers ALL pushes to a slice integration branch, not just the initial creation. If it does, the BRC-history commit is OK. If it only covers the creation push, extend the exemption to subsequent synthetic-session pushes to the same branch.", + "Add a gateway test: synthetic-session pushes a commit modifying ONLY `.egg-state/brc-history/-implement-slice-1.json` to an existing `egg//slice-1` branch — must succeed.", + "Audit-log distinguishability: emit a separate `push_brc_history_append` event so operators can grep for these specifically (vs. the integration-branch creation event)." + ], + "rollback": "Removing the BRC-history-append step is a single-function change.", + "needs_human_review": false + }, + { + "id": "R12", + "title": "Agent transcripts (per Q3) may exceed PR diff size limits", + "category": "performance", + "subcategory": "ux", + "description": "Including `.egg-state/agent-outputs/-refine-*.md` and similar transcripts in the context PR's diff can produce very large diffs. A typical refine session produces 500–5000 lines of agent reasoning. With multiple agents per phase, a context PR could carry 20k+ lines of LLM-authored markdown. GitHub's PR review UI degrades around 500 changed files; the diff-rendering performance also gets sluggish past ~3MB total diff. Reviewers may give up on actually reading the context PR.", + "impact": "low", + "likelihood": "high", + "blast_radius": "every context PR", + "evidence": [ + "Feedback Q3: 'Include agent transcripts as well (.egg-state/agent-outputs/-refine-*.md and similar). Maximum transparency.'", + "Empirical: existing refine-phase BRC history files (e.g. 2548-refine.md) are already 689 lines after ONE round; agent-outputs transcripts are typically larger" + ], + "mitigations": [ + "Compress transcripts at commit time: drop tool-call output verbatim, keep only role-level summaries. Document the compression in the context PR body.", + "Use a `.egg-state/agent-outputs//index.md` table-of-contents file in the diff that links to per-agent transcript files; reviewers see a small index even if the underlying transcripts are large.", + "Add a size-budget check: if the cumulative transcript size exceeds 5MB, the orchestrator emits an OVERSEER_ALERT and the operator chooses to (a) include anyway, (b) summarize, or (c) drop transcripts for this pipeline.", + "Document in the operator runbook that maximum-transparency context PRs may need manual triage on very-long-running pipelines." + ], + "rollback": "Excluding transcripts from the context PR is a single-step change in the orchestrator's context-branch builder.", + "needs_human_review": true, + "human_review_reason": "Confirm with operator: is 'maximum transparency' worth a 5MB+ PR diff that no reviewer will read in full? A summary mode might serve the audit goal better." + }, + { + "id": "R13", + "title": "No automated test coverage today for stacked-PR + reconciler + new-PR-type interactions", + "category": "testing", + "subcategory": "regression-risk", + "description": "The stacked-PR reconciler is exercised by a small number of integration tests (against a synthetic GH mock). Adding a new PR layer at the bottom of the stack expands the state space of 'what merges first?' / 'what closes-without-merging?' significantly. Without new tests, regressions in stacked behavior won't be caught until they hit production pipelines.", + "impact": "medium", + "likelihood": "medium", + "blast_radius": "every pipeline using the new mechanism", + "evidence": [ + "orchestrator/stacked_pr_reconciler.py — single file, hard to test in isolation without a GH mock" + ], + "mitigations": [ + "Task_planner must include a slice (or a sub-task within the reconciler slice) for end-to-end integration tests covering: (a) context+slice-1+slice-2 stack happy path, (b) context PR closed without merging while slice-1 open, (c) slice-1 closed without merging while slice-2 open, (d) cascade-merge of all three, (e) operator force-merges slice-1 before context.", + "Reuse the existing GH-mock infrastructure in integration_tests/.", + "Make the test fixtures explicit about pipeline.base_branch != 'main' so we cover decision-5's parametric base resolution." + ], + "rollback": "Tests are additive; they don't gate functionality.", + "needs_human_review": false + }, + { + "id": "R14", + "title": "Decision-5 'pipeline base_branch != main' adds a previously-untested parameter to slice provisioning", + "category": "compatibility", + "subcategory": "branch-resolution", + "description": "The HITL operator amended decision-5 to require `Context PR base = ` (NOT hardcoded main). Today, _run_one_slice_inner()'s base resolution implicitly assumes pipeline_branch is the canonical root, and pipeline_branch is created from base_branch. The new logic must read pipeline.base_branch from the contract and pass it to the gateway's create-context-branch primitive. If any code path implicitly hardcodes 'main' (in PR-body templates, in default refspecs, in audit-log messages), pipelines with a non-main base_branch will silently produce broken context PRs.", + "impact": "medium", + "likelihood": "medium", + "blast_radius": "any pipeline whose base_branch is not 'main' (e.g., long-running feature branches, release branches)", + "evidence": [ + "Decision-5 amended resolution: 'Option 1 with base= (not hardcoded main)'", + "egg/issue-2548/work currently exists; pipeline.base_branch is derivable from contract" + ], + "mitigations": [ + "Grep the orchestrator + gateway codebase for hardcoded `'main'` strings as default base; replace with `pipeline.base_branch` lookups.", + "Add an integration test where pipeline.base_branch is 'develop' (or any non-main string) and assert the context PR's base is correctly set.", + "Surface base_branch in the context PR body so reviewers can sanity-check it." + ], + "rollback": "Hardcoding 'main' temporarily restores prior behavior, at the cost of breaking non-main base pipelines.", + "needs_human_review": false + } + ], + "consolidated_mitigation_strategy": { + "ordering": [ + "1. Land gateway-side regex extension (R1) FIRST as a standalone slice. This is the only change that, if absent, hard-fails every subsequent pipeline.", + "2. Land contract schema additions with safe defaults (R8, R2, R9). New fields must be Optional with default=None / default_factory=list. Bump schemaVersion. Add legacy contract round-trip test.", + "3. Land BRC-history file naming centralization helper (R6) BEFORE the per-slice split. Audit all read sites first, then flip to per-slice.", + "4. Land orchestrator context-branch creation primitive + mcp + audit logging (R5, R11). Add content scrubber for transcripts.", + "5. Land slice-1 base-branch resolution change (R3, R7, R14). Update reconciler fallback. Add metrics.", + "6. Land context-PR-create step in plan→implement transition (R10). Define failure semantics. Add retry.", + "7. Land slice-PR body re-render to include 'Strategic context: #' backlink and merge-order note (R4).", + "8. Land integration tests covering all stacked-PR scenarios (R13)." + ], + "drain_runbook": [ + "Before merging slice 5+ above, operator MUST: (a) list all in-flight pipelines (egg-orch pipeline list --state in-flight), (b) drain or cancel each, (c) confirm zero in-flight, (d) merge the slice. Otherwise R2 / R9 fire.", + "Surface this requirement in the slice PR body's Manual Steps section so the merger sees it." + ], + "monitoring_after_launch": [ + "egg_reconciler_fallback_to_base_branch_total — should rise from 0 to small-but-nonzero", + "egg_reconciler_retarget_total{from='context'} — should fire on every context-PR merge", + "push_brc_history_append events in audit log — every slice's PR open should produce one", + "context PR auto-close-without-merge events — flag if rate > 10% (suggests merge-order confusion)" + ] + }, + "areas_for_human_review": [ + "R2 / R9: Operator chose hard-switchover; confirm they want a drain runbook (recommended) vs. force-restart CLI vs. abandon-in-flight.", + "R4: Confirm 'merge order: context first' is advisory text only, not a GH branch-protection enforcement.", + "R5: Confirm public-repo exposure of agent transcripts is acceptable; add content scrubber + opt-out flag.", + "R10: What should happen if context-PR CREATION fails? Decision-3 covers merge, not creation. New HITL question recommended.", + "R12: Maximum-transparency transcripts may produce 5MB+ diffs no reviewer reads. Compress / index?" + ], + "complexity_assessment": { + "rating": "high", + "reasoning": "8 distinct subsystems touched (gateway regex + push exemptions, contract schema + migration, BRC history naming + read sites, orchestrator slice provisioning, reconciler fallback, context-PR creation primitive, slice-PR body renderer, agent-transcript scrubbing). Decision-4's hard-switchover dominates the migration risk — without a force-restart escape hatch, any in-flight pipeline at upgrade time is potentially lost work. Decision-5's parametric base_branch (not main) is a small but pervasive change that must be threaded through every PR-body template and default refspec." + }, + "go_no_go_recommendation": { + "verdict": "go-with-conditions", + "conditions": [ + "Hard requirement: gateway regex extension lands as the first slice. The orchestrator slices that depend on it cannot land first or pipelines stall immediately.", + "Hard requirement: every new contract.pr.context_* field has a safe default (Optional, default=None / default_factory=list). Pydantic validation crashes on legacy contract load are NOT what decision-4 chose.", + "Hard requirement: surface a HITL question for context-PR-creation failure semantics (R10) before the orchestrator slice that calls create_context_pr() lands.", + "Hard requirement: surface a HITL question for agent-transcript public exposure + size-budget (R5, R12) before the slice that copies transcripts into the context branch lands.", + "Strong recommendation: add a force-restart CLI for stranded in-flight pipelines (R9). Even though decision-4 says no backfill, no-escape-hatch is operator-hostile." + ] + }, + "artifacts_authored": [ + ".egg-state/agent-outputs/2548-risk_analyst-output.json" + ] +} diff --git a/.egg-state/brc-history/2548-plan.json b/.egg-state/brc-history/2548-plan.json new file mode 100644 index 0000000000..7461b621ca --- /dev/null +++ b/.egg-state/brc-history/2548-plan.json @@ -0,0 +1,858 @@ +[ + { + "id": "e5e93357-6c89-46", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:28:41.754573+00:00" + }, + "timestamp": "2026-05-07T18:28:53.308564+00:00", + "phase": "plan" + }, + { + "id": "7fe0a895-8b0b-49", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:31:45.053610+00:00" + }, + "timestamp": "2026-05-07T18:31:45.086145+00:00", + "phase": "plan" + }, + { + "id": "76561448-bd7c-45", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:31:45.053610+00:00" + }, + "timestamp": "2026-05-07T18:32:45.179278+00:00", + "phase": "plan" + }, + { + "id": "85239e17-39a1-4a", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:31:45.053610+00:00" + }, + "timestamp": "2026-05-07T18:33:45.287588+00:00", + "phase": "plan" + }, + { + "id": "0b0c1ef7-3ec9-4d", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:31:45.053610+00:00" + }, + "timestamp": "2026-05-07T18:34:45.379217+00:00", + "phase": "plan" + }, + { + "id": "04da4cfc-d607-48", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from risk_analyst", + "body": "Risk assessment for issue #2548 plan phase: identifies 14 distinct risks (R1\u2013R14) spanning gateway-policy compatibility, contract-schema migration, BRC-history file-naming centralization, stacked-PR reconciler invariants, agent-transcript exposure, and operator-experience around the hard-switchover that decision-4 selected. Captures all five HITL decision inputs (D1 dedicated context PR, D2 per-slice BRC split at write time, D3 doc-only auto-open, D4 hard switchover, D5 parametric base_branch) and feedback-1 Q1\u2013Q5 as decision_inputs. Recommends go-with-conditions: gateway regex extension must ship first, every new PRMetadata field must be Optional with safe defaults, and two new HITL questions are needed (context-PR creation-failure semantics and transcript size/scrub policy). Includes consolidated mitigation strategy with launch ordering, drain runbook, and post-launch monitoring counters.", + "metadata": { + "payload": { + "summary": "Risk assessment for issue #2548 plan phase: identifies 14 distinct risks (R1\u2013R14) spanning gateway-policy compatibility, contract-schema migration, BRC-history file-naming centralization, stacked-PR reconciler invariants, agent-transcript exposure, and operator-experience around the hard-switchover that decision-4 selected. Captures all five HITL decision inputs (D1 dedicated context PR, D2 per-slice BRC split at write time, D3 doc-only auto-open, D4 hard switchover, D5 parametric base_branch) and feedback-1 Q1\u2013Q5 as decision_inputs. Recommends go-with-conditions: gateway regex extension must ship first, every new PRMetadata field must be Optional with safe defaults, and two new HITL questions are needed (context-PR creation-failure semantics and transcript size/scrub policy). Includes consolidated mitigation strategy with launch ordering, drain runbook, and post-launch monitoring counters.", + "attestation": {}, + "artifacts": [ + ".egg-state/agent-outputs/2548-risk_analyst-output.json" + ], + "risk_considered": "High-impact risks enumerated R1 (gateway regex blocks context-branch push), R2/R9 (hard-switchover with no escape hatch for in-flight pipelines), R8 (Pydantic crash on legacy contract load if defaults missing). Medium: R3/R7 (reconciler fallback wrong post-context-PR), R5 (public-repo agent-transcript exposure), R6 (BRC read-site fan-out), R10 (creation-failure undefined), R11 (gateway second-push exemption), R12 (oversized PR diffs), R13 (test coverage gap), R14 (parametric base_branch). Low: R4 (subsumed PR auto-close cosmetic).", + "commit_sha": "f881dde4fa171d0aa027b626afa9313a4b8bd0fa", + "files_changed": [ + ".egg-state/agent-outputs/2548-risk_analyst-output.json" + ], + "tests_run": [], + "tasks_satisfied": [] + }, + "version": 1, + "commit_sha": "f881dde4fa171d0aa027b626afa9313a4b8bd0fa" + }, + "timestamp": "2026-05-07T18:35:26.539699+00:00", + "phase": "plan" + }, + { + "id": "ed53085d-6a9a-4a", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:35:26.567868+00:00", + "phase": "plan" + }, + { + "id": "f95f4329-e6ce-4c", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:35:31.654409+00:00" + }, + "timestamp": "2026-05-07T18:35:51.225573+00:00", + "phase": "plan" + }, + { + "id": "be811ecb-7c50-4d", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from task_planner", + "body": "Plan #2548 decomposes the context-PR + per-slice BRC work into a five-slice serialized forest chain matching the operator's HITL resolutions (D1 context PR, D2 per-slice implement BRC files, D3 doc-only auto-open, D4 hard switchover, D5 context base = pipeline.base_branch). Slice-1 ships the PRMetadata contract delta (context_title/context_description/context_branch/context_pr_number) and updates the planner prompt + plan_yaml ingestion. Slice-2 hard-switches _write_brc_history() / _rewrite_brc_history_for_pr() / _persist_phase_brc_history() so implement-phase BRC writes route to -implement-slice-.{json,md} with no aggregate file. Slice-3 adds a gateway primitive that creates egg//context from pipeline.base_branch, copies refine + plan artifacts (analysis.md, plan.md, refine/plan BRC files, refine/plan agent transcripts) onto it, and opens a doc-only auto-open PR that does not block slice-1 spawning. Slice-4 wires slice-1's parent_branch to contract.pr.context_branch, lands per-slice implement BRC files on each slice integration branch as a final orchestrator-authored commit before the slice PR is opened, and updates the stacked-PR reconciler fallback to prefer the context branch over pipeline_branch. Slice-5 (documenter) refreshes docs/guides/concurrent-execution.md, docs/architecture/orchestrator.md, docs/reference/orchestrator-cli.md, and docs/guides/babysit-pr.md. Test strategy covers PRMetadata round-trip, per-slice BRC writer, context-branch primitive idempotency, end-to-end PR-stack fixture asserting context PR + N slice PRs with no aggregate file. Manual verification spelled out. No .github/ changes anticipated; if any surface, they'll be staged under .github-staging/.", + "metadata": { + "payload": { + "summary": "Plan #2548 decomposes the context-PR + per-slice BRC work into a five-slice serialized forest chain matching the operator's HITL resolutions (D1 context PR, D2 per-slice implement BRC files, D3 doc-only auto-open, D4 hard switchover, D5 context base = pipeline.base_branch). Slice-1 ships the PRMetadata contract delta (context_title/context_description/context_branch/context_pr_number) and updates the planner prompt + plan_yaml ingestion. Slice-2 hard-switches _write_brc_history() / _rewrite_brc_history_for_pr() / _persist_phase_brc_history() so implement-phase BRC writes route to -implement-slice-.{json,md} with no aggregate file. Slice-3 adds a gateway primitive that creates egg//context from pipeline.base_branch, copies refine + plan artifacts (analysis.md, plan.md, refine/plan BRC files, refine/plan agent transcripts) onto it, and opens a doc-only auto-open PR that does not block slice-1 spawning. Slice-4 wires slice-1's parent_branch to contract.pr.context_branch, lands per-slice implement BRC files on each slice integration branch as a final orchestrator-authored commit before the slice PR is opened, and updates the stacked-PR reconciler fallback to prefer the context branch over pipeline_branch. Slice-5 (documenter) refreshes docs/guides/concurrent-execution.md, docs/architecture/orchestrator.md, docs/reference/orchestrator-cli.md, and docs/guides/babysit-pr.md. Test strategy covers PRMetadata round-trip, per-slice BRC writer, context-branch primitive idempotency, end-to-end PR-stack fixture asserting context PR + N slice PRs with no aggregate file. Manual verification spelled out. No .github/ changes anticipated; if any surface, they'll be staged under .github-staging/.", + "attestation": {}, + "artifacts": [ + ".egg-state/drafts/2548-plan.md" + ], + "risk_considered": "Stacked-PR reconciler fallback change is feature-isolated to the new code path (slice-4 TASK-4-3); D3's doc-only auto-open means slice-1 is unblocked even if context PR is unmerged \u2014 surfaced via slice PR backlinks; branch-creation race tolerated (GitHub UI surfaces \"behind by N\"); D4 hard switchover means no in-flight pipeline backfill (issue-2474-v2 will not be retroactively fixed) \u2014 explicit operator decision.", + "commit_sha": "6e009f17633eecc3f209a7fbf53d1305dc0e19ce", + "files_changed": [ + ".egg-state/drafts/2548-plan.md" + ], + "tests_run": [], + "tasks_satisfied": [] + }, + "version": 1, + "commit_sha": "6e009f17633eecc3f209a7fbf53d1305dc0e19ce" + }, + "timestamp": "2026-05-07T18:36:41.625502+00:00", + "phase": "plan" + }, + { + "id": "b8b08a9a-7499-42", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from architect", + "body": "Architect output for #2548 plan phase: design for the context PR (egg//context, base=) that carries refine/plan analysis docs, agent transcripts, and refine+plan BRC histories; slice-1 stacks on top of it. Implement-phase BRC history split at write time into -implement-slice-.{json,md} per slice (no aggregate). Doc-only auto-open (no BRC roster, no merge gate). Orchestrator-authored commits. Hard switchover for new pipelines only \u2014 no backfill, no migration. PRMetadata gains optional context_title / context_description / context_pr_number / context_branch fields. _SLICE_INTEGRATION_BRANCH_RE in gateway/gateway.py extended to admit egg//context. _run_one_slice_inner root-slice resolution prefers contract.pr.context_branch when present, else pipeline_branch. Stacked-PR reconciler _resolve_extant_new_base() falls back to context_branch then pipeline.base_branch. All HITL decisions 1-5 and feedback Q1-Q5 from refine reflected. 4 slices proposed; 9 files modified, 3 created. Open question Q1: verify BRC message store carries slice_id metadata on implement messages (spike during slice-1).", + "metadata": { + "payload": { + "summary": "Architect output for #2548 plan phase: design for the context PR (egg//context, base=) that carries refine/plan analysis docs, agent transcripts, and refine+plan BRC histories; slice-1 stacks on top of it. Implement-phase BRC history split at write time into -implement-slice-.{json,md} per slice (no aggregate). Doc-only auto-open (no BRC roster, no merge gate). Orchestrator-authored commits. Hard switchover for new pipelines only \u2014 no backfill, no migration. PRMetadata gains optional context_title / context_description / context_pr_number / context_branch fields. _SLICE_INTEGRATION_BRANCH_RE in gateway/gateway.py extended to admit egg//context. _run_one_slice_inner root-slice resolution prefers contract.pr.context_branch when present, else pipeline_branch. Stacked-PR reconciler _resolve_extant_new_base() falls back to context_branch then pipeline.base_branch. All HITL decisions 1-5 and feedback Q1-Q5 from refine reflected. 4 slices proposed; 9 files modified, 3 created. Open question Q1: verify BRC message store carries slice_id metadata on implement messages (spike during slice-1).", + "attestation": {}, + "artifacts": [ + ".egg-state/agent-outputs/2548-architect-output.json" + ], + "risk_considered": "Branch topology change touches slice spawning + reconciler; mitigated by gating all new behavior on contract.pr.context_branch presence so legacy pipelines are unaffected. Hard switchover (decision-4) intentional; schema additions default-tolerant for read of old contracts. orchestrator/routes/pipelines.py is large and being decomposed in #2261; new code slots into pre-allocated submodule clusters per orchestrator/CLAUDE.md. BRC slice_id metadata availability flagged as Q1 spike for slice-1.", + "commit_sha": "e84972d400f76d91b3fed34973e2e1d8d73c80dd", + "files_changed": [ + ".egg-state/agent-outputs/2548-architect-output.json" + ], + "tests_run": [], + "tasks_satisfied": [] + }, + "version": 1, + "commit_sha": "e84972d400f76d91b3fed34973e2e1d8d73c80dd" + }, + "timestamp": "2026-05-07T18:36:44.029285+00:00", + "phase": "plan" + }, + { + "id": "1d6f9b29-78ea-42", + "pipeline_id": "issue-2548", + "from_role": "orchestrator", + "to_role": "risk_analyst", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1 + }, + "timestamp": "2026-05-07T18:36:44.029328+00:00", + "phase": "plan" + }, + { + "id": "2fd944f2-5090-4c", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:36:44.065944+00:00", + "phase": "plan" + }, + { + "id": "1bcbcbe9-cf39-45", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by risk_analyst", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-05-07T18:37:06.196964+00:00", + "phase": "plan" + }, + { + "id": "850b73d9-9cf7-4b", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:36:55.607881+00:00" + }, + "timestamp": "2026-05-07T18:37:06.210156+00:00", + "phase": "plan" + }, + { + "id": "8f0e0976-2d94-42", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "risk_analyst", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_plan for risk_analyst", + "body": "\nReviewed the risk_analyst's full risk-assessment artifact (`.egg-state/agent-outputs/2548-risk_analyst-output.json`, 383 lines, 14 risks R1\u2013R14) and cross-referenced its evidence pointers against the codebase and the resolved HITL set.\n\n### Verification\n\n- **decision_inputs accurately reflect HITL state.** All 5 decisions and 5 feedback Q answers from the resolved refine-phase HITL set are mirrored verbatim (decision-1: dedicated context PR; decision-2: split-at-write per-slice only; decision-3: doc-only auto-open no merge gate; decision-4: hard switchover no backfill; decision-5: base = `pipeline.base_branch` not hardcoded main; Q1\u2013Q5 as recorded). No drift.\n- **R1 evidence verified.** `gateway/gateway.py:1085` confirms `_SLICE_INTEGRATION_BRANCH_RE = re.compile(r\"^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\\d+$\")` and `:1307` confirms it gates the synthetic-session exemption. Branch `egg//context` will indeed be rejected \u2014 R1 is real, near-certain, and correctly ordered as the prerequisite slice.\n- **R3/R7 evidence verified.** `orchestrator/stacked_pr_reconciler.py:_resolve_extant_new_base()` (lines 87\u2013132) does fall back to `pipeline_branch`. Under the new model, that fallback target is wrong; R3's recommendation to fall back to `contract.pr.context_branch` then `pipeline.base_branch` is correct.\n- **R8 evidence verified.** `shared/egg_contracts/models.py:371\u2013427` (PRMetadata) has no `context_*` fields today, and the existing `_migrate_phases_to_slices` shim (lines 682\u2013749) is the right precedent. The \"fields must be Optional with safe defaults\" hard-requirement is correct \u2014 Pydantic v2 will raise on missing required fields.\n- **R6 evidence verified.** `_write_brc_history` (8110\u20138228) and `_rewrite_brc_history_for_pr` (8265\u20138328) are both implicated; the recommendation to centralize naming through a single helper before flipping to per-slice is sound.\n\n### Strengths\n\n1. **Risk taxonomy is comprehensive without padding.** 14 risks across compatibility (gateway-policy, schema, migration, orphan-recovery, branch-resolution, internal-api, hitl-flow), security (data-exposure), performance (scheduler-load, ux), operator-experience (migration, merge-flow), and testing (regression-risk). Each risk maps to a specific code surface; no generic \"things could break\" filler.\n2. **Hard-switchover (decision-4) consequences are surfaced front and center.** R2 + R9 + the drain runbook section explicitly call out that decision-4 leaves in-flight pipelines stranded, and R8 nails the corollary: even with hard switchover, the new fields STILL need safe defaults so contract-load doesn't crash. That's a correctly nuanced reading \u2014 the operator chose \"no backfill\" for behavior, not \"crash on legacy load\".\n3. **Launch ordering is concrete and dependency-correct.** The 8-step `consolidated_mitigation_strategy.ordering` lands the gateway regex first (R1 prerequisite), schema with safe defaults second (R8/R2/R9), then BRC-history naming centralization before the per-slice split (R6), and tests last (R13). Task_planner can lift this ordering directly into slice DAG dependencies.\n4. **Areas-for-human-review are well-scoped.** R2/R9 (drain runbook vs. force-restart escape hatch), R4 (advisory merge-order vs. branch-protection enforcement), R5 (public-repo transcript exposure + content scrubber), R10 (context-PR-CREATION failure semantics, distinct from decision-3's merge gate), R12 (transparency vs. 5MB diff). These are all real operator-owned questions that decision-3/decision-4 don't cover.\n5. **R10 catches a genuine gap in the resolved HITL set.** Decision-3 says \"no merge gate\" for the context PR, but is silent on creation failure (gateway 500, GH API 5xx, rate-limit). Surfacing this as a new HITL question for the planner to register is exactly the right move.\n\n### Non-blocking\n- **R1 mitigation regex is incorrectly grouped.** The suggested replacement `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\\d+|context$` parses as `(^egg/.../(?:slice|phase)-\\d+) | (context$)` because `|` has the lowest precedence \u2014 it would match any string ending in literal \"context\", including `egg//context-foo-context` or unrelated branches like `feature/some-context`. The correct extension is `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:(?:slice|phase)-\\d+|context)$`. The task_planner / coder picking up R1 should use that grouping, not the literal text in this risk doc.\n- **R12 size threshold (5MB) is asserted without empirical anchor.** \"Empirical: 2548-refine.md is already 689 lines after ONE round\" supports \"transcripts may be large\" but doesn't justify exactly 5MB. A more useful budget would be \"\u22651000 changed lines OR \u22651MB total\" since GitHub's diff-rendering degradation kicks in earlier than 3\u20135MB on text. Not blocking; the planner can refine this when picking up R12.\n- **R11 references symbol `is_slice_integration_push`.** That looks paraphrased \u2014 the actual gateway code uses `_SLICE_INTEGRATION_BRANCH_RE.match(branch)` directly inside the synthetic-session push handler (`gateway.py:1296\u20131332`). Worth confirming whether the SECOND push to a slice branch actually re-evaluates the regex (it should, since it's a per-push check, not a per-branch lock-in) \u2014 if so, R11 may be a non-issue and can be downgraded; the risk_analyst should verify and either confirm or remove.\n- **R13 doesn't explicitly call out `pipeline.base_branch != \"main\"` in the test matrix despite R14 flagging it as a previously-untested parameter.** Suggest the planner ensure at least one happy-path integration test parametrizes base_branch to `\"develop\"` (or similar) so R13 + R14 are jointly covered.\n- **No risk explicitly addresses `egg//work` becoming dead weight.** Once slice-1 bases on `egg//context` (which bases on `pipeline.base_branch`), the `egg//work` branch loses its role as the merge spine. Feedback Q1 says \"work\u2192main gap is in scope\" but no R-entry explicitly covers what becomes of the work branch \u2014 is it still committed to (analysis/plan are still written there during refine/plan phases per `_commit_statefiles_to_worktree`) but no longer merged? Or does the orchestrator stop using it entirely once context exists? The planner should clarify this lifecycle in the plan; flagging now so the task_planner addresses it.\n\nThe risk doc is approval-quality. No blocking issues. The non-blocking items above are refinements the producer or downstream slice owners can pick up; none invalidate the analysis or the go-with-conditions verdict.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/agent-outputs/2548-risk_analyst-output.json", + ".egg-state/drafts/2548-analysis.md", + "gateway/gateway.py" + ], + "reason": "\nReviewed the risk_analyst's full risk-assessment artifact (`.egg-state/agent-outputs/2548-risk_analyst-output.json`, 383 lines, 14 risks R1\u2013R14) and cross-referenced its evidence pointers against the codebase and the resolved HITL set.\n\n### Verification\n\n- **decision_inputs accurately reflect HITL state.** All 5 decisions and 5 feedback Q answers from the resolved refine-phase HITL set are mirrored verbatim (decision-1: dedicated context PR; decision-2: split-at-write per-slice only; decision-3: doc-only auto-open no merge gate; decision-4: hard switchover no backfill; decision-5: base = `pipeline.base_branch` not hardcoded main; Q1\u2013Q5 as recorded). No drift.\n- **R1 evidence verified.** `gateway/gateway.py:1085` confirms `_SLICE_INTEGRATION_BRANCH_RE = re.compile(r\"^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\\d+$\")` and `:1307` confirms it gates the synthetic-session exemption. Branch `egg//context` will indeed be rejected \u2014 R1 is real, near-certain, and correctly ordered as the prerequisite slice.\n- **R3/R7 evidence verified.** `orchestrator/stacked_pr_reconciler.py:_resolve_extant_new_base()` (lines 87\u2013132) does fall back to `pipeline_branch`. Under the new model, that fallback target is wrong; R3's recommendation to fall back to `contract.pr.context_branch` then `pipeline.base_branch` is correct.\n- **R8 evidence verified.** `shared/egg_contracts/models.py:371\u2013427` (PRMetadata) has no `context_*` fields today, and the existing `_migrate_phases_to_slices` shim (lines 682\u2013749) is the right precedent. The \"fields must be Optional with safe defaults\" hard-requirement is correct \u2014 Pydantic v2 will raise on missing required fields.\n- **R6 evidence verified.** `_write_brc_history` (8110\u20138228) and `_rewrite_brc_history_for_pr` (8265\u20138328) are both implicated; the recommendation to centralize naming through a single helper before flipping to per-slice is sound.\n\n### Strengths\n\n1. **Risk taxonomy is comprehensive without padding.** 14 risks across compatibility (gateway-policy, schema, migration, orphan-recovery, branch-resolution, internal-api, hitl-flow), security (data-exposure), performance (scheduler-load, ux), operator-experience (migration, merge-flow), and testing (regression-risk). Each risk maps to a specific code surface; no generic \"things could break\" filler.\n2. **Hard-switchover (decision-4) consequences are surfaced front and center.** R2 + R9 + the drain runbook section explicitly call out that decision-4 leaves in-flight pipelines stranded, and R8 nails the corollary: even with hard switchover, the new fields STILL need safe defaults so contract-load doesn't crash. That's a correctly nuanced reading \u2014 the operator chose \"no backfill\" for behavior, not \"crash on legacy load\".\n3. **Launch ordering is concrete and dependency-correct.** The 8-step `consolidated_mitigation_strategy.ordering` lands the gateway regex first (R1 prerequisite), schema with safe defaults second (R8/R2/R9), then BRC-history naming centralization before the per-slice split (R6), and tests last (R13). Task_planner can lift this ordering directly into slice DAG dependencies.\n4. **Areas-for-human-review are well-scoped.** R2/R9 (drain runbook vs. force-restart escape hatch), R4 (advisory merge-order vs. branch-protection enforcement), R5 (public-repo transcript exposure + content scrubber), R10 (context-PR-CREATION failure semantics, distinct from decision-3's merge gate), R12 (transparency vs. 5MB diff). These are all real operator-owned questions that decision-3/decision-4 don't cover.\n5. **R10 catches a genuine gap in the resolved HITL set.** Decision-3 says \"no merge gate\" for the context PR, but is silent on creation failure (gateway 500, GH API 5xx, rate-limit). Surfacing this as a new HITL question for the planner to register is exactly the right move.\n\n### Non-blocking\n- **R1 mitigation regex is incorrectly grouped.** The suggested replacement `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\\d+|context$` parses as `(^egg/.../(?:slice|phase)-\\d+) | (context$)` because `|` has the lowest precedence \u2014 it would match any string ending in literal \"context\", including `egg//context-foo-context` or unrelated branches like `feature/some-context`. The correct extension is `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:(?:slice|phase)-\\d+|context)$`. The task_planner / coder picking up R1 should use that grouping, not the literal text in this risk doc.\n- **R12 size threshold (5MB) is asserted without empirical anchor.** \"Empirical: 2548-refine.md is already 689 lines after ONE round\" supports \"transcripts may be large\" but doesn't justify exactly 5MB. A more useful budget would be \"\u22651000 changed lines OR \u22651MB total\" since GitHub's diff-rendering degradation kicks in earlier than 3\u20135MB on text. Not blocking; the planner can refine this when picking up R12.\n- **R11 references symbol `is_slice_integration_push`.** That looks paraphrased \u2014 the actual gateway code uses `_SLICE_INTEGRATION_BRANCH_RE.match(branch)` directly inside the synthetic-session push handler (`gateway.py:1296\u20131332`). Worth confirming whether the SECOND push to a slice branch actually re-evaluates the regex (it should, since it's a per-push check, not a per-branch lock-in) \u2014 if so, R11 may be a non-issue and can be downgraded; the risk_analyst should verify and either confirm or remove.\n- **R13 doesn't explicitly call out `pipeline.base_branch != \"main\"` in the test matrix despite R14 flagging it as a previously-untested parameter.** Suggest the planner ensure at least one happy-path integration test parametrizes base_branch to `\"develop\"` (or similar) so R13 + R14 are jointly covered.\n- **No risk explicitly addresses `egg//work` becoming dead weight.** Once slice-1 bases on `egg//context` (which bases on `pipeline.base_branch`), the `egg//work` branch loses its role as the merge spine. Feedback Q1 says \"work\u2192main gap is in scope\" but no R-entry explicitly covers what becomes of the work branch \u2014 is it still committed to (analysis/plan are still written there during refine/plan phases per `_commit_statefiles_to_worktree`) but no longer merged? Or does the orchestrator stop using it entirely once context exists? The planner should clarify this lifecycle in the plan; flagging now so the task_planner addresses it.\n\nThe risk doc is approval-quality. No blocking issues. The non-blocking items above are refinements the producer or downstream slice owners can pick up; none invalidate the analysis or the go-with-conditions verdict.\n", + "ack_version": 1 + }, + "version": 1 + }, + "timestamp": "2026-05-07T18:37:35.463188+00:00", + "phase": "plan" + }, + { + "id": "ace22c64-048e-4b", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:37.488449+00:00" + }, + "timestamp": "2026-05-07T18:37:56.729679+00:00", + "phase": "plan" + }, + { + "id": "5aa3d6c5-5af8-4a", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:41.304292+00:00" + }, + "timestamp": "2026-05-07T18:37:56.730998+00:00", + "phase": "plan" + }, + { + "id": "abf51ee7-ed3b-4e", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:39.924179+00:00" + }, + "timestamp": "2026-05-07T18:37:56.732097+00:00", + "phase": "plan" + }, + { + "id": "544c1be1-eaf6-49", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:37:56.748330+00:00", + "phase": "plan" + }, + { + "id": "0339a710-4e3b-49", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:36:55.607881+00:00" + }, + "timestamp": "2026-05-07T18:38:06.247663+00:00", + "phase": "plan" + }, + { + "id": "ba1196da-61db-47", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:37.488449+00:00" + }, + "timestamp": "2026-05-07T18:38:52.603450+00:00", + "phase": "plan" + }, + { + "id": "47e058a2-b3df-4a", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:41.304292+00:00" + }, + "timestamp": "2026-05-07T18:38:56.370385+00:00", + "phase": "plan" + }, + { + "id": "4681a986-a958-4e", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:36:55.607881+00:00" + }, + "timestamp": "2026-05-07T18:39:06.347675+00:00", + "phase": "plan" + }, + { + "id": "8a9320ca-79a4-4d", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:37.488449+00:00" + }, + "timestamp": "2026-05-07T18:39:52.703697+00:00", + "phase": "plan" + }, + { + "id": "28634a9b-bdc8-4f", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:41.304292+00:00" + }, + "timestamp": "2026-05-07T18:39:56.424488+00:00", + "phase": "plan" + }, + { + "id": "331decba-546d-44", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:36:55.607881+00:00" + }, + "timestamp": "2026-05-07T18:40:06.468737+00:00", + "phase": "plan" + }, + { + "id": "bb287e93-54b3-40", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:37.488449+00:00" + }, + "timestamp": "2026-05-07T18:40:52.789871+00:00", + "phase": "plan" + }, + { + "id": "e514c66d-7304-41", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:41.304292+00:00" + }, + "timestamp": "2026-05-07T18:40:56.541417+00:00", + "phase": "plan" + }, + { + "id": "4cfc4258-d2bb-46", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:36:55.607881+00:00" + }, + "timestamp": "2026-05-07T18:41:06.527032+00:00", + "phase": "plan" + }, + { + "id": "52765d57-1b7e-48", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:37.488449+00:00" + }, + "timestamp": "2026-05-07T18:41:52.874174+00:00", + "phase": "plan" + }, + { + "id": "84716e7c-c073-46", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:41.304292+00:00" + }, + "timestamp": "2026-05-07T18:41:56.583146+00:00", + "phase": "plan" + }, + { + "id": "ea06a93f-15eb-49", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:36:55.607881+00:00" + }, + "timestamp": "2026-05-07T18:42:06.570731+00:00", + "phase": "plan" + }, + { + "id": "bba47ba2-d511-45", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "architect", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_plan for architect", + "body": "\nReviewed `.egg-state/agent-outputs/2548-architect-output.json` (455 lines, components C1\u2013C7, 8 risks, 3 open questions, alternatives section, branch topology diagram) at proposal version 1, commit e84972d40, against the resolved HITL set, the analysis, and the codebase.\n\n### Verification\n- **HITL traceability is complete.** All 5 decisions and all 5 feedback Qs are mirrored in `hitl_resolutions` with implications spelled out, and the design honors each of them \u2014 base = `pipeline.base_branch` not hardcoded `main` (D5), per-slice writes only with no aggregate (D2), doc-only auto-open with no merge gate (D3), schema additions default-tolerant but no migration path (D4), separate `context_title`/`context_description` fields (Q2), agent transcripts included on the context PR (Q3), orchestrator-authored BRC commits (Q4), per-slice-only BRC files on slice PRs (Q5).\n- **Codebase claims verified.** `_SLICE_INTEGRATION_BRANCH_RE` at `gateway/gateway.py:1085` and the synthetic-session exemption at `:1296\u20131332` are exactly as cited. `_resolve_extant_new_base()` in `stacked_pr_reconciler.py:87\u2013132` does fall back to `pipeline_branch` as described. `_run_one_slice_inner` at `pipelines.py:12405\u201312454` is the right place for slice-1 root resolution. `PRMetadata` at `models.py:371\u2013426` has the listed fields and no `context_*`. `_write_brc_history` (8110\u20138260), `_persist_phase_brc_history` (8355\u20138400), and `_rewrite_brc_history_for_pr` (8265\u20138330) are correctly cited.\n- **Component design is internally consistent.** C1 (context branch + PR), C2 (per-slice BRC), C3 (slice-1 root), C4 (reconciler awareness), C5 (schema), C6 (docs), C7 (tests) cover the surface area with no gaps. Failure handling for context-PR creation is non-blocking and yields to the legacy fallback \u2014 that posture is consistent with D3.\n- **Branch topology diagram is correct** and matches `docs/guides/concurrent-execution.md`'s existing stack model.\n- **Alternatives section** correctly cites which HITL decisions ruled out each alternative.\n\n### Strengths\n1. **`consensus_inputs_for_reviewer_plan` is unusually useful.** The \"what to NACK on\" list (no hardcoded `main`, no aggregate file, no merge-blocking, no backfill, no coder/tester authoring of `.egg-state/brc-history/` commits) gives the reviewer + downstream slice owners explicit invariants to enforce. Encourage other architects to mirror this pattern.\n2. **R6 (decomposition coordination with #2261)** is a real risk that's easy to miss. The architect correctly identifies that 5+ adjacent additions to `orchestrator/routes/pipelines.py` (currently 16k+ lines) need to slot into the pre-allocated `_pr_lifecycle/` and `_concurrent_phase/` clusters per `orchestrator/CLAUDE.md`. This is the right mitigation given the slice-15 of #2261 hasn't landed yet.\n3. **Failure handling for context-PR creation is well-specified.** The \"log + STATUS broadcast + slice-1 falls back to pipeline_branch\" path exactly addresses risk_analyst R10 (creation-failure semantics not covered by D3). Architect surfaced this independently.\n4. **Open questions Q1\u2013Q3 are appropriately scoped** to spike-during-plan rather than blocking; recommendations are concrete (compute `context_branch` deterministically from `pipeline_identifier`, no additional handling for human-closed context PRs).\n5. **artifact_set_for_context_pr explicitly excludes** implement-phase BRC and contract files \u2014 matches Q5's per-slice-only resolution and avoids double-shipping.\n\n### Non-blocking\n- **Slice count and ordering disagrees with task_planner.** Architect proposes 4 slices (slice-1: schema + BRC writer; slice-2: context PR + planner; slice-3: slice-1 root + reconciler + per-slice BRC commit; slice-4: docs + tests + cleanup). Task_planner proposes 5 (slice-1: schema + planner; slice-2: BRC writer; slice-3: context PR; slice-4: stack rewiring; slice-5: docs). Both decompose the same surface but bundle differently. Either decomposition works; the operator/coder needs one canonical plan. Recommend the planner reconcile to the canonical 5-slice ordering already published in `2548-plan.md`, since that's what `_populate_contract_from_plan` will actually ingest. The architect's analysis remains valid as design rationale \u2014 flag the divergence in the plan's \"alignment\" section and pick task_planner's slice ordering as authoritative for ingestion.\n- **Default-type inconsistency on `context_title`/`context_description`.** Architect's C5 says `context_title (str, default='')`, `context_description (str, default='')`, but `context_pr_number (int | None, default=None)` and `context_branch (str | None, default=None)`. Task_planner's TASK-1-1 uses `str | None = None` for all four. Either choice is valid Pydantic, but downstream code (e.g. R7 mitigation \"fall back to a sensible default when planner-supplied fields are empty\") branches differently on `''` vs `None`. Pick one and align. Recommendation: `str | None = None` for all four (simpler `is None` guard, matches task_planner's plan, and the falsy check `field or default` covers both empty-string and None equivalently if the renderer needs it).\n- **Q2 recommendation conflicts with task_planner's design.** Architect recommends \"Compute `context_branch` deterministically from `pipeline_identifier`. Add the contract field only as a presence flag.\" Task_planner persists `context_branch` as a real string in TASK-1-1. The architect's recommendation is sound (less invariant to maintain, no contract write needed) but task_planner already chose persistence. Either is fine; the planner should either (a) align to deterministic computation and drop `context_branch` from PRMetadata (keeping only `context_pr_number` as the presence flag), or (b) document why persistence won (e.g. read-time consumers shouldn't need to know the naming convention). Default to (b) for now since it matches task_planner; the deterministic path is a refactor for later.\n- **R5 (transcript content scrubber) from risk_analyst is not picked up.** Risk_analyst R5 flags that `.egg-state/agent-outputs/-{refine,plan}-*.md` may contain reasoning that quoted secrets / internal-only error messages, and `egg`'s repo is public. Architect's `artifact_set_for_context_pr.agent_transcripts` lists the transcripts without a scrubbing step or HITL check. Suggest C1 grow a sub-step \"scan transcripts for high-entropy / known-secret patterns; if hit, fail open with operator alert\" or surface a HITL question. Not blocking the plan, but the slice that lands C1 should not ship without addressing this.\n- **C2 \"open_concern\" about slice_id metadata in BRC store is overcautious.** I verified `routes/signals.py:1480\u20131487` already filters `metadata[\"slice_id\"]` per-slice via the post-#2535 routing layer, and `peer_consensus.py:1875` documents the nested `{pipeline_id}/{slice_id}` BRC scope. The implement-phase BRC store DOES carry slice_id today. The spike the architect recommends is still a good 30-minute safety check during implementation but is not a real risk to the plan itself. Downgrade to verification step in slice-1.\n- **C5's planner-prompt update target** is `shared/agent_prompts/planner-*` (architect) but task_planner targets `orchestrator/routes/pipelines.py` line ~11046. Both touchpoints exist (the planner has its own prompt file AND the orchestrator embeds prompt context); the slice-1 coder needs to update both. The architect's analysis would be stronger if it explicitly enumerated both locations.\n\n### Phrasing nit\n- The opening summary describes \"Hard switchover for new pipelines only\" \u2014 accurate, but a reader skimming might miss that the schema change still applies to old contracts read post-upgrade. Consider rewording to \"Hard switchover behavior; schema additions are default-tolerant for read-only legacy load.\"\n\nThe architecture analysis is approval-quality: HITL fidelity is total, code-citation accuracy is high, design components are coherent, and the non-blocking items above are reconciliation/refinement work the planner and coder can pick up. No blocking issues.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/agent-outputs/2548-architect-output.json", + ".egg-state/drafts/2548-analysis.md", + "shared/egg_contracts/models.py", + "orchestrator/routes/pipelines.py", + "gateway/gateway.py", + "orchestrator/stacked_pr_reconciler.py" + ], + "reason": "\nReviewed `.egg-state/agent-outputs/2548-architect-output.json` (455 lines, components C1\u2013C7, 8 risks, 3 open questions, alternatives section, branch topology diagram) at proposal version 1, commit e84972d40, against the resolved HITL set, the analysis, and the codebase.\n\n### Verification\n- **HITL traceability is complete.** All 5 decisions and all 5 feedback Qs are mirrored in `hitl_resolutions` with implications spelled out, and the design honors each of them \u2014 base = `pipeline.base_branch` not hardcoded `main` (D5), per-slice writes only with no aggregate (D2), doc-only auto-open with no merge gate (D3), schema additions default-tolerant but no migration path (D4), separate `context_title`/`context_description` fields (Q2), agent transcripts included on the context PR (Q3), orchestrator-authored BRC commits (Q4), per-slice-only BRC files on slice PRs (Q5).\n- **Codebase claims verified.** `_SLICE_INTEGRATION_BRANCH_RE` at `gateway/gateway.py:1085` and the synthetic-session exemption at `:1296\u20131332` are exactly as cited. `_resolve_extant_new_base()` in `stacked_pr_reconciler.py:87\u2013132` does fall back to `pipeline_branch` as described. `_run_one_slice_inner` at `pipelines.py:12405\u201312454` is the right place for slice-1 root resolution. `PRMetadata` at `models.py:371\u2013426` has the listed fields and no `context_*`. `_write_brc_history` (8110\u20138260), `_persist_phase_brc_history` (8355\u20138400), and `_rewrite_brc_history_for_pr` (8265\u20138330) are correctly cited.\n- **Component design is internally consistent.** C1 (context branch + PR), C2 (per-slice BRC), C3 (slice-1 root), C4 (reconciler awareness), C5 (schema), C6 (docs), C7 (tests) cover the surface area with no gaps. Failure handling for context-PR creation is non-blocking and yields to the legacy fallback \u2014 that posture is consistent with D3.\n- **Branch topology diagram is correct** and matches `docs/guides/concurrent-execution.md`'s existing stack model.\n- **Alternatives section** correctly cites which HITL decisions ruled out each alternative.\n\n### Strengths\n1. **`consensus_inputs_for_reviewer_plan` is unusually useful.** The \"what to NACK on\" list (no hardcoded `main`, no aggregate file, no merge-blocking, no backfill, no coder/tester authoring of `.egg-state/brc-history/` commits) gives the reviewer + downstream slice owners explicit invariants to enforce. Encourage other architects to mirror this pattern.\n2. **R6 (decomposition coordination with #2261)** is a real risk that's easy to miss. The architect correctly identifies that 5+ adjacent additions to `orchestrator/routes/pipelines.py` (currently 16k+ lines) need to slot into the pre-allocated `_pr_lifecycle/` and `_concurrent_phase/` clusters per `orchestrator/CLAUDE.md`. This is the right mitigation given the slice-15 of #2261 hasn't landed yet.\n3. **Failure handling for context-PR creation is well-specified.** The \"log + STATUS broadcast + slice-1 falls back to pipeline_branch\" path exactly addresses risk_analyst R10 (creation-failure semantics not covered by D3). Architect surfaced this independently.\n4. **Open questions Q1\u2013Q3 are appropriately scoped** to spike-during-plan rather than blocking; recommendations are concrete (compute `context_branch` deterministically from `pipeline_identifier`, no additional handling for human-closed context PRs).\n5. **artifact_set_for_context_pr explicitly excludes** implement-phase BRC and contract files \u2014 matches Q5's per-slice-only resolution and avoids double-shipping.\n\n### Non-blocking\n- **Slice count and ordering disagrees with task_planner.** Architect proposes 4 slices (slice-1: schema + BRC writer; slice-2: context PR + planner; slice-3: slice-1 root + reconciler + per-slice BRC commit; slice-4: docs + tests + cleanup). Task_planner proposes 5 (slice-1: schema + planner; slice-2: BRC writer; slice-3: context PR; slice-4: stack rewiring; slice-5: docs). Both decompose the same surface but bundle differently. Either decomposition works; the operator/coder needs one canonical plan. Recommend the planner reconcile to the canonical 5-slice ordering already published in `2548-plan.md`, since that's what `_populate_contract_from_plan` will actually ingest. The architect's analysis remains valid as design rationale \u2014 flag the divergence in the plan's \"alignment\" section and pick task_planner's slice ordering as authoritative for ingestion.\n- **Default-type inconsistency on `context_title`/`context_description`.** Architect's C5 says `context_title (str, default='')`, `context_description (str, default='')`, but `context_pr_number (int | None, default=None)` and `context_branch (str | None, default=None)`. Task_planner's TASK-1-1 uses `str | None = None` for all four. Either choice is valid Pydantic, but downstream code (e.g. R7 mitigation \"fall back to a sensible default when planner-supplied fields are empty\") branches differently on `''` vs `None`. Pick one and align. Recommendation: `str | None = None` for all four (simpler `is None` guard, matches task_planner's plan, and the falsy check `field or default` covers both empty-string and None equivalently if the renderer needs it).\n- **Q2 recommendation conflicts with task_planner's design.** Architect recommends \"Compute `context_branch` deterministically from `pipeline_identifier`. Add the contract field only as a presence flag.\" Task_planner persists `context_branch` as a real string in TASK-1-1. The architect's recommendation is sound (less invariant to maintain, no contract write needed) but task_planner already chose persistence. Either is fine; the planner should either (a) align to deterministic computation and drop `context_branch` from PRMetadata (keeping only `context_pr_number` as the presence flag), or (b) document why persistence won (e.g. read-time consumers shouldn't need to know the naming convention). Default to (b) for now since it matches task_planner; the deterministic path is a refactor for later.\n- **R5 (transcript content scrubber) from risk_analyst is not picked up.** Risk_analyst R5 flags that `.egg-state/agent-outputs/-{refine,plan}-*.md` may contain reasoning that quoted secrets / internal-only error messages, and `egg`'s repo is public. Architect's `artifact_set_for_context_pr.agent_transcripts` lists the transcripts without a scrubbing step or HITL check. Suggest C1 grow a sub-step \"scan transcripts for high-entropy / known-secret patterns; if hit, fail open with operator alert\" or surface a HITL question. Not blocking the plan, but the slice that lands C1 should not ship without addressing this.\n- **C2 \"open_concern\" about slice_id metadata in BRC store is overcautious.** I verified `routes/signals.py:1480\u20131487` already filters `metadata[\"slice_id\"]` per-slice via the post-#2535 routing layer, and `peer_consensus.py:1875` documents the nested `{pipeline_id}/{slice_id}` BRC scope. The implement-phase BRC store DOES carry slice_id today. The spike the architect recommends is still a good 30-minute safety check during implementation but is not a real risk to the plan itself. Downgrade to verification step in slice-1.\n- **C5's planner-prompt update target** is `shared/agent_prompts/planner-*` (architect) but task_planner targets `orchestrator/routes/pipelines.py` line ~11046. Both touchpoints exist (the planner has its own prompt file AND the orchestrator embeds prompt context); the slice-1 coder needs to update both. The architect's analysis would be stronger if it explicitly enumerated both locations.\n\n### Phrasing nit\n- The opening summary describes \"Hard switchover for new pipelines only\" \u2014 accurate, but a reader skimming might miss that the schema change still applies to old contracts read post-upgrade. Consider rewording to \"Hard switchover behavior; schema additions are default-tolerant for read-only legacy load.\"\n\nThe architecture analysis is approval-quality: HITL fidelity is total, code-citation accuracy is high, design components are coherent, and the non-blocking items above are reconciliation/refinement work the planner and coder can pick up. No blocking issues.\n", + "ack_version": 1 + }, + "version": 1 + }, + "timestamp": "2026-05-07T18:42:32.681466+00:00", + "phase": "plan" + }, + { + "id": "003a1d93-85c9-45", + "pipeline_id": "issue-2548", + "from_role": "orchestrator", + "to_role": "architect", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1 + }, + "timestamp": "2026-05-07T18:42:32.681499+00:00", + "phase": "plan" + }, + { + "id": "51568339-1151-48", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:42:32.725182+00:00", + "phase": "plan" + }, + { + "id": "7b3d0168-5a5f-48", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by architect", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-05-07T18:42:38.218672+00:00", + "phase": "plan" + }, + { + "id": "a78d6ab1-ec8f-4b", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:42:38.253585+00:00", + "phase": "plan" + }, + { + "id": "dfc893c6-47df-43", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:42:43.375204+00:00" + }, + "timestamp": "2026-05-07T18:42:43.410629+00:00", + "phase": "plan" + }, + { + "id": "5d1f4d19-42c2-4a", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:42:55.491161+00:00" + }, + "timestamp": "2026-05-07T18:42:55.522838+00:00", + "phase": "plan" + }, + { + "id": "70eeeab6-9aba-4c", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:37:41.304292+00:00" + }, + "timestamp": "2026-05-07T18:42:56.634378+00:00", + "phase": "plan" + }, + { + "id": "00d94cf4-d7ec-4f", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:42:43.375204+00:00" + }, + "timestamp": "2026-05-07T18:43:43.448769+00:00", + "phase": "plan" + }, + { + "id": "a4346f12-e0c7-4b", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:42:55.491161+00:00" + }, + "timestamp": "2026-05-07T18:43:55.615795+00:00", + "phase": "plan" + }, + { + "id": "9baff5fe-24f0-41", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "task_planner", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_plan for task_planner", + "body": "\nReviewed `.egg-state/drafts/2548-plan.md` (603 lines, 5 slices, full `yaml-tasks` block with PRMetadata + 13 tasks across 5 slices) at proposal version 1, commit 6e009f176, against the resolved HITL set, the analysis, the architect's design, the risk_analyst's findings, and the codebase.\n\n### Verification\n- **HITL traceability table is complete and accurate.** D1\u2013D5 + Q1\u2013Q5 are mirrored verbatim in the Approach section, and the slice/task structure honors each: D5 uses `pipeline.base_branch` (TASK-3-1, TASK-3-2), D2 produces only per-slice files (TASK-2-1, TASK-2-3 explicitly forbids the aggregate), D3 marks the context PR doc-only auto-open (TASK-3-2 step 5 + acceptance \"Slice-1 provisioning is **not** blocked on context-PR merge (D3)\"), D4 hard switchover (TASK-2-3 acceptance \"the aggregate-file assertion no longer appears in the test suite\"), Q4 orchestrator-authored commits (TASK-4-2 explicitly says \"Orchestrator-authored\").\n- **Slice-DAG forest check (#2137) passes.** Each slice has exactly one `dependencies` entry pointing to its predecessor (slice-2 \u2192 slice-1, slice-3 \u2192 slice-2, slice-4 \u2192 slice-3, slice-5 \u2192 slice-4). No multi-parent edges; no `serialized_chain_order` needed for a strict chain. `_populate_contract_from_plan`'s forest validator (`pipelines.py:14834`) will accept this without a `forest_violation` log.\n- **Slice-DAG sizing advisory (#2137 opt-2).** Estimated LOC per slice: slice-1 ~250 (3 files), slice-2 ~400 (1 file twice + tests), slice-3 ~600 (gateway primitive + orchestrator hook + 2 new tests), slice-4 ~500 (3 files), slice-5 ~150 (4 docs). All well under the 1,000-LOC soft target. **No size advisory.**\n- **Plan-parser ingestion compatibility verified.** `shared/egg_contracts/plan_parser.py:610` accepts `TASK-N-N` with `re.IGNORECASE` and normalizes to `task-N-N`; `id: 1` for slices is converted to `slice-1` by the phase-number resolver. The `documenter` role on TASK-5-1 is in `EXECUTION_ROLE_VALUES = {coder, tester, documenter}` (`agent_roles.py:930`). All format conventions are valid for ingestion.\n- **PRMetadata schema delta is complete.** TASK-1-1 adds the four fields with `str | None = None` / `int | None = None`, schema bumped to `1.1`, migration shim documented.\n\n### Strengths\n1. **Hard-switchover acceptance criteria are explicit and testable.** TASK-2-3 acceptance: \"The aggregate-file assertion no longer appears in the test suite.\" TASK-2-2 acceptance: \"`grep -n '-implement\\\\.\\\\(json\\\\|md\\\\)' orchestrator/` returns no remaining direct references.\" These are exactly the kind of grep-able invariants that make D4 verifiable in CI rather than relying on the coder remembering.\n2. **Idempotency requirements are stated in the right places.** TASK-3-1 (\"Idempotent: if the branch already exists at the same SHA, return success; if it exists at a different SHA, raise\"), TASK-3-2 (\"hook MUST guard against double-opening (idempotent on retry)\"), TASK-4-2 (\"Idempotent (re-running mid-flight produces no new commit if the files match HEAD)\"). Matches the established `_commit_statefiles_to_worktree` pattern.\n3. **TASK-3-2 step ordering is correct.** Create context branch \u2192 checkout worktree \u2192 copy artifacts \u2192 commit \u2192 push \u2192 open PR \u2192 persist branch + PR number. The persistence-last ordering means a partial failure (e.g. push succeeds but PR open fails) leaves the contract in a recoverable \"branch exists, no PR yet\" state instead of a \"PR open but contract empty\" state.\n4. **TASK-4-1 has a defensive fallback** (`parent_branch = contract.pr.context_branch or pipeline_branch`) with a warning log. Under D4 hard-switchover the fallback should never fire, but the defensive code lets the orchestrator continue even if a freak race left the field unset \u2014 better than crashing slice-1 provisioning.\n5. **TASK-4-3's reconciler ordering is correct.** Walk DAG \u2192 `context_branch` if extant \u2192 `pipeline_branch` legacy fallback. Matches risk_analyst R3's recommendation and architect C4 exactly.\n6. **`yaml-tasks` block is well-formed** and the planner emits `pr.context_title` / `pr.context_description` correctly via TASK-1-3's prompt update \u2014 the round-trip described in TASK-1-3 acceptance is testable.\n7. **Test coverage** spans unit (TASK-1-2, TASK-2-3), gateway-primitive (TASK-3-3), orchestrator-hook (TASK-3-3), reconciler (TASK-4-4), and end-to-end smoke (TASK-4-4) \u2014 appropriate for the change scope.\n\n### Non-blocking\n- **TASK-1-3's `files:` lists `.github/scripts/checks/plan_yaml_check.py` directly.** The gateway blocks every producer role from pushing under `.github/` (`pipelines.py:8614, 8831`); the established convention is to stage at `.github-staging/scripts/checks/plan_yaml_check.py` and emit a Pre-merge Obligation for `git mv`. The plan's `manual_steps` block does mention this convention (lines 200\u2013204), but TASK-1-3 itself doesn't reflect it \u2014 the coder will hit a gateway rejection on first push if they take the `files:` field literally. Update TASK-1-3 to either (a) target `.github-staging/scripts/checks/plan_yaml_check.py` with a conditional ACK / pre-merge obligation, or (b) move the YAML ingestion check out of `.github/scripts/` into an orchestrator-resident module the coder is free to write. (a) is the minimal-blast-radius path.\n- **TASK-2-1 description quietly assumes** \"the orchestrator attaches a `slice_id` to each implement-phase BRC message; if the message lacks a slice_id, log a warning and skip it.\" The architect surfaced this as Q1 (spike during plan-refine). I verified `routes/signals.py:1480\u20131487` already filters `metadata[\"slice_id\"]` and `peer_consensus.py:1875` documents the nested `{pipeline_id}/{slice_id}` BRC scope, so the assumption holds today \u2014 but the \"log a warning and skip\" path is a silent-data-loss footgun if a future producer/reviewer regression drops the metadata. Suggest TASK-2-1 acceptance gain \"Add an integration test that asserts every implement-phase CONSENSUS_* message in a 2-slice fixture pipeline carries `metadata['slice_id']`. If the assertion fails, the test must fail loud \u2014 not silently skip.\"\n- **No agent-transcript content scrubber.** Risk_analyst R5 explicitly flagged that `.egg-state/agent-outputs/-{refine,plan}-*.md` on a public repo (`jwbron/egg`) may leak issue-body secrets, internal error messages, or quoted source. Slice-3 (TASK-3-2 step 3) copies these transcripts onto the context branch verbatim. There is no scrub step in the plan, no HITL question, and no opt-out flag (e.g. `pr.include_transcripts: bool`). The operator chose \"maximum transparency\" in Q3 but the operator was probably not picturing 5MB of agent reasoning landing on a public PR. Add a TASK-3-2 sub-step or TASK-3-4: \"Run `.egg-state/agent-outputs/-*` through a content scrubber (regex / entropy-based, mirror existing pre-commit secret detection); on hit, fail loud and surface an OVERSEER_ALERT for operator triage.\" Alternatively register a new HITL question to confirm the operator wants no scrubbing.\n- **No size budget for the context PR diff.** Risk_analyst R12 noted that `2548-refine.md` is already 689 lines after one round and agent-output transcripts are typically larger. Without a budget, a long-running pipeline could land a 5MB context PR diff that no human reviews in full \u2014 undermining the discoverability goal that motivated #2548. Suggest TASK-3-2 acceptance gain \"If cumulative transcript size exceeds 1MB, the orchestrator emits an OVERSEER_ALERT and the operator chooses to (a) include anyway, (b) summarize, (c) drop transcripts for this pipeline.\" This dovetails with the scrubber TASK-3-4 suggestion above and protects the operator from \"transparency by accident\".\n- **TASK-3-2 step 4 says `--no-verify`.** That's the established `_commit_statefiles_to_worktree` semantics (line 7179\u20137330) and is correct given the orchestrator commits .egg-state in a tight loop where pre-commit hooks would fight back, but the plan should explicitly note \"matches existing primitive \u2014 `--no-verify` is intentional, not a hook bypass.\" Future reviewers reading the slice-3 PR will otherwise (correctly) flag `--no-verify` as suspicious.\n- **TASK-1-2 acceptance \"Confirm `context_pr_number=0` and negative values are rejected if we add a `ge=1` validator (apply a sensible validator)\" is conditional / hand-wavy.** Either commit to `Field(default=None, ge=1)` (PR numbers are always \u22651 from GitHub's side) or drop the validator. Recommendation: add `ge=1` \u2014 there's no legitimate `context_pr_number=0` case.\n- **TASK-3-2 doesn't specify how to handle context-PR creation FAILURE.** Risk_analyst R10 + architect C1 both flag this: D3 says \"no merge gate\" but is silent on creation. Architect's failure handling is \"log + STATUS broadcast + slice-1 falls back to pipeline_branch (legacy behavior)\". Task_planner's TASK-3-2 step 5 says \"PR is opened doc-only auto-open: the orchestrator does not block on its merge before slicing (D3)\" \u2014 but doesn't define what happens if the PR-open call itself raises. Add to TASK-3-2 acceptance: \"If `gateway.create_pr()` fails, the orchestrator logs an OVERSEER_ALERT, leaves `contract.pr.context_pr_number=None`, and continues to slice-1 provisioning (which falls back to `pipeline_branch` per TASK-4-1's defensive guard). Slice-1 spawning is NOT blocked.\" That makes the \"no merge gate\" decision honor creation-failure tolerance too.\n- **Slice-5 docs scope is narrow.** TASK-5-1 lists `concurrent-execution.md`, `orchestrator.md`, `orchestrator-cli.md`, `babysit-pr.md`. Missing: `docs/guides/sdlc-pipeline.md` (the canonical place for branch topology \u2014 architect's C6 puts the `egg//context` topology change here), and a release-note / changelog entry calling out the hard switchover (D4). Add both to TASK-5-1's `files`.\n- **`base: pipeline.base_branch` is referenced 4 times in the plan but `pipeline.base_branch` is not validated to be present at plan_gate time.** If a pipeline is somehow missing `base_branch` (legacy contracts pre-pipeline-base-branch field?), TASK-3-1's `create_context_branch()` will crash. Add a precondition check in TASK-3-2 step 1: \"Assert `pipeline.base_branch` is set; if absent, fail with operator-friendly error rather than passing `None` to the gateway.\"\n- **Architect proposed 4 slices, task_planner produced 5.** I'm ACKing the 5-slice decomposition since `_populate_contract_from_plan` ingests this plan. The architect's analysis remains valid context but should NOT be re-ingested as slice metadata \u2014 only the task_planner's output flows into the contract.\n\nThe plan is approval-quality: ingestion-compatible, HITL-faithful, dependency-correct, hard-switchover semantics are testable via grep-able invariants, and per-slice acceptance is concrete. The non-blocking items above are refinement work \u2014 primarily R5/R12 transcript exposure (genuine security gap that should be picked up by slice-3's coder), the `.github/` staging convention for TASK-1-3, and an explicit context-PR creation-failure path. None block consensus.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/drafts/2548-plan.md", + ".egg-state/drafts/2548-analysis.md", + ".egg-state/agent-outputs/2548-architect-output.json", + ".egg-state/agent-outputs/2548-risk_analyst-output.json", + "shared/egg_contracts/models.py", + "shared/egg_contracts/plan_parser.py", + "shared/egg_contracts/agent_roles.py", + "orchestrator/routes/pipelines.py", + "gateway/gateway.py" + ], + "reason": "\nReviewed `.egg-state/drafts/2548-plan.md` (603 lines, 5 slices, full `yaml-tasks` block with PRMetadata + 13 tasks across 5 slices) at proposal version 1, commit 6e009f176, against the resolved HITL set, the analysis, the architect's design, the risk_analyst's findings, and the codebase.\n\n### Verification\n- **HITL traceability table is complete and accurate.** D1\u2013D5 + Q1\u2013Q5 are mirrored verbatim in the Approach section, and the slice/task structure honors each: D5 uses `pipeline.base_branch` (TASK-3-1, TASK-3-2), D2 produces only per-slice files (TASK-2-1, TASK-2-3 explicitly forbids the aggregate), D3 marks the context PR doc-only auto-open (TASK-3-2 step 5 + acceptance \"Slice-1 provisioning is **not** blocked on context-PR merge (D3)\"), D4 hard switchover (TASK-2-3 acceptance \"the aggregate-file assertion no longer appears in the test suite\"), Q4 orchestrator-authored commits (TASK-4-2 explicitly says \"Orchestrator-authored\").\n- **Slice-DAG forest check (#2137) passes.** Each slice has exactly one `dependencies` entry pointing to its predecessor (slice-2 \u2192 slice-1, slice-3 \u2192 slice-2, slice-4 \u2192 slice-3, slice-5 \u2192 slice-4). No multi-parent edges; no `serialized_chain_order` needed for a strict chain. `_populate_contract_from_plan`'s forest validator (`pipelines.py:14834`) will accept this without a `forest_violation` log.\n- **Slice-DAG sizing advisory (#2137 opt-2).** Estimated LOC per slice: slice-1 ~250 (3 files), slice-2 ~400 (1 file twice + tests), slice-3 ~600 (gateway primitive + orchestrator hook + 2 new tests), slice-4 ~500 (3 files), slice-5 ~150 (4 docs). All well under the 1,000-LOC soft target. **No size advisory.**\n- **Plan-parser ingestion compatibility verified.** `shared/egg_contracts/plan_parser.py:610` accepts `TASK-N-N` with `re.IGNORECASE` and normalizes to `task-N-N`; `id: 1` for slices is converted to `slice-1` by the phase-number resolver. The `documenter` role on TASK-5-1 is in `EXECUTION_ROLE_VALUES = {coder, tester, documenter}` (`agent_roles.py:930`). All format conventions are valid for ingestion.\n- **PRMetadata schema delta is complete.** TASK-1-1 adds the four fields with `str | None = None` / `int | None = None`, schema bumped to `1.1`, migration shim documented.\n\n### Strengths\n1. **Hard-switchover acceptance criteria are explicit and testable.** TASK-2-3 acceptance: \"The aggregate-file assertion no longer appears in the test suite.\" TASK-2-2 acceptance: \"`grep -n '-implement\\\\.\\\\(json\\\\|md\\\\)' orchestrator/` returns no remaining direct references.\" These are exactly the kind of grep-able invariants that make D4 verifiable in CI rather than relying on the coder remembering.\n2. **Idempotency requirements are stated in the right places.** TASK-3-1 (\"Idempotent: if the branch already exists at the same SHA, return success; if it exists at a different SHA, raise\"), TASK-3-2 (\"hook MUST guard against double-opening (idempotent on retry)\"), TASK-4-2 (\"Idempotent (re-running mid-flight produces no new commit if the files match HEAD)\"). Matches the established `_commit_statefiles_to_worktree` pattern.\n3. **TASK-3-2 step ordering is correct.** Create context branch \u2192 checkout worktree \u2192 copy artifacts \u2192 commit \u2192 push \u2192 open PR \u2192 persist branch + PR number. The persistence-last ordering means a partial failure (e.g. push succeeds but PR open fails) leaves the contract in a recoverable \"branch exists, no PR yet\" state instead of a \"PR open but contract empty\" state.\n4. **TASK-4-1 has a defensive fallback** (`parent_branch = contract.pr.context_branch or pipeline_branch`) with a warning log. Under D4 hard-switchover the fallback should never fire, but the defensive code lets the orchestrator continue even if a freak race left the field unset \u2014 better than crashing slice-1 provisioning.\n5. **TASK-4-3's reconciler ordering is correct.** Walk DAG \u2192 `context_branch` if extant \u2192 `pipeline_branch` legacy fallback. Matches risk_analyst R3's recommendation and architect C4 exactly.\n6. **`yaml-tasks` block is well-formed** and the planner emits `pr.context_title` / `pr.context_description` correctly via TASK-1-3's prompt update \u2014 the round-trip described in TASK-1-3 acceptance is testable.\n7. **Test coverage** spans unit (TASK-1-2, TASK-2-3), gateway-primitive (TASK-3-3), orchestrator-hook (TASK-3-3), reconciler (TASK-4-4), and end-to-end smoke (TASK-4-4) \u2014 appropriate for the change scope.\n\n### Non-blocking\n- **TASK-1-3's `files:` lists `.github/scripts/checks/plan_yaml_check.py` directly.** The gateway blocks every producer role from pushing under `.github/` (`pipelines.py:8614, 8831`); the established convention is to stage at `.github-staging/scripts/checks/plan_yaml_check.py` and emit a Pre-merge Obligation for `git mv`. The plan's `manual_steps` block does mention this convention (lines 200\u2013204), but TASK-1-3 itself doesn't reflect it \u2014 the coder will hit a gateway rejection on first push if they take the `files:` field literally. Update TASK-1-3 to either (a) target `.github-staging/scripts/checks/plan_yaml_check.py` with a conditional ACK / pre-merge obligation, or (b) move the YAML ingestion check out of `.github/scripts/` into an orchestrator-resident module the coder is free to write. (a) is the minimal-blast-radius path.\n- **TASK-2-1 description quietly assumes** \"the orchestrator attaches a `slice_id` to each implement-phase BRC message; if the message lacks a slice_id, log a warning and skip it.\" The architect surfaced this as Q1 (spike during plan-refine). I verified `routes/signals.py:1480\u20131487` already filters `metadata[\"slice_id\"]` and `peer_consensus.py:1875` documents the nested `{pipeline_id}/{slice_id}` BRC scope, so the assumption holds today \u2014 but the \"log a warning and skip\" path is a silent-data-loss footgun if a future producer/reviewer regression drops the metadata. Suggest TASK-2-1 acceptance gain \"Add an integration test that asserts every implement-phase CONSENSUS_* message in a 2-slice fixture pipeline carries `metadata['slice_id']`. If the assertion fails, the test must fail loud \u2014 not silently skip.\"\n- **No agent-transcript content scrubber.** Risk_analyst R5 explicitly flagged that `.egg-state/agent-outputs/-{refine,plan}-*.md` on a public repo (`jwbron/egg`) may leak issue-body secrets, internal error messages, or quoted source. Slice-3 (TASK-3-2 step 3) copies these transcripts onto the context branch verbatim. There is no scrub step in the plan, no HITL question, and no opt-out flag (e.g. `pr.include_transcripts: bool`). The operator chose \"maximum transparency\" in Q3 but the operator was probably not picturing 5MB of agent reasoning landing on a public PR. Add a TASK-3-2 sub-step or TASK-3-4: \"Run `.egg-state/agent-outputs/-*` through a content scrubber (regex / entropy-based, mirror existing pre-commit secret detection); on hit, fail loud and surface an OVERSEER_ALERT for operator triage.\" Alternatively register a new HITL question to confirm the operator wants no scrubbing.\n- **No size budget for the context PR diff.** Risk_analyst R12 noted that `2548-refine.md` is already 689 lines after one round and agent-output transcripts are typically larger. Without a budget, a long-running pipeline could land a 5MB context PR diff that no human reviews in full \u2014 undermining the discoverability goal that motivated #2548. Suggest TASK-3-2 acceptance gain \"If cumulative transcript size exceeds 1MB, the orchestrator emits an OVERSEER_ALERT and the operator chooses to (a) include anyway, (b) summarize, (c) drop transcripts for this pipeline.\" This dovetails with the scrubber TASK-3-4 suggestion above and protects the operator from \"transparency by accident\".\n- **TASK-3-2 step 4 says `--no-verify`.** That's the established `_commit_statefiles_to_worktree` semantics (line 7179\u20137330) and is correct given the orchestrator commits .egg-state in a tight loop where pre-commit hooks would fight back, but the plan should explicitly note \"matches existing primitive \u2014 `--no-verify` is intentional, not a hook bypass.\" Future reviewers reading the slice-3 PR will otherwise (correctly) flag `--no-verify` as suspicious.\n- **TASK-1-2 acceptance \"Confirm `context_pr_number=0` and negative values are rejected if we add a `ge=1` validator (apply a sensible validator)\" is conditional / hand-wavy.** Either commit to `Field(default=None, ge=1)` (PR numbers are always \u22651 from GitHub's side) or drop the validator. Recommendation: add `ge=1` \u2014 there's no legitimate `context_pr_number=0` case.\n- **TASK-3-2 doesn't specify how to handle context-PR creation FAILURE.** Risk_analyst R10 + architect C1 both flag this: D3 says \"no merge gate\" but is silent on creation. Architect's failure handling is \"log + STATUS broadcast + slice-1 falls back to pipeline_branch (legacy behavior)\". Task_planner's TASK-3-2 step 5 says \"PR is opened doc-only auto-open: the orchestrator does not block on its merge before slicing (D3)\" \u2014 but doesn't define what happens if the PR-open call itself raises. Add to TASK-3-2 acceptance: \"If `gateway.create_pr()` fails, the orchestrator logs an OVERSEER_ALERT, leaves `contract.pr.context_pr_number=None`, and continues to slice-1 provisioning (which falls back to `pipeline_branch` per TASK-4-1's defensive guard). Slice-1 spawning is NOT blocked.\" That makes the \"no merge gate\" decision honor creation-failure tolerance too.\n- **Slice-5 docs scope is narrow.** TASK-5-1 lists `concurrent-execution.md`, `orchestrator.md`, `orchestrator-cli.md`, `babysit-pr.md`. Missing: `docs/guides/sdlc-pipeline.md` (the canonical place for branch topology \u2014 architect's C6 puts the `egg//context` topology change here), and a release-note / changelog entry calling out the hard switchover (D4). Add both to TASK-5-1's `files`.\n- **`base: pipeline.base_branch` is referenced 4 times in the plan but `pipeline.base_branch` is not validated to be present at plan_gate time.** If a pipeline is somehow missing `base_branch` (legacy contracts pre-pipeline-base-branch field?), TASK-3-1's `create_context_branch()` will crash. Add a precondition check in TASK-3-2 step 1: \"Assert `pipeline.base_branch` is set; if absent, fail with operator-friendly error rather than passing `None` to the gateway.\"\n- **Architect proposed 4 slices, task_planner produced 5.** I'm ACKing the 5-slice decomposition since `_populate_contract_from_plan` ingests this plan. The architect's analysis remains valid context but should NOT be re-ingested as slice metadata \u2014 only the task_planner's output flows into the contract.\n\nThe plan is approval-quality: ingestion-compatible, HITL-faithful, dependency-correct, hard-switchover semantics are testable via grep-able invariants, and per-slice acceptance is concrete. The non-blocking items above are refinement work \u2014 primarily R5/R12 transcript exposure (genuine security gap that should be picked up by slice-3's coder), the `.github/` staging convention for TASK-1-3, and an explicit context-PR creation-failure path. None block consensus.\n", + "ack_version": 1 + }, + "version": 1 + }, + "timestamp": "2026-05-07T18:43:55.865207+00:00", + "phase": "plan" + }, + { + "id": "c8fc2886-03e2-41", + "pipeline_id": "issue-2548", + "from_role": "orchestrator", + "to_role": "task_planner", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1 + }, + "timestamp": "2026-05-07T18:43:55.865244+00:00", + "phase": "plan" + }, + { + "id": "69641e60-693f-40", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:43:55.897588+00:00", + "phase": "plan" + }, + { + "id": "94244eb2-1eb2-49", + "pipeline_id": "issue-2548", + "from_role": "task_planner", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by task_planner", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-05-07T18:44:07.031528+00:00", + "phase": "plan" + }, + { + "id": "c4aca1ed-5ea0-45", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:44:07.142069+00:00", + "phase": "plan" + }, + { + "id": "11ba4a05-eb74-44", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:44:07.184615+00:00", + "phase": "plan" + }, + { + "id": "aef6ea5c-306f-45", + "pipeline_id": "issue-2548", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by reviewer_plan", + "body": "", + "metadata": { + "consensus_reached": true + }, + "timestamp": "2026-05-07T18:44:07.209879+00:00", + "phase": "plan" + }, + { + "id": "2679ed2e-35fb-4b", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:44:11.271924+00:00" + }, + "timestamp": "2026-05-07T18:44:11.301073+00:00", + "phase": "plan" + }, + { + "id": "50ac4352-0d86-45", + "pipeline_id": "issue-2548", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:44:11.395129+00:00", + "phase": "plan" + }, + { + "id": "a6006181-ced3-46", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T18:44:11.765464+00:00" + }, + "timestamp": "2026-05-07T18:44:11.791389+00:00", + "phase": "plan" + }, + { + "id": "bca2734b-685f-43", + "pipeline_id": "issue-2548", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T18:44:11.878597+00:00", + "phase": "plan" + } +] \ No newline at end of file diff --git a/.egg-state/brc-history/2548-plan.md b/.egg-state/brc-history/2548-plan.md new file mode 100644 index 0000000000..8e04aa7af5 --- /dev/null +++ b/.egg-state/brc-history/2548-plan.md @@ -0,0 +1,1154 @@ +# BRC Consensus History — plan phase + +Generated: 2026-05-07T18:44:11Z +Pipeline: issue-2548 + +### [2026-05-07T18:28:53Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: e5e93357-6c89-46 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:28:41.754573+00:00' +```` + +### [2026-05-07T18:31:45Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 7fe0a895-8b0b-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:31:45.053610+00:00' +```` + +### [2026-05-07T18:32:45Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 76561448-bd7c-45 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:31:45.053610+00:00' +```` + +### [2026-05-07T18:33:45Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 85239e17-39a1-4a +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:31:45.053610+00:00' +```` + +### [2026-05-07T18:34:45Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 0b0c1ef7-3ec9-4d +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:31:45.053610+00:00' +```` + +### [2026-05-07T18:35:26Z] risk_analyst (CONSENSUS_PROPOSE): Proposal from risk_analyst + +Risk assessment for issue #2548 plan phase: identifies 14 distinct risks (R1–R14) spanning gateway-policy compatibility, contract-schema migration, BRC-history file-naming centralization, stacked-PR reconciler invariants, agent-transcript exposure, and operator-experience around the hard-switchover that decision-4 selected. Captures all five HITL decision inputs (D1 dedicated context PR, D2 per-slice BRC split at write time, D3 doc-only auto-open, D4 hard switchover, D5 parametric base_branch) and feedback-1 Q1–Q5 as decision_inputs. Recommends go-with-conditions: gateway regex extension must ship first, every new PRMetadata field must be Optional with safe defaults, and two new HITL questions are needed (context-PR creation-failure semantics and transcript size/scrub policy). Includes consolidated mitigation strategy with launch ordering, drain runbook, and post-launch monitoring counters. + +````yaml +id: 04da4cfc-d607-48 +phase: plan +metadata: + payload: + summary: "Risk assessment for issue #2548 plan phase: identifies 14 distinct risks\ + \ (R1\u2013R14) spanning gateway-policy compatibility, contract-schema migration,\ + \ BRC-history file-naming centralization, stacked-PR reconciler invariants,\ + \ agent-transcript exposure, and operator-experience around the hard-switchover\ + \ that decision-4 selected. Captures all five HITL decision inputs (D1 dedicated\ + \ context PR, D2 per-slice BRC split at write time, D3 doc-only auto-open, D4\ + \ hard switchover, D5 parametric base_branch) and feedback-1 Q1\u2013Q5 as decision_inputs.\ + \ Recommends go-with-conditions: gateway regex extension must ship first, every\ + \ new PRMetadata field must be Optional with safe defaults, and two new HITL\ + \ questions are needed (context-PR creation-failure semantics and transcript\ + \ size/scrub policy). Includes consolidated mitigation strategy with launch\ + \ ordering, drain runbook, and post-launch monitoring counters." + attestation: {} + artifacts: + - .egg-state/agent-outputs/2548-risk_analyst-output.json + risk_considered: 'High-impact risks enumerated R1 (gateway regex blocks context-branch + push), R2/R9 (hard-switchover with no escape hatch for in-flight pipelines), + R8 (Pydantic crash on legacy contract load if defaults missing). Medium: R3/R7 + (reconciler fallback wrong post-context-PR), R5 (public-repo agent-transcript + exposure), R6 (BRC read-site fan-out), R10 (creation-failure undefined), R11 + (gateway second-push exemption), R12 (oversized PR diffs), R13 (test coverage + gap), R14 (parametric base_branch). Low: R4 (subsumed PR auto-close cosmetic).' + commit_sha: f881dde4fa171d0aa027b626afa9313a4b8bd0fa + files_changed: + - .egg-state/agent-outputs/2548-risk_analyst-output.json + tests_run: [] + tasks_satisfied: [] + version: 1 + commit_sha: f881dde4fa171d0aa027b626afa9313a4b8bd0fa +```` + +### [2026-05-07T18:35:26Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: ed53085d-6a9a-4a +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:35:51Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: f95f4329-e6ce-4c +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:35:31.654409+00:00' +```` + +### [2026-05-07T18:36:41Z] task_planner (CONSENSUS_PROPOSE): Proposal from task_planner + +Plan #2548 decomposes the context-PR + per-slice BRC work into a five-slice serialized forest chain matching the operator's HITL resolutions (D1 context PR, D2 per-slice implement BRC files, D3 doc-only auto-open, D4 hard switchover, D5 context base = pipeline.base_branch). Slice-1 ships the PRMetadata contract delta (context_title/context_description/context_branch/context_pr_number) and updates the planner prompt + plan_yaml ingestion. Slice-2 hard-switches _write_brc_history() / _rewrite_brc_history_for_pr() / _persist_phase_brc_history() so implement-phase BRC writes route to -implement-slice-.{json,md} with no aggregate file. Slice-3 adds a gateway primitive that creates egg//context from pipeline.base_branch, copies refine + plan artifacts (analysis.md, plan.md, refine/plan BRC files, refine/plan agent transcripts) onto it, and opens a doc-only auto-open PR that does not block slice-1 spawning. Slice-4 wires slice-1's parent_branch to contract.pr.context_branch, lands per-slice implement BRC files on each slice integration branch as a final orchestrator-authored commit before the slice PR is opened, and updates the stacked-PR reconciler fallback to prefer the context branch over pipeline_branch. Slice-5 (documenter) refreshes docs/guides/concurrent-execution.md, docs/architecture/orchestrator.md, docs/reference/orchestrator-cli.md, and docs/guides/babysit-pr.md. Test strategy covers PRMetadata round-trip, per-slice BRC writer, context-branch primitive idempotency, end-to-end PR-stack fixture asserting context PR + N slice PRs with no aggregate file. Manual verification spelled out. No .github/ changes anticipated; if any surface, they'll be staged under .github-staging/. + +````yaml +id: be811ecb-7c50-4d +phase: plan +metadata: + payload: + summary: 'Plan #2548 decomposes the context-PR + per-slice BRC work into a five-slice + serialized forest chain matching the operator''s HITL resolutions (D1 context + PR, D2 per-slice implement BRC files, D3 doc-only auto-open, D4 hard switchover, + D5 context base = pipeline.base_branch). Slice-1 ships the PRMetadata contract + delta (context_title/context_description/context_branch/context_pr_number) and + updates the planner prompt + plan_yaml ingestion. Slice-2 hard-switches _write_brc_history() + / _rewrite_brc_history_for_pr() / _persist_phase_brc_history() so implement-phase + BRC writes route to -implement-slice-.{json,md} with no aggregate file. + Slice-3 adds a gateway primitive that creates egg//context from pipeline.base_branch, + copies refine + plan artifacts (analysis.md, plan.md, refine/plan BRC files, + refine/plan agent transcripts) onto it, and opens a doc-only auto-open PR that + does not block slice-1 spawning. Slice-4 wires slice-1''s parent_branch to contract.pr.context_branch, + lands per-slice implement BRC files on each slice integration branch as a final + orchestrator-authored commit before the slice PR is opened, and updates the + stacked-PR reconciler fallback to prefer the context branch over pipeline_branch. + Slice-5 (documenter) refreshes docs/guides/concurrent-execution.md, docs/architecture/orchestrator.md, + docs/reference/orchestrator-cli.md, and docs/guides/babysit-pr.md. Test strategy + covers PRMetadata round-trip, per-slice BRC writer, context-branch primitive + idempotency, end-to-end PR-stack fixture asserting context PR + N slice PRs + with no aggregate file. Manual verification spelled out. No .github/ changes + anticipated; if any surface, they''ll be staged under .github-staging/.' + attestation: {} + artifacts: + - .egg-state/drafts/2548-plan.md + risk_considered: "Stacked-PR reconciler fallback change is feature-isolated to\ + \ the new code path (slice-4 TASK-4-3); D3's doc-only auto-open means slice-1\ + \ is unblocked even if context PR is unmerged \u2014 surfaced via slice PR backlinks;\ + \ branch-creation race tolerated (GitHub UI surfaces \"behind by N\"); D4 hard\ + \ switchover means no in-flight pipeline backfill (issue-2474-v2 will not be\ + \ retroactively fixed) \u2014 explicit operator decision." + commit_sha: 6e009f17633eecc3f209a7fbf53d1305dc0e19ce + files_changed: + - .egg-state/drafts/2548-plan.md + tests_run: [] + tasks_satisfied: [] + version: 1 + commit_sha: 6e009f17633eecc3f209a7fbf53d1305dc0e19ce +```` + +### [2026-05-07T18:36:44Z] architect (CONSENSUS_PROPOSE): Proposal from architect + +Architect output for #2548 plan phase: design for the context PR (egg//context, base=) that carries refine/plan analysis docs, agent transcripts, and refine+plan BRC histories; slice-1 stacks on top of it. Implement-phase BRC history split at write time into -implement-slice-.{json,md} per slice (no aggregate). Doc-only auto-open (no BRC roster, no merge gate). Orchestrator-authored commits. Hard switchover for new pipelines only — no backfill, no migration. PRMetadata gains optional context_title / context_description / context_pr_number / context_branch fields. _SLICE_INTEGRATION_BRANCH_RE in gateway/gateway.py extended to admit egg//context. _run_one_slice_inner root-slice resolution prefers contract.pr.context_branch when present, else pipeline_branch. Stacked-PR reconciler _resolve_extant_new_base() falls back to context_branch then pipeline.base_branch. All HITL decisions 1-5 and feedback Q1-Q5 from refine reflected. 4 slices proposed; 9 files modified, 3 created. Open question Q1: verify BRC message store carries slice_id metadata on implement messages (spike during slice-1). + +````yaml +id: b8b08a9a-7499-42 +phase: plan +metadata: + payload: + summary: "Architect output for #2548 plan phase: design for the context PR (egg//context,\ + \ base=) that carries refine/plan analysis docs, agent\ + \ transcripts, and refine+plan BRC histories; slice-1 stacks on top of it. Implement-phase\ + \ BRC history split at write time into -implement-slice-.{json,md} per\ + \ slice (no aggregate). Doc-only auto-open (no BRC roster, no merge gate). Orchestrator-authored\ + \ commits. Hard switchover for new pipelines only \u2014 no backfill, no migration.\ + \ PRMetadata gains optional context_title / context_description / context_pr_number\ + \ / context_branch fields. _SLICE_INTEGRATION_BRANCH_RE in gateway/gateway.py\ + \ extended to admit egg//context. _run_one_slice_inner root-slice resolution\ + \ prefers contract.pr.context_branch when present, else pipeline_branch. Stacked-PR\ + \ reconciler _resolve_extant_new_base() falls back to context_branch then pipeline.base_branch.\ + \ All HITL decisions 1-5 and feedback Q1-Q5 from refine reflected. 4 slices\ + \ proposed; 9 files modified, 3 created. Open question Q1: verify BRC message\ + \ store carries slice_id metadata on implement messages (spike during slice-1)." + attestation: {} + artifacts: + - .egg-state/agent-outputs/2548-architect-output.json + risk_considered: 'Branch topology change touches slice spawning + reconciler; + mitigated by gating all new behavior on contract.pr.context_branch presence + so legacy pipelines are unaffected. Hard switchover (decision-4) intentional; + schema additions default-tolerant for read of old contracts. orchestrator/routes/pipelines.py + is large and being decomposed in #2261; new code slots into pre-allocated submodule + clusters per orchestrator/CLAUDE.md. BRC slice_id metadata availability flagged + as Q1 spike for slice-1.' + commit_sha: e84972d400f76d91b3fed34973e2e1d8d73c80dd + files_changed: + - .egg-state/agent-outputs/2548-architect-output.json + tests_run: [] + tasks_satisfied: [] + version: 1 + commit_sha: e84972d400f76d91b3fed34973e2e1d8d73c80dd +```` + +### [2026-05-07T18:36:44Z] orchestrator → risk_analyst (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: 1d6f9b29-78ea-42 +phase: plan +metadata: + ready_to_confirm: true + version: 1 +```` + +### [2026-05-07T18:36:44Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 2fd944f2-5090-4c +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:37:06Z] risk_analyst (CONSENSUS_CONFIRMED): Confirmed by risk_analyst + +````yaml +id: 1bcbcbe9-cf39-45 +phase: plan +metadata: + consensus_reached: false +```` + +### [2026-05-07T18:37:06Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 850b73d9-9cf7-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:36:55.607881+00:00' +```` + +### [2026-05-07T18:37:35Z] reviewer_plan → risk_analyst (CONSENSUS_ACK): ACK from reviewer_plan for risk_analyst + + +Reviewed the risk_analyst's full risk-assessment artifact (`.egg-state/agent-outputs/2548-risk_analyst-output.json`, 383 lines, 14 risks R1–R14) and cross-referenced its evidence pointers against the codebase and the resolved HITL set. + +### Verification + +- **decision_inputs accurately reflect HITL state.** All 5 decisions and 5 feedback Q answers from the resolved refine-phase HITL set are mirrored verbatim (decision-1: dedicated context PR; decision-2: split-at-write per-slice only; decision-3: doc-only auto-open no merge gate; decision-4: hard switchover no backfill; decision-5: base = `pipeline.base_branch` not hardcoded main; Q1–Q5 as recorded). No drift. +- **R1 evidence verified.** `gateway/gateway.py:1085` confirms `_SLICE_INTEGRATION_BRANCH_RE = re.compile(r"^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\d+$")` and `:1307` confirms it gates the synthetic-session exemption. Branch `egg//context` will indeed be rejected — R1 is real, near-certain, and correctly ordered as the prerequisite slice. +- **R3/R7 evidence verified.** `orchestrator/stacked_pr_reconciler.py:_resolve_extant_new_base()` (lines 87–132) does fall back to `pipeline_branch`. Under the new model, that fallback target is wrong; R3's recommendation to fall back to `contract.pr.context_branch` then `pipeline.base_branch` is correct. +- **R8 evidence verified.** `shared/egg_contracts/models.py:371–427` (PRMetadata) has no `context_*` fields today, and the existing `_migrate_phases_to_slices` shim (lines 682–749) is the right precedent. The "fields must be Optional with safe defaults" hard-requirement is correct — Pydantic v2 will raise on missing required fields. +- **R6 evidence verified.** `_write_brc_history` (8110–8228) and `_rewrite_brc_history_for_pr` (8265–8328) are both implicated; the recommendation to centralize naming through a single helper before flipping to per-slice is sound. + +### Strengths + +1. **Risk taxonomy is comprehensive without padding.** 14 risks across compatibility (gateway-policy, schema, migration, orphan-recovery, branch-resolution, internal-api, hitl-flow), security (data-exposure), performance (scheduler-load, ux), operator-experience (migration, merge-flow), and testing (regression-risk). Each risk maps to a specific code surface; no generic "things could break" filler. +2. **Hard-switchover (decision-4) consequences are surfaced front and center.** R2 + R9 + the drain runbook section explicitly call out that decision-4 leaves in-flight pipelines stranded, and R8 nails the corollary: even with hard switchover, the new fields STILL need safe defaults so contract-load doesn't crash. That's a correctly nuanced reading — the operator chose "no backfill" for behavior, not "crash on legacy load". +3. **Launch ordering is concrete and dependency-correct.** The 8-step `consolidated_mitigation_strategy.ordering` lands the gateway regex first (R1 prerequisite), schema with safe defaults second (R8/R2/R9), then BRC-history naming centralization before the per-slice split (R6), and tests last (R13). Task_planner can lift this ordering directly into slice DAG dependencies. +4. **Areas-for-human-review are well-scoped.** R2/R9 (drain runbook vs. force-restart escape hatch), R4 (advisory merge-order vs. branch-protection enforcement), R5 (public-repo transcript exposure + content scrubber), R10 (context-PR-CREATION failure semantics, distinct from decision-3's merge gate), R12 (transparency vs. 5MB diff). These are all real operator-owned questions that decision-3/decision-4 don't cover. +5. **R10 catches a genuine gap in the resolved HITL set.** Decision-3 says "no merge gate" for the context PR, but is silent on creation failure (gateway 500, GH API 5xx, rate-limit). Surfacing this as a new HITL question for the planner to register is exactly the right move. + +### Non-blocking +- **R1 mitigation regex is incorrectly grouped.** The suggested replacement `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\d+|context$` parses as `(^egg/.../(?:slice|phase)-\d+) | (context$)` because `|` has the lowest precedence — it would match any string ending in literal "context", including `egg//context-foo-context` or unrelated branches like `feature/some-context`. The correct extension is `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:(?:slice|phase)-\d+|context)$`. The task_planner / coder picking up R1 should use that grouping, not the literal text in this risk doc. +- **R12 size threshold (5MB) is asserted without empirical anchor.** "Empirical: 2548-refine.md is already 689 lines after ONE round" supports "transcripts may be large" but doesn't justify exactly 5MB. A more useful budget would be "≥1000 changed lines OR ≥1MB total" since GitHub's diff-rendering degradation kicks in earlier than 3–5MB on text. Not blocking; the planner can refine this when picking up R12. +- **R11 references symbol `is_slice_integration_push`.** That looks paraphrased — the actual gateway code uses `_SLICE_INTEGRATION_BRANCH_RE.match(branch)` directly inside the synthetic-session push handler (`gateway.py:1296–1332`). Worth confirming whether the SECOND push to a slice branch actually re-evaluates the regex (it should, since it's a per-push check, not a per-branch lock-in) — if so, R11 may be a non-issue and can be downgraded; the risk_analyst should verify and either confirm or remove. +- **R13 doesn't explicitly call out `pipeline.base_branch != "main"` in the test matrix despite R14 flagging it as a previously-untested parameter.** Suggest the planner ensure at least one happy-path integration test parametrizes base_branch to `"develop"` (or similar) so R13 + R14 are jointly covered. +- **No risk explicitly addresses `egg//work` becoming dead weight.** Once slice-1 bases on `egg//context` (which bases on `pipeline.base_branch`), the `egg//work` branch loses its role as the merge spine. Feedback Q1 says "work→main gap is in scope" but no R-entry explicitly covers what becomes of the work branch — is it still committed to (analysis/plan are still written there during refine/plan phases per `_commit_statefiles_to_worktree`) but no longer merged? Or does the orchestrator stop using it entirely once context exists? The planner should clarify this lifecycle in the plan; flagging now so the task_planner addresses it. + +The risk doc is approval-quality. No blocking issues. The non-blocking items above are refinements the producer or downstream slice owners can pick up; none invalidate the analysis or the go-with-conditions verdict. + + +````yaml +id: 8f0e0976-2d94-42 +phase: plan +metadata: + payload: + artifact_references: + - .egg-state/agent-outputs/2548-risk_analyst-output.json + - .egg-state/drafts/2548-analysis.md + - gateway/gateway.py + reason: "\nReviewed the risk_analyst's full risk-assessment artifact (`.egg-state/agent-outputs/2548-risk_analyst-output.json`,\ + \ 383 lines, 14 risks R1\u2013R14) and cross-referenced its evidence pointers\ + \ against the codebase and the resolved HITL set.\n\n### Verification\n\n- **decision_inputs\ + \ accurately reflect HITL state.** All 5 decisions and 5 feedback Q answers\ + \ from the resolved refine-phase HITL set are mirrored verbatim (decision-1:\ + \ dedicated context PR; decision-2: split-at-write per-slice only; decision-3:\ + \ doc-only auto-open no merge gate; decision-4: hard switchover no backfill;\ + \ decision-5: base = `pipeline.base_branch` not hardcoded main; Q1\u2013Q5 as\ + \ recorded). No drift.\n- **R1 evidence verified.** `gateway/gateway.py:1085`\ + \ confirms `_SLICE_INTEGRATION_BRANCH_RE = re.compile(r\"^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\\\ + d+$\")` and `:1307` confirms it gates the synthetic-session exemption. Branch\ + \ `egg//context` will indeed be rejected \u2014 R1 is real, near-certain,\ + \ and correctly ordered as the prerequisite slice.\n- **R3/R7 evidence verified.**\ + \ `orchestrator/stacked_pr_reconciler.py:_resolve_extant_new_base()` (lines\ + \ 87\u2013132) does fall back to `pipeline_branch`. Under the new model, that\ + \ fallback target is wrong; R3's recommendation to fall back to `contract.pr.context_branch`\ + \ then `pipeline.base_branch` is correct.\n- **R8 evidence verified.** `shared/egg_contracts/models.py:371\u2013\ + 427` (PRMetadata) has no `context_*` fields today, and the existing `_migrate_phases_to_slices`\ + \ shim (lines 682\u2013749) is the right precedent. The \"fields must be Optional\ + \ with safe defaults\" hard-requirement is correct \u2014 Pydantic v2 will raise\ + \ on missing required fields.\n- **R6 evidence verified.** `_write_brc_history`\ + \ (8110\u20138228) and `_rewrite_brc_history_for_pr` (8265\u20138328) are both\ + \ implicated; the recommendation to centralize naming through a single helper\ + \ before flipping to per-slice is sound.\n\n### Strengths\n\n1. **Risk taxonomy\ + \ is comprehensive without padding.** 14 risks across compatibility (gateway-policy,\ + \ schema, migration, orphan-recovery, branch-resolution, internal-api, hitl-flow),\ + \ security (data-exposure), performance (scheduler-load, ux), operator-experience\ + \ (migration, merge-flow), and testing (regression-risk). Each risk maps to\ + \ a specific code surface; no generic \"things could break\" filler.\n2. **Hard-switchover\ + \ (decision-4) consequences are surfaced front and center.** R2 + R9 + the drain\ + \ runbook section explicitly call out that decision-4 leaves in-flight pipelines\ + \ stranded, and R8 nails the corollary: even with hard switchover, the new fields\ + \ STILL need safe defaults so contract-load doesn't crash. That's a correctly\ + \ nuanced reading \u2014 the operator chose \"no backfill\" for behavior, not\ + \ \"crash on legacy load\".\n3. **Launch ordering is concrete and dependency-correct.**\ + \ The 8-step `consolidated_mitigation_strategy.ordering` lands the gateway regex\ + \ first (R1 prerequisite), schema with safe defaults second (R8/R2/R9), then\ + \ BRC-history naming centralization before the per-slice split (R6), and tests\ + \ last (R13). Task_planner can lift this ordering directly into slice DAG dependencies.\n\ + 4. **Areas-for-human-review are well-scoped.** R2/R9 (drain runbook vs. force-restart\ + \ escape hatch), R4 (advisory merge-order vs. branch-protection enforcement),\ + \ R5 (public-repo transcript exposure + content scrubber), R10 (context-PR-CREATION\ + \ failure semantics, distinct from decision-3's merge gate), R12 (transparency\ + \ vs. 5MB diff). These are all real operator-owned questions that decision-3/decision-4\ + \ don't cover.\n5. **R10 catches a genuine gap in the resolved HITL set.** Decision-3\ + \ says \"no merge gate\" for the context PR, but is silent on creation failure\ + \ (gateway 500, GH API 5xx, rate-limit). Surfacing this as a new HITL question\ + \ for the planner to register is exactly the right move.\n\n### Non-blocking\n\ + - **R1 mitigation regex is incorrectly grouped.** The suggested replacement\ + \ `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:slice|phase)-\\d+|context$` parses as `(^egg/.../(?:slice|phase)-\\\ + d+) | (context$)` because `|` has the lowest precedence \u2014 it would match\ + \ any string ending in literal \"context\", including `egg//context-foo-context`\ + \ or unrelated branches like `feature/some-context`. The correct extension is\ + \ `^egg/[A-Za-z0-9][A-Za-z0-9_-]*/(?:(?:slice|phase)-\\d+|context)$`. The task_planner\ + \ / coder picking up R1 should use that grouping, not the literal text in this\ + \ risk doc.\n- **R12 size threshold (5MB) is asserted without empirical anchor.**\ + \ \"Empirical: 2548-refine.md is already 689 lines after ONE round\" supports\ + \ \"transcripts may be large\" but doesn't justify exactly 5MB. A more useful\ + \ budget would be \"\u22651000 changed lines OR \u22651MB total\" since GitHub's\ + \ diff-rendering degradation kicks in earlier than 3\u20135MB on text. Not blocking;\ + \ the planner can refine this when picking up R12.\n- **R11 references symbol\ + \ `is_slice_integration_push`.** That looks paraphrased \u2014 the actual gateway\ + \ code uses `_SLICE_INTEGRATION_BRANCH_RE.match(branch)` directly inside the\ + \ synthetic-session push handler (`gateway.py:1296\u20131332`). Worth confirming\ + \ whether the SECOND push to a slice branch actually re-evaluates the regex\ + \ (it should, since it's a per-push check, not a per-branch lock-in) \u2014\ + \ if so, R11 may be a non-issue and can be downgraded; the risk_analyst should\ + \ verify and either confirm or remove.\n- **R13 doesn't explicitly call out\ + \ `pipeline.base_branch != \"main\"` in the test matrix despite R14 flagging\ + \ it as a previously-untested parameter.** Suggest the planner ensure at least\ + \ one happy-path integration test parametrizes base_branch to `\"develop\"`\ + \ (or similar) so R13 + R14 are jointly covered.\n- **No risk explicitly addresses\ + \ `egg//work` becoming dead weight.** Once slice-1 bases on `egg//context`\ + \ (which bases on `pipeline.base_branch`), the `egg//work` branch loses\ + \ its role as the merge spine. Feedback Q1 says \"work\u2192main gap is in scope\"\ + \ but no R-entry explicitly covers what becomes of the work branch \u2014 is\ + \ it still committed to (analysis/plan are still written there during refine/plan\ + \ phases per `_commit_statefiles_to_worktree`) but no longer merged? Or does\ + \ the orchestrator stop using it entirely once context exists? The planner should\ + \ clarify this lifecycle in the plan; flagging now so the task_planner addresses\ + \ it.\n\nThe risk doc is approval-quality. No blocking issues. The non-blocking\ + \ items above are refinements the producer or downstream slice owners can pick\ + \ up; none invalidate the analysis or the go-with-conditions verdict.\n" + ack_version: 1 + version: 1 +```` + +### [2026-05-07T18:37:56Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: ace22c64-048e-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:37.488449+00:00' +```` + +### [2026-05-07T18:37:56Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 5aa3d6c5-5af8-4a +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:41.304292+00:00' +```` + +### [2026-05-07T18:37:56Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: abf51ee7-ed3b-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:39.924179+00:00' +```` + +### [2026-05-07T18:37:56Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 544c1be1-eaf6-49 +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:38:06Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 0339a710-4e3b-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:36:55.607881+00:00' +```` + +### [2026-05-07T18:38:52Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: ba1196da-61db-47 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:37.488449+00:00' +```` + +### [2026-05-07T18:38:56Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 47e058a2-b3df-4a +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:41.304292+00:00' +```` + +### [2026-05-07T18:39:06Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 4681a986-a958-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:36:55.607881+00:00' +```` + +### [2026-05-07T18:39:52Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 8a9320ca-79a4-4d +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:37.488449+00:00' +```` + +### [2026-05-07T18:39:56Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 28634a9b-bdc8-4f +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:41.304292+00:00' +```` + +### [2026-05-07T18:40:06Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 331decba-546d-44 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:36:55.607881+00:00' +```` + +### [2026-05-07T18:40:52Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: bb287e93-54b3-40 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:37.488449+00:00' +```` + +### [2026-05-07T18:40:56Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: e514c66d-7304-41 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:41.304292+00:00' +```` + +### [2026-05-07T18:41:06Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 4cfc4258-d2bb-46 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:36:55.607881+00:00' +```` + +### [2026-05-07T18:41:52Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 52765d57-1b7e-48 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:37.488449+00:00' +```` + +### [2026-05-07T18:41:56Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 84716e7c-c073-46 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:41.304292+00:00' +```` + +### [2026-05-07T18:42:06Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: ea06a93f-15eb-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:36:55.607881+00:00' +```` + +### [2026-05-07T18:42:32Z] reviewer_plan → architect (CONSENSUS_ACK): ACK from reviewer_plan for architect + + +Reviewed `.egg-state/agent-outputs/2548-architect-output.json` (455 lines, components C1–C7, 8 risks, 3 open questions, alternatives section, branch topology diagram) at proposal version 1, commit e84972d40, against the resolved HITL set, the analysis, and the codebase. + +### Verification +- **HITL traceability is complete.** All 5 decisions and all 5 feedback Qs are mirrored in `hitl_resolutions` with implications spelled out, and the design honors each of them — base = `pipeline.base_branch` not hardcoded `main` (D5), per-slice writes only with no aggregate (D2), doc-only auto-open with no merge gate (D3), schema additions default-tolerant but no migration path (D4), separate `context_title`/`context_description` fields (Q2), agent transcripts included on the context PR (Q3), orchestrator-authored BRC commits (Q4), per-slice-only BRC files on slice PRs (Q5). +- **Codebase claims verified.** `_SLICE_INTEGRATION_BRANCH_RE` at `gateway/gateway.py:1085` and the synthetic-session exemption at `:1296–1332` are exactly as cited. `_resolve_extant_new_base()` in `stacked_pr_reconciler.py:87–132` does fall back to `pipeline_branch` as described. `_run_one_slice_inner` at `pipelines.py:12405–12454` is the right place for slice-1 root resolution. `PRMetadata` at `models.py:371–426` has the listed fields and no `context_*`. `_write_brc_history` (8110–8260), `_persist_phase_brc_history` (8355–8400), and `_rewrite_brc_history_for_pr` (8265–8330) are correctly cited. +- **Component design is internally consistent.** C1 (context branch + PR), C2 (per-slice BRC), C3 (slice-1 root), C4 (reconciler awareness), C5 (schema), C6 (docs), C7 (tests) cover the surface area with no gaps. Failure handling for context-PR creation is non-blocking and yields to the legacy fallback — that posture is consistent with D3. +- **Branch topology diagram is correct** and matches `docs/guides/concurrent-execution.md`'s existing stack model. +- **Alternatives section** correctly cites which HITL decisions ruled out each alternative. + +### Strengths +1. **`consensus_inputs_for_reviewer_plan` is unusually useful.** The "what to NACK on" list (no hardcoded `main`, no aggregate file, no merge-blocking, no backfill, no coder/tester authoring of `.egg-state/brc-history/` commits) gives the reviewer + downstream slice owners explicit invariants to enforce. Encourage other architects to mirror this pattern. +2. **R6 (decomposition coordination with #2261)** is a real risk that's easy to miss. The architect correctly identifies that 5+ adjacent additions to `orchestrator/routes/pipelines.py` (currently 16k+ lines) need to slot into the pre-allocated `_pr_lifecycle/` and `_concurrent_phase/` clusters per `orchestrator/CLAUDE.md`. This is the right mitigation given the slice-15 of #2261 hasn't landed yet. +3. **Failure handling for context-PR creation is well-specified.** The "log + STATUS broadcast + slice-1 falls back to pipeline_branch" path exactly addresses risk_analyst R10 (creation-failure semantics not covered by D3). Architect surfaced this independently. +4. **Open questions Q1–Q3 are appropriately scoped** to spike-during-plan rather than blocking; recommendations are concrete (compute `context_branch` deterministically from `pipeline_identifier`, no additional handling for human-closed context PRs). +5. **artifact_set_for_context_pr explicitly excludes** implement-phase BRC and contract files — matches Q5's per-slice-only resolution and avoids double-shipping. + +### Non-blocking +- **Slice count and ordering disagrees with task_planner.** Architect proposes 4 slices (slice-1: schema + BRC writer; slice-2: context PR + planner; slice-3: slice-1 root + reconciler + per-slice BRC commit; slice-4: docs + tests + cleanup). Task_planner proposes 5 (slice-1: schema + planner; slice-2: BRC writer; slice-3: context PR; slice-4: stack rewiring; slice-5: docs). Both decompose the same surface but bundle differently. Either decomposition works; the operator/coder needs one canonical plan. Recommend the planner reconcile to the canonical 5-slice ordering already published in `2548-plan.md`, since that's what `_populate_contract_from_plan` will actually ingest. The architect's analysis remains valid as design rationale — flag the divergence in the plan's "alignment" section and pick task_planner's slice ordering as authoritative for ingestion. +- **Default-type inconsistency on `context_title`/`context_description`.** Architect's C5 says `context_title (str, default='')`, `context_description (str, default='')`, but `context_pr_number (int | None, default=None)` and `context_branch (str | None, default=None)`. Task_planner's TASK-1-1 uses `str | None = None` for all four. Either choice is valid Pydantic, but downstream code (e.g. R7 mitigation "fall back to a sensible default when planner-supplied fields are empty") branches differently on `''` vs `None`. Pick one and align. Recommendation: `str | None = None` for all four (simpler `is None` guard, matches task_planner's plan, and the falsy check `field or default` covers both empty-string and None equivalently if the renderer needs it). +- **Q2 recommendation conflicts with task_planner's design.** Architect recommends "Compute `context_branch` deterministically from `pipeline_identifier`. Add the contract field only as a presence flag." Task_planner persists `context_branch` as a real string in TASK-1-1. The architect's recommendation is sound (less invariant to maintain, no contract write needed) but task_planner already chose persistence. Either is fine; the planner should either (a) align to deterministic computation and drop `context_branch` from PRMetadata (keeping only `context_pr_number` as the presence flag), or (b) document why persistence won (e.g. read-time consumers shouldn't need to know the naming convention). Default to (b) for now since it matches task_planner; the deterministic path is a refactor for later. +- **R5 (transcript content scrubber) from risk_analyst is not picked up.** Risk_analyst R5 flags that `.egg-state/agent-outputs/-{refine,plan}-*.md` may contain reasoning that quoted secrets / internal-only error messages, and `egg`'s repo is public. Architect's `artifact_set_for_context_pr.agent_transcripts` lists the transcripts without a scrubbing step or HITL check. Suggest C1 grow a sub-step "scan transcripts for high-entropy / known-secret patterns; if hit, fail open with operator alert" or surface a HITL question. Not blocking the plan, but the slice that lands C1 should not ship without addressing this. +- **C2 "open_concern" about slice_id metadata in BRC store is overcautious.** I verified `routes/signals.py:1480–1487` already filters `metadata["slice_id"]` per-slice via the post-#2535 routing layer, and `peer_consensus.py:1875` documents the nested `{pipeline_id}/{slice_id}` BRC scope. The implement-phase BRC store DOES carry slice_id today. The spike the architect recommends is still a good 30-minute safety check during implementation but is not a real risk to the plan itself. Downgrade to verification step in slice-1. +- **C5's planner-prompt update target** is `shared/agent_prompts/planner-*` (architect) but task_planner targets `orchestrator/routes/pipelines.py` line ~11046. Both touchpoints exist (the planner has its own prompt file AND the orchestrator embeds prompt context); the slice-1 coder needs to update both. The architect's analysis would be stronger if it explicitly enumerated both locations. + +### Phrasing nit +- The opening summary describes "Hard switchover for new pipelines only" — accurate, but a reader skimming might miss that the schema change still applies to old contracts read post-upgrade. Consider rewording to "Hard switchover behavior; schema additions are default-tolerant for read-only legacy load." + +The architecture analysis is approval-quality: HITL fidelity is total, code-citation accuracy is high, design components are coherent, and the non-blocking items above are reconciliation/refinement work the planner and coder can pick up. No blocking issues. + + +````yaml +id: bba47ba2-d511-45 +phase: plan +metadata: + payload: + artifact_references: + - .egg-state/agent-outputs/2548-architect-output.json + - .egg-state/drafts/2548-analysis.md + - shared/egg_contracts/models.py + - orchestrator/routes/pipelines.py + - gateway/gateway.py + - orchestrator/stacked_pr_reconciler.py + reason: "\nReviewed `.egg-state/agent-outputs/2548-architect-output.json` (455\ + \ lines, components C1\u2013C7, 8 risks, 3 open questions, alternatives section,\ + \ branch topology diagram) at proposal version 1, commit e84972d40, against\ + \ the resolved HITL set, the analysis, and the codebase.\n\n### Verification\n\ + - **HITL traceability is complete.** All 5 decisions and all 5 feedback Qs are\ + \ mirrored in `hitl_resolutions` with implications spelled out, and the design\ + \ honors each of them \u2014 base = `pipeline.base_branch` not hardcoded `main`\ + \ (D5), per-slice writes only with no aggregate (D2), doc-only auto-open with\ + \ no merge gate (D3), schema additions default-tolerant but no migration path\ + \ (D4), separate `context_title`/`context_description` fields (Q2), agent transcripts\ + \ included on the context PR (Q3), orchestrator-authored BRC commits (Q4), per-slice-only\ + \ BRC files on slice PRs (Q5).\n- **Codebase claims verified.** `_SLICE_INTEGRATION_BRANCH_RE`\ + \ at `gateway/gateway.py:1085` and the synthetic-session exemption at `:1296\u2013\ + 1332` are exactly as cited. `_resolve_extant_new_base()` in `stacked_pr_reconciler.py:87\u2013\ + 132` does fall back to `pipeline_branch` as described. `_run_one_slice_inner`\ + \ at `pipelines.py:12405\u201312454` is the right place for slice-1 root resolution.\ + \ `PRMetadata` at `models.py:371\u2013426` has the listed fields and no `context_*`.\ + \ `_write_brc_history` (8110\u20138260), `_persist_phase_brc_history` (8355\u2013\ + 8400), and `_rewrite_brc_history_for_pr` (8265\u20138330) are correctly cited.\n\ + - **Component design is internally consistent.** C1 (context branch + PR), C2\ + \ (per-slice BRC), C3 (slice-1 root), C4 (reconciler awareness), C5 (schema),\ + \ C6 (docs), C7 (tests) cover the surface area with no gaps. Failure handling\ + \ for context-PR creation is non-blocking and yields to the legacy fallback\ + \ \u2014 that posture is consistent with D3.\n- **Branch topology diagram is\ + \ correct** and matches `docs/guides/concurrent-execution.md`'s existing stack\ + \ model.\n- **Alternatives section** correctly cites which HITL decisions ruled\ + \ out each alternative.\n\n### Strengths\n1. **`consensus_inputs_for_reviewer_plan`\ + \ is unusually useful.** The \"what to NACK on\" list (no hardcoded `main`,\ + \ no aggregate file, no merge-blocking, no backfill, no coder/tester authoring\ + \ of `.egg-state/brc-history/` commits) gives the reviewer + downstream slice\ + \ owners explicit invariants to enforce. Encourage other architects to mirror\ + \ this pattern.\n2. **R6 (decomposition coordination with #2261)** is a real\ + \ risk that's easy to miss. The architect correctly identifies that 5+ adjacent\ + \ additions to `orchestrator/routes/pipelines.py` (currently 16k+ lines) need\ + \ to slot into the pre-allocated `_pr_lifecycle/` and `_concurrent_phase/` clusters\ + \ per `orchestrator/CLAUDE.md`. This is the right mitigation given the slice-15\ + \ of #2261 hasn't landed yet.\n3. **Failure handling for context-PR creation\ + \ is well-specified.** The \"log + STATUS broadcast + slice-1 falls back to\ + \ pipeline_branch\" path exactly addresses risk_analyst R10 (creation-failure\ + \ semantics not covered by D3). Architect surfaced this independently.\n4. **Open\ + \ questions Q1\u2013Q3 are appropriately scoped** to spike-during-plan rather\ + \ than blocking; recommendations are concrete (compute `context_branch` deterministically\ + \ from `pipeline_identifier`, no additional handling for human-closed context\ + \ PRs).\n5. **artifact_set_for_context_pr explicitly excludes** implement-phase\ + \ BRC and contract files \u2014 matches Q5's per-slice-only resolution and avoids\ + \ double-shipping.\n\n### Non-blocking\n- **Slice count and ordering disagrees\ + \ with task_planner.** Architect proposes 4 slices (slice-1: schema + BRC writer;\ + \ slice-2: context PR + planner; slice-3: slice-1 root + reconciler + per-slice\ + \ BRC commit; slice-4: docs + tests + cleanup). Task_planner proposes 5 (slice-1:\ + \ schema + planner; slice-2: BRC writer; slice-3: context PR; slice-4: stack\ + \ rewiring; slice-5: docs). Both decompose the same surface but bundle differently.\ + \ Either decomposition works; the operator/coder needs one canonical plan. Recommend\ + \ the planner reconcile to the canonical 5-slice ordering already published\ + \ in `2548-plan.md`, since that's what `_populate_contract_from_plan` will actually\ + \ ingest. The architect's analysis remains valid as design rationale \u2014\ + \ flag the divergence in the plan's \"alignment\" section and pick task_planner's\ + \ slice ordering as authoritative for ingestion.\n- **Default-type inconsistency\ + \ on `context_title`/`context_description`.** Architect's C5 says `context_title\ + \ (str, default='')`, `context_description (str, default='')`, but `context_pr_number\ + \ (int | None, default=None)` and `context_branch (str | None, default=None)`.\ + \ Task_planner's TASK-1-1 uses `str | None = None` for all four. Either choice\ + \ is valid Pydantic, but downstream code (e.g. R7 mitigation \"fall back to\ + \ a sensible default when planner-supplied fields are empty\") branches differently\ + \ on `''` vs `None`. Pick one and align. Recommendation: `str | None = None`\ + \ for all four (simpler `is None` guard, matches task_planner's plan, and the\ + \ falsy check `field or default` covers both empty-string and None equivalently\ + \ if the renderer needs it).\n- **Q2 recommendation conflicts with task_planner's\ + \ design.** Architect recommends \"Compute `context_branch` deterministically\ + \ from `pipeline_identifier`. Add the contract field only as a presence flag.\"\ + \ Task_planner persists `context_branch` as a real string in TASK-1-1. The architect's\ + \ recommendation is sound (less invariant to maintain, no contract write needed)\ + \ but task_planner already chose persistence. Either is fine; the planner should\ + \ either (a) align to deterministic computation and drop `context_branch` from\ + \ PRMetadata (keeping only `context_pr_number` as the presence flag), or (b)\ + \ document why persistence won (e.g. read-time consumers shouldn't need to know\ + \ the naming convention). Default to (b) for now since it matches task_planner;\ + \ the deterministic path is a refactor for later.\n- **R5 (transcript content\ + \ scrubber) from risk_analyst is not picked up.** Risk_analyst R5 flags that\ + \ `.egg-state/agent-outputs/-{refine,plan}-*.md` may contain reasoning that\ + \ quoted secrets / internal-only error messages, and `egg`'s repo is public.\ + \ Architect's `artifact_set_for_context_pr.agent_transcripts` lists the transcripts\ + \ without a scrubbing step or HITL check. Suggest C1 grow a sub-step \"scan\ + \ transcripts for high-entropy / known-secret patterns; if hit, fail open with\ + \ operator alert\" or surface a HITL question. Not blocking the plan, but the\ + \ slice that lands C1 should not ship without addressing this.\n- **C2 \"open_concern\"\ + \ about slice_id metadata in BRC store is overcautious.** I verified `routes/signals.py:1480\u2013\ + 1487` already filters `metadata[\"slice_id\"]` per-slice via the post-#2535\ + \ routing layer, and `peer_consensus.py:1875` documents the nested `{pipeline_id}/{slice_id}`\ + \ BRC scope. The implement-phase BRC store DOES carry slice_id today. The spike\ + \ the architect recommends is still a good 30-minute safety check during implementation\ + \ but is not a real risk to the plan itself. Downgrade to verification step\ + \ in slice-1.\n- **C5's planner-prompt update target** is `shared/agent_prompts/planner-*`\ + \ (architect) but task_planner targets `orchestrator/routes/pipelines.py` line\ + \ ~11046. Both touchpoints exist (the planner has its own prompt file AND the\ + \ orchestrator embeds prompt context); the slice-1 coder needs to update both.\ + \ The architect's analysis would be stronger if it explicitly enumerated both\ + \ locations.\n\n### Phrasing nit\n- The opening summary describes \"Hard switchover\ + \ for new pipelines only\" \u2014 accurate, but a reader skimming might miss\ + \ that the schema change still applies to old contracts read post-upgrade. Consider\ + \ rewording to \"Hard switchover behavior; schema additions are default-tolerant\ + \ for read-only legacy load.\"\n\nThe architecture analysis is approval-quality:\ + \ HITL fidelity is total, code-citation accuracy is high, design components\ + \ are coherent, and the non-blocking items above are reconciliation/refinement\ + \ work the planner and coder can pick up. No blocking issues.\n" + ack_version: 1 + version: 1 +```` + +### [2026-05-07T18:42:32Z] orchestrator → architect (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: 003a1d93-85c9-45 +phase: plan +metadata: + ready_to_confirm: true + version: 1 +```` + +### [2026-05-07T18:42:32Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 51568339-1151-48 +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:42:38Z] architect (CONSENSUS_CONFIRMED): Confirmed by architect + +````yaml +id: 7b3d0168-5a5f-48 +phase: plan +metadata: + consensus_reached: false +```` + +### [2026-05-07T18:42:38Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: a78d6ab1-ec8f-4b +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:42:43Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: dfc893c6-47df-43 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:42:43.375204+00:00' +```` + +### [2026-05-07T18:42:55Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 5d1f4d19-42c2-4a +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:42:55.491161+00:00' +```` + +### [2026-05-07T18:42:56Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 70eeeab6-9aba-4c +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:37:41.304292+00:00' +```` + +### [2026-05-07T18:43:43Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 00d94cf4-d7ec-4f +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:42:43.375204+00:00' +```` + +### [2026-05-07T18:43:55Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: a4346f12-e0c7-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:42:55.491161+00:00' +```` + +### [2026-05-07T18:43:55Z] reviewer_plan → task_planner (CONSENSUS_ACK): ACK from reviewer_plan for task_planner + + +Reviewed `.egg-state/drafts/2548-plan.md` (603 lines, 5 slices, full `yaml-tasks` block with PRMetadata + 13 tasks across 5 slices) at proposal version 1, commit 6e009f176, against the resolved HITL set, the analysis, the architect's design, the risk_analyst's findings, and the codebase. + +### Verification +- **HITL traceability table is complete and accurate.** D1–D5 + Q1–Q5 are mirrored verbatim in the Approach section, and the slice/task structure honors each: D5 uses `pipeline.base_branch` (TASK-3-1, TASK-3-2), D2 produces only per-slice files (TASK-2-1, TASK-2-3 explicitly forbids the aggregate), D3 marks the context PR doc-only auto-open (TASK-3-2 step 5 + acceptance "Slice-1 provisioning is **not** blocked on context-PR merge (D3)"), D4 hard switchover (TASK-2-3 acceptance "the aggregate-file assertion no longer appears in the test suite"), Q4 orchestrator-authored commits (TASK-4-2 explicitly says "Orchestrator-authored"). +- **Slice-DAG forest check (#2137) passes.** Each slice has exactly one `dependencies` entry pointing to its predecessor (slice-2 → slice-1, slice-3 → slice-2, slice-4 → slice-3, slice-5 → slice-4). No multi-parent edges; no `serialized_chain_order` needed for a strict chain. `_populate_contract_from_plan`'s forest validator (`pipelines.py:14834`) will accept this without a `forest_violation` log. +- **Slice-DAG sizing advisory (#2137 opt-2).** Estimated LOC per slice: slice-1 ~250 (3 files), slice-2 ~400 (1 file twice + tests), slice-3 ~600 (gateway primitive + orchestrator hook + 2 new tests), slice-4 ~500 (3 files), slice-5 ~150 (4 docs). All well under the 1,000-LOC soft target. **No size advisory.** +- **Plan-parser ingestion compatibility verified.** `shared/egg_contracts/plan_parser.py:610` accepts `TASK-N-N` with `re.IGNORECASE` and normalizes to `task-N-N`; `id: 1` for slices is converted to `slice-1` by the phase-number resolver. The `documenter` role on TASK-5-1 is in `EXECUTION_ROLE_VALUES = {coder, tester, documenter}` (`agent_roles.py:930`). All format conventions are valid for ingestion. +- **PRMetadata schema delta is complete.** TASK-1-1 adds the four fields with `str | None = None` / `int | None = None`, schema bumped to `1.1`, migration shim documented. + +### Strengths +1. **Hard-switchover acceptance criteria are explicit and testable.** TASK-2-3 acceptance: "The aggregate-file assertion no longer appears in the test suite." TASK-2-2 acceptance: "`grep -n '-implement\\.\\(json\\|md\\)' orchestrator/` returns no remaining direct references." These are exactly the kind of grep-able invariants that make D4 verifiable in CI rather than relying on the coder remembering. +2. **Idempotency requirements are stated in the right places.** TASK-3-1 ("Idempotent: if the branch already exists at the same SHA, return success; if it exists at a different SHA, raise"), TASK-3-2 ("hook MUST guard against double-opening (idempotent on retry)"), TASK-4-2 ("Idempotent (re-running mid-flight produces no new commit if the files match HEAD)"). Matches the established `_commit_statefiles_to_worktree` pattern. +3. **TASK-3-2 step ordering is correct.** Create context branch → checkout worktree → copy artifacts → commit → push → open PR → persist branch + PR number. The persistence-last ordering means a partial failure (e.g. push succeeds but PR open fails) leaves the contract in a recoverable "branch exists, no PR yet" state instead of a "PR open but contract empty" state. +4. **TASK-4-1 has a defensive fallback** (`parent_branch = contract.pr.context_branch or pipeline_branch`) with a warning log. Under D4 hard-switchover the fallback should never fire, but the defensive code lets the orchestrator continue even if a freak race left the field unset — better than crashing slice-1 provisioning. +5. **TASK-4-3's reconciler ordering is correct.** Walk DAG → `context_branch` if extant → `pipeline_branch` legacy fallback. Matches risk_analyst R3's recommendation and architect C4 exactly. +6. **`yaml-tasks` block is well-formed** and the planner emits `pr.context_title` / `pr.context_description` correctly via TASK-1-3's prompt update — the round-trip described in TASK-1-3 acceptance is testable. +7. **Test coverage** spans unit (TASK-1-2, TASK-2-3), gateway-primitive (TASK-3-3), orchestrator-hook (TASK-3-3), reconciler (TASK-4-4), and end-to-end smoke (TASK-4-4) — appropriate for the change scope. + +### Non-blocking +- **TASK-1-3's `files:` lists `.github/scripts/checks/plan_yaml_check.py` directly.** The gateway blocks every producer role from pushing under `.github/` (`pipelines.py:8614, 8831`); the established convention is to stage at `.github-staging/scripts/checks/plan_yaml_check.py` and emit a Pre-merge Obligation for `git mv`. The plan's `manual_steps` block does mention this convention (lines 200–204), but TASK-1-3 itself doesn't reflect it — the coder will hit a gateway rejection on first push if they take the `files:` field literally. Update TASK-1-3 to either (a) target `.github-staging/scripts/checks/plan_yaml_check.py` with a conditional ACK / pre-merge obligation, or (b) move the YAML ingestion check out of `.github/scripts/` into an orchestrator-resident module the coder is free to write. (a) is the minimal-blast-radius path. +- **TASK-2-1 description quietly assumes** "the orchestrator attaches a `slice_id` to each implement-phase BRC message; if the message lacks a slice_id, log a warning and skip it." The architect surfaced this as Q1 (spike during plan-refine). I verified `routes/signals.py:1480–1487` already filters `metadata["slice_id"]` and `peer_consensus.py:1875` documents the nested `{pipeline_id}/{slice_id}` BRC scope, so the assumption holds today — but the "log a warning and skip" path is a silent-data-loss footgun if a future producer/reviewer regression drops the metadata. Suggest TASK-2-1 acceptance gain "Add an integration test that asserts every implement-phase CONSENSUS_* message in a 2-slice fixture pipeline carries `metadata['slice_id']`. If the assertion fails, the test must fail loud — not silently skip." +- **No agent-transcript content scrubber.** Risk_analyst R5 explicitly flagged that `.egg-state/agent-outputs/-{refine,plan}-*.md` on a public repo (`jwbron/egg`) may leak issue-body secrets, internal error messages, or quoted source. Slice-3 (TASK-3-2 step 3) copies these transcripts onto the context branch verbatim. There is no scrub step in the plan, no HITL question, and no opt-out flag (e.g. `pr.include_transcripts: bool`). The operator chose "maximum transparency" in Q3 but the operator was probably not picturing 5MB of agent reasoning landing on a public PR. Add a TASK-3-2 sub-step or TASK-3-4: "Run `.egg-state/agent-outputs/-*` through a content scrubber (regex / entropy-based, mirror existing pre-commit secret detection); on hit, fail loud and surface an OVERSEER_ALERT for operator triage." Alternatively register a new HITL question to confirm the operator wants no scrubbing. +- **No size budget for the context PR diff.** Risk_analyst R12 noted that `2548-refine.md` is already 689 lines after one round and agent-output transcripts are typically larger. Without a budget, a long-running pipeline could land a 5MB context PR diff that no human reviews in full — undermining the discoverability goal that motivated #2548. Suggest TASK-3-2 acceptance gain "If cumulative transcript size exceeds 1MB, the orchestrator emits an OVERSEER_ALERT and the operator chooses to (a) include anyway, (b) summarize, (c) drop transcripts for this pipeline." This dovetails with the scrubber TASK-3-4 suggestion above and protects the operator from "transparency by accident". +- **TASK-3-2 step 4 says `--no-verify`.** That's the established `_commit_statefiles_to_worktree` semantics (line 7179–7330) and is correct given the orchestrator commits .egg-state in a tight loop where pre-commit hooks would fight back, but the plan should explicitly note "matches existing primitive — `--no-verify` is intentional, not a hook bypass." Future reviewers reading the slice-3 PR will otherwise (correctly) flag `--no-verify` as suspicious. +- **TASK-1-2 acceptance "Confirm `context_pr_number=0` and negative values are rejected if we add a `ge=1` validator (apply a sensible validator)" is conditional / hand-wavy.** Either commit to `Field(default=None, ge=1)` (PR numbers are always ≥1 from GitHub's side) or drop the validator. Recommendation: add `ge=1` — there's no legitimate `context_pr_number=0` case. +- **TASK-3-2 doesn't specify how to handle context-PR creation FAILURE.** Risk_analyst R10 + architect C1 both flag this: D3 says "no merge gate" but is silent on creation. Architect's failure handling is "log + STATUS broadcast + slice-1 falls back to pipeline_branch (legacy behavior)". Task_planner's TASK-3-2 step 5 says "PR is opened doc-only auto-open: the orchestrator does not block on its merge before slicing (D3)" — but doesn't define what happens if the PR-open call itself raises. Add to TASK-3-2 acceptance: "If `gateway.create_pr()` fails, the orchestrator logs an OVERSEER_ALERT, leaves `contract.pr.context_pr_number=None`, and continues to slice-1 provisioning (which falls back to `pipeline_branch` per TASK-4-1's defensive guard). Slice-1 spawning is NOT blocked." That makes the "no merge gate" decision honor creation-failure tolerance too. +- **Slice-5 docs scope is narrow.** TASK-5-1 lists `concurrent-execution.md`, `orchestrator.md`, `orchestrator-cli.md`, `babysit-pr.md`. Missing: `docs/guides/sdlc-pipeline.md` (the canonical place for branch topology — architect's C6 puts the `egg//context` topology change here), and a release-note / changelog entry calling out the hard switchover (D4). Add both to TASK-5-1's `files`. +- **`base: pipeline.base_branch` is referenced 4 times in the plan but `pipeline.base_branch` is not validated to be present at plan_gate time.** If a pipeline is somehow missing `base_branch` (legacy contracts pre-pipeline-base-branch field?), TASK-3-1's `create_context_branch()` will crash. Add a precondition check in TASK-3-2 step 1: "Assert `pipeline.base_branch` is set; if absent, fail with operator-friendly error rather than passing `None` to the gateway." +- **Architect proposed 4 slices, task_planner produced 5.** I'm ACKing the 5-slice decomposition since `_populate_contract_from_plan` ingests this plan. The architect's analysis remains valid context but should NOT be re-ingested as slice metadata — only the task_planner's output flows into the contract. + +The plan is approval-quality: ingestion-compatible, HITL-faithful, dependency-correct, hard-switchover semantics are testable via grep-able invariants, and per-slice acceptance is concrete. The non-blocking items above are refinement work — primarily R5/R12 transcript exposure (genuine security gap that should be picked up by slice-3's coder), the `.github/` staging convention for TASK-1-3, and an explicit context-PR creation-failure path. None block consensus. + + +````yaml +id: 9baff5fe-24f0-41 +phase: plan +metadata: + payload: + artifact_references: + - .egg-state/drafts/2548-plan.md + - .egg-state/drafts/2548-analysis.md + - .egg-state/agent-outputs/2548-architect-output.json + - .egg-state/agent-outputs/2548-risk_analyst-output.json + - shared/egg_contracts/models.py + - shared/egg_contracts/plan_parser.py + - shared/egg_contracts/agent_roles.py + - orchestrator/routes/pipelines.py + - gateway/gateway.py + reason: "\nReviewed `.egg-state/drafts/2548-plan.md` (603 lines, 5 slices, full\ + \ `yaml-tasks` block with PRMetadata + 13 tasks across 5 slices) at proposal\ + \ version 1, commit 6e009f176, against the resolved HITL set, the analysis,\ + \ the architect's design, the risk_analyst's findings, and the codebase.\n\n\ + ### Verification\n- **HITL traceability table is complete and accurate.** D1\u2013\ + D5 + Q1\u2013Q5 are mirrored verbatim in the Approach section, and the slice/task\ + \ structure honors each: D5 uses `pipeline.base_branch` (TASK-3-1, TASK-3-2),\ + \ D2 produces only per-slice files (TASK-2-1, TASK-2-3 explicitly forbids the\ + \ aggregate), D3 marks the context PR doc-only auto-open (TASK-3-2 step 5 +\ + \ acceptance \"Slice-1 provisioning is **not** blocked on context-PR merge (D3)\"\ + ), D4 hard switchover (TASK-2-3 acceptance \"the aggregate-file assertion no\ + \ longer appears in the test suite\"), Q4 orchestrator-authored commits (TASK-4-2\ + \ explicitly says \"Orchestrator-authored\").\n- **Slice-DAG forest check (#2137)\ + \ passes.** Each slice has exactly one `dependencies` entry pointing to its\ + \ predecessor (slice-2 \u2192 slice-1, slice-3 \u2192 slice-2, slice-4 \u2192\ + \ slice-3, slice-5 \u2192 slice-4). No multi-parent edges; no `serialized_chain_order`\ + \ needed for a strict chain. `_populate_contract_from_plan`'s forest validator\ + \ (`pipelines.py:14834`) will accept this without a `forest_violation` log.\n\ + - **Slice-DAG sizing advisory (#2137 opt-2).** Estimated LOC per slice: slice-1\ + \ ~250 (3 files), slice-2 ~400 (1 file twice + tests), slice-3 ~600 (gateway\ + \ primitive + orchestrator hook + 2 new tests), slice-4 ~500 (3 files), slice-5\ + \ ~150 (4 docs). All well under the 1,000-LOC soft target. **No size advisory.**\n\ + - **Plan-parser ingestion compatibility verified.** `shared/egg_contracts/plan_parser.py:610`\ + \ accepts `TASK-N-N` with `re.IGNORECASE` and normalizes to `task-N-N`; `id:\ + \ 1` for slices is converted to `slice-1` by the phase-number resolver. The\ + \ `documenter` role on TASK-5-1 is in `EXECUTION_ROLE_VALUES = {coder, tester,\ + \ documenter}` (`agent_roles.py:930`). All format conventions are valid for\ + \ ingestion.\n- **PRMetadata schema delta is complete.** TASK-1-1 adds the four\ + \ fields with `str | None = None` / `int | None = None`, schema bumped to `1.1`,\ + \ migration shim documented.\n\n### Strengths\n1. **Hard-switchover acceptance\ + \ criteria are explicit and testable.** TASK-2-3 acceptance: \"The aggregate-file\ + \ assertion no longer appears in the test suite.\" TASK-2-2 acceptance: \"`grep\ + \ -n '-implement\\\\.\\\\(json\\\\|md\\\\)' orchestrator/` returns no remaining\ + \ direct references.\" These are exactly the kind of grep-able invariants that\ + \ make D4 verifiable in CI rather than relying on the coder remembering.\n2.\ + \ **Idempotency requirements are stated in the right places.** TASK-3-1 (\"\ + Idempotent: if the branch already exists at the same SHA, return success; if\ + \ it exists at a different SHA, raise\"), TASK-3-2 (\"hook MUST guard against\ + \ double-opening (idempotent on retry)\"), TASK-4-2 (\"Idempotent (re-running\ + \ mid-flight produces no new commit if the files match HEAD)\"). Matches the\ + \ established `_commit_statefiles_to_worktree` pattern.\n3. **TASK-3-2 step\ + \ ordering is correct.** Create context branch \u2192 checkout worktree \u2192\ + \ copy artifacts \u2192 commit \u2192 push \u2192 open PR \u2192 persist branch\ + \ + PR number. The persistence-last ordering means a partial failure (e.g. push\ + \ succeeds but PR open fails) leaves the contract in a recoverable \"branch\ + \ exists, no PR yet\" state instead of a \"PR open but contract empty\" state.\n\ + 4. **TASK-4-1 has a defensive fallback** (`parent_branch = contract.pr.context_branch\ + \ or pipeline_branch`) with a warning log. Under D4 hard-switchover the fallback\ + \ should never fire, but the defensive code lets the orchestrator continue even\ + \ if a freak race left the field unset \u2014 better than crashing slice-1 provisioning.\n\ + 5. **TASK-4-3's reconciler ordering is correct.** Walk DAG \u2192 `context_branch`\ + \ if extant \u2192 `pipeline_branch` legacy fallback. Matches risk_analyst R3's\ + \ recommendation and architect C4 exactly.\n6. **`yaml-tasks` block is well-formed**\ + \ and the planner emits `pr.context_title` / `pr.context_description` correctly\ + \ via TASK-1-3's prompt update \u2014 the round-trip described in TASK-1-3 acceptance\ + \ is testable.\n7. **Test coverage** spans unit (TASK-1-2, TASK-2-3), gateway-primitive\ + \ (TASK-3-3), orchestrator-hook (TASK-3-3), reconciler (TASK-4-4), and end-to-end\ + \ smoke (TASK-4-4) \u2014 appropriate for the change scope.\n\n### Non-blocking\n\ + - **TASK-1-3's `files:` lists `.github/scripts/checks/plan_yaml_check.py` directly.**\ + \ The gateway blocks every producer role from pushing under `.github/` (`pipelines.py:8614,\ + \ 8831`); the established convention is to stage at `.github-staging/scripts/checks/plan_yaml_check.py`\ + \ and emit a Pre-merge Obligation for `git mv`. The plan's `manual_steps` block\ + \ does mention this convention (lines 200\u2013204), but TASK-1-3 itself doesn't\ + \ reflect it \u2014 the coder will hit a gateway rejection on first push if\ + \ they take the `files:` field literally. Update TASK-1-3 to either (a) target\ + \ `.github-staging/scripts/checks/plan_yaml_check.py` with a conditional ACK\ + \ / pre-merge obligation, or (b) move the YAML ingestion check out of `.github/scripts/`\ + \ into an orchestrator-resident module the coder is free to write. (a) is the\ + \ minimal-blast-radius path.\n- **TASK-2-1 description quietly assumes** \"\ + the orchestrator attaches a `slice_id` to each implement-phase BRC message;\ + \ if the message lacks a slice_id, log a warning and skip it.\" The architect\ + \ surfaced this as Q1 (spike during plan-refine). I verified `routes/signals.py:1480\u2013\ + 1487` already filters `metadata[\"slice_id\"]` and `peer_consensus.py:1875`\ + \ documents the nested `{pipeline_id}/{slice_id}` BRC scope, so the assumption\ + \ holds today \u2014 but the \"log a warning and skip\" path is a silent-data-loss\ + \ footgun if a future producer/reviewer regression drops the metadata. Suggest\ + \ TASK-2-1 acceptance gain \"Add an integration test that asserts every implement-phase\ + \ CONSENSUS_* message in a 2-slice fixture pipeline carries `metadata['slice_id']`.\ + \ If the assertion fails, the test must fail loud \u2014 not silently skip.\"\ + \n- **No agent-transcript content scrubber.** Risk_analyst R5 explicitly flagged\ + \ that `.egg-state/agent-outputs/-{refine,plan}-*.md` on a public repo (`jwbron/egg`)\ + \ may leak issue-body secrets, internal error messages, or quoted source. Slice-3\ + \ (TASK-3-2 step 3) copies these transcripts onto the context branch verbatim.\ + \ There is no scrub step in the plan, no HITL question, and no opt-out flag\ + \ (e.g. `pr.include_transcripts: bool`). The operator chose \"maximum transparency\"\ + \ in Q3 but the operator was probably not picturing 5MB of agent reasoning landing\ + \ on a public PR. Add a TASK-3-2 sub-step or TASK-3-4: \"Run `.egg-state/agent-outputs/-*`\ + \ through a content scrubber (regex / entropy-based, mirror existing pre-commit\ + \ secret detection); on hit, fail loud and surface an OVERSEER_ALERT for operator\ + \ triage.\" Alternatively register a new HITL question to confirm the operator\ + \ wants no scrubbing.\n- **No size budget for the context PR diff.** Risk_analyst\ + \ R12 noted that `2548-refine.md` is already 689 lines after one round and agent-output\ + \ transcripts are typically larger. Without a budget, a long-running pipeline\ + \ could land a 5MB context PR diff that no human reviews in full \u2014 undermining\ + \ the discoverability goal that motivated #2548. Suggest TASK-3-2 acceptance\ + \ gain \"If cumulative transcript size exceeds 1MB, the orchestrator emits an\ + \ OVERSEER_ALERT and the operator chooses to (a) include anyway, (b) summarize,\ + \ (c) drop transcripts for this pipeline.\" This dovetails with the scrubber\ + \ TASK-3-4 suggestion above and protects the operator from \"transparency by\ + \ accident\".\n- **TASK-3-2 step 4 says `--no-verify`.** That's the established\ + \ `_commit_statefiles_to_worktree` semantics (line 7179\u20137330) and is correct\ + \ given the orchestrator commits .egg-state in a tight loop where pre-commit\ + \ hooks would fight back, but the plan should explicitly note \"matches existing\ + \ primitive \u2014 `--no-verify` is intentional, not a hook bypass.\" Future\ + \ reviewers reading the slice-3 PR will otherwise (correctly) flag `--no-verify`\ + \ as suspicious.\n- **TASK-1-2 acceptance \"Confirm `context_pr_number=0` and\ + \ negative values are rejected if we add a `ge=1` validator (apply a sensible\ + \ validator)\" is conditional / hand-wavy.** Either commit to `Field(default=None,\ + \ ge=1)` (PR numbers are always \u22651 from GitHub's side) or drop the validator.\ + \ Recommendation: add `ge=1` \u2014 there's no legitimate `context_pr_number=0`\ + \ case.\n- **TASK-3-2 doesn't specify how to handle context-PR creation FAILURE.**\ + \ Risk_analyst R10 + architect C1 both flag this: D3 says \"no merge gate\"\ + \ but is silent on creation. Architect's failure handling is \"log + STATUS\ + \ broadcast + slice-1 falls back to pipeline_branch (legacy behavior)\". Task_planner's\ + \ TASK-3-2 step 5 says \"PR is opened doc-only auto-open: the orchestrator does\ + \ not block on its merge before slicing (D3)\" \u2014 but doesn't define what\ + \ happens if the PR-open call itself raises. Add to TASK-3-2 acceptance: \"\ + If `gateway.create_pr()` fails, the orchestrator logs an OVERSEER_ALERT, leaves\ + \ `contract.pr.context_pr_number=None`, and continues to slice-1 provisioning\ + \ (which falls back to `pipeline_branch` per TASK-4-1's defensive guard). Slice-1\ + \ spawning is NOT blocked.\" That makes the \"no merge gate\" decision honor\ + \ creation-failure tolerance too.\n- **Slice-5 docs scope is narrow.** TASK-5-1\ + \ lists `concurrent-execution.md`, `orchestrator.md`, `orchestrator-cli.md`,\ + \ `babysit-pr.md`. Missing: `docs/guides/sdlc-pipeline.md` (the canonical place\ + \ for branch topology \u2014 architect's C6 puts the `egg//context` topology\ + \ change here), and a release-note / changelog entry calling out the hard switchover\ + \ (D4). Add both to TASK-5-1's `files`.\n- **`base: pipeline.base_branch` is\ + \ referenced 4 times in the plan but `pipeline.base_branch` is not validated\ + \ to be present at plan_gate time.** If a pipeline is somehow missing `base_branch`\ + \ (legacy contracts pre-pipeline-base-branch field?), TASK-3-1's `create_context_branch()`\ + \ will crash. Add a precondition check in TASK-3-2 step 1: \"Assert `pipeline.base_branch`\ + \ is set; if absent, fail with operator-friendly error rather than passing `None`\ + \ to the gateway.\"\n- **Architect proposed 4 slices, task_planner produced\ + \ 5.** I'm ACKing the 5-slice decomposition since `_populate_contract_from_plan`\ + \ ingests this plan. The architect's analysis remains valid context but should\ + \ NOT be re-ingested as slice metadata \u2014 only the task_planner's output\ + \ flows into the contract.\n\nThe plan is approval-quality: ingestion-compatible,\ + \ HITL-faithful, dependency-correct, hard-switchover semantics are testable\ + \ via grep-able invariants, and per-slice acceptance is concrete. The non-blocking\ + \ items above are refinement work \u2014 primarily R5/R12 transcript exposure\ + \ (genuine security gap that should be picked up by slice-3's coder), the `.github/`\ + \ staging convention for TASK-1-3, and an explicit context-PR creation-failure\ + \ path. None block consensus.\n" + ack_version: 1 + version: 1 +```` + +### [2026-05-07T18:43:55Z] orchestrator → task_planner (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: c8fc2886-03e2-41 +phase: plan +metadata: + ready_to_confirm: true + version: 1 +```` + +### [2026-05-07T18:43:55Z] task_planner (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 69641e60-693f-40 +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:44:07Z] task_planner (CONSENSUS_CONFIRMED): Confirmed by task_planner + +````yaml +id: 94244eb2-1eb2-49 +phase: plan +metadata: + consensus_reached: false +```` + +### [2026-05-07T18:44:07Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: c4aca1ed-5ea0-45 +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:44:07Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 11ba4a05-eb74-44 +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:44:07Z] reviewer_plan (CONSENSUS_CONFIRMED): Confirmed by reviewer_plan + +````yaml +id: aef6ea5c-306f-45 +phase: plan +metadata: + consensus_reached: true +```` + +### [2026-05-07T18:44:11Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 2679ed2e-35fb-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:44:11.271924+00:00' +```` + +### [2026-05-07T18:44:11Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 50ac4352-0d86-45 +phase: plan +metadata: + state: WORKING +```` + +### [2026-05-07T18:44:11Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: a6006181-ced3-46 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T18:44:11.765464+00:00' +```` + +### [2026-05-07T18:44:11Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: bca2734b-685f-43 +phase: plan +metadata: + state: WORKING +```` diff --git a/.egg-state/brc-history/2548-refine.json b/.egg-state/brc-history/2548-refine.json new file mode 100644 index 0000000000..4dd91d5436 --- /dev/null +++ b/.egg-state/brc-history/2548-refine.json @@ -0,0 +1,605 @@ +[ + { + "id": "022ea11d-caf7-4a", + "pipeline_id": "issue-2548", + "from_role": "orchestrator", + "to_role": "all", + "message_type": "AGENT_FAILED", + "subject": "Agent refiner failed", + "body": "Container exited with code -1", + "metadata": {}, + "timestamp": "2026-05-07T17:26:19.077994+00:00", + "phase": "refine" + }, + { + "id": "5e2f05fe-be1e-4b", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:18.198623+00:00" + }, + "timestamp": "2026-05-07T17:43:28.244064+00:00", + "phase": "refine" + }, + { + "id": "658b3dcb-0cad-44", + "pipeline_id": "issue-2548", + "from_role": "overseer", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Cycle 1 complete. Refiner AGENT_FAILED at 17:26:19 was already resolved by orchestrator auto-restart. All three refine agents (refiner, reviewer_refine, reviewer_agent_design) running as of 17:42:33. Pipeline status: running / refine phase. No corrective action required; continuing monitor loop.", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T17:43:28.245153+00:00", + "phase": "refine" + }, + { + "id": "01f06216-454c-46", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:22.501256+00:00" + }, + "timestamp": "2026-05-07T17:43:28.245499+00:00", + "phase": "refine" + }, + { + "id": "ca5864f1-b0ae-4f", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:18.198623+00:00" + }, + "timestamp": "2026-05-07T17:44:28.292673+00:00", + "phase": "refine" + }, + { + "id": "1fb78ad8-4dd1-42", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:22.501256+00:00" + }, + "timestamp": "2026-05-07T17:44:28.322958+00:00", + "phase": "refine" + }, + { + "id": "4918e11d-cca8-4d", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:18.198623+00:00" + }, + "timestamp": "2026-05-07T17:45:28.432791+00:00", + "phase": "refine" + }, + { + "id": "090c7818-b2ac-4c", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:22.501256+00:00" + }, + "timestamp": "2026-05-07T17:45:28.451034+00:00", + "phase": "refine" + }, + { + "id": "162a1b66-5f09-42", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:18.198623+00:00" + }, + "timestamp": "2026-05-07T17:46:28.481595+00:00", + "phase": "refine" + }, + { + "id": "f34674ac-9ace-45", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:22.501256+00:00" + }, + "timestamp": "2026-05-07T17:46:28.490356+00:00", + "phase": "refine" + }, + { + "id": "60c88b2e-6234-49", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:18.198623+00:00" + }, + "timestamp": "2026-05-07T17:47:28.528393+00:00", + "phase": "refine" + }, + { + "id": "090baff8-3262-4e", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:22.501256+00:00" + }, + "timestamp": "2026-05-07T17:47:28.558603+00:00", + "phase": "refine" + }, + { + "id": "cfe44292-ba11-41", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:18.198623+00:00" + }, + "timestamp": "2026-05-07T17:48:28.584652+00:00", + "phase": "refine" + }, + { + "id": "2d6e73bc-a2e1-48", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:22.501256+00:00" + }, + "timestamp": "2026-05-07T17:48:28.613232+00:00", + "phase": "refine" + }, + { + "id": "14161a36-53f1-4e", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:18.198623+00:00" + }, + "timestamp": "2026-05-07T17:49:28.693255+00:00", + "phase": "refine" + }, + { + "id": "a013a9af-d639-4f", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:22.501256+00:00" + }, + "timestamp": "2026-05-07T17:49:28.710466+00:00", + "phase": "refine" + }, + { + "id": "8b50e57b-b74a-4d", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:18.198623+00:00" + }, + "timestamp": "2026-05-07T17:50:28.824588+00:00", + "phase": "refine" + }, + { + "id": "2457567f-9e9b-40", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:43:22.501256+00:00" + }, + "timestamp": "2026-05-07T17:50:28.901617+00:00", + "phase": "refine" + }, + { + "id": "f8f01d4c-f520-4e", + "pipeline_id": "issue-2548", + "from_role": "refiner", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from refiner", + "body": "Refine analysis for #2548 (missing analysis/plan/BRC docs on slice PRs). Verified current behavior in orchestrator/routes/pipelines.py (work-branch persistence, slice base resolution, BRC aggregate file) and stacked_pr_reconciler.py (orphan rebase fallback). Compared four options (context PR / embed-in-slice-1 / embed-in-terminal-slice / PR-body render); recommended Option A (dedicated context PR base=main + per-slice implement BRC split) as the only path that durably lands strategic context on main. Registered five HITL decisions (decision-1..5) and five open feedback questions (feedback-1) covering placement, BRC split, review model, rollout, stack ordering, and orchestrator-authored final commits to slice integration branches. Surfaced the deeper unintended-state issue that egg/<id>/work today never merges to main. Draft committed to .egg-state/drafts/2548-analysis.md at 8706bd6346.", + "metadata": { + "payload": { + "summary": "Refine analysis for #2548 (missing analysis/plan/BRC docs on slice PRs). Verified current behavior in orchestrator/routes/pipelines.py (work-branch persistence, slice base resolution, BRC aggregate file) and stacked_pr_reconciler.py (orphan rebase fallback). Compared four options (context PR / embed-in-slice-1 / embed-in-terminal-slice / PR-body render); recommended Option A (dedicated context PR base=main + per-slice implement BRC split) as the only path that durably lands strategic context on main. Registered five HITL decisions (decision-1..5) and five open feedback questions (feedback-1) covering placement, BRC split, review model, rollout, stack ordering, and orchestrator-authored final commits to slice integration branches. Surfaced the deeper unintended-state issue that egg/<id>/work today never merges to main. Draft committed to .egg-state/drafts/2548-analysis.md at 8706bd6346.", + "attestation": {}, + "artifacts": [ + ".egg-state/drafts/2548-analysis.md" + ], + "risk_considered": "Risk: recommending Option A locks in a non-trivial schema/code change. Mitigation: registered decision-1 so the operator can pick a smaller-blast-radius alternative (B or C) and decision-5 for stack ordering. Risk: per-slice BRC split touches every implement-phase write/read site. Mitigation: registered decision-2 to let the operator pick split-only vs aggregate-plus-per-slice vs PR-body-only.", + "commit_sha": "8706bd6346522b450099dc4c1cbdc310c94821f6", + "files_changed": [ + ".egg-state/drafts/2548-analysis.md" + ], + "tests_run": [], + "tasks_satisfied": [] + }, + "version": 1, + "commit_sha": "8706bd6346522b450099dc4c1cbdc310c94821f6" + }, + "timestamp": "2026-05-07T17:51:15.475634+00:00", + "phase": "refine" + }, + { + "id": "bc12eb6a-d206-40", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T17:51:15.534409+00:00", + "phase": "refine" + }, + { + "id": "47d3b861-a659-4f", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T17:51:15.543451+00:00", + "phase": "refine" + }, + { + "id": "e326eb09-bfc3-4c", + "pipeline_id": "issue-2548", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:51:24.811975+00:00" + }, + "timestamp": "2026-05-07T17:51:43.043401+00:00", + "phase": "refine" + }, + { + "id": "a5d00d38-4b56-4f", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "refiner", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_agent_design for refiner", + "body": "\nReviewed `.egg-state/drafts/2548-analysis.md` against agent-mode design criteria. No anti-patterns to flag.\n\nThe proposal is squarely an **orchestrator / infrastructure** redesign \u2014 context-PR branch creation, statefile commits authored by the orchestrator, contract schema delta on `PRMetadata`, slice-aware `_write_brc_history()` / `_rewrite_brc_history_for_pr()`, and a fallback adjustment in `stacked_pr_reconciler._resolve_extant_new_base()`. None of the four options (A\u2013D) modifies agent prompts, bakes large diffs into prompts, demands JSON output for human-facing surfaces, post-processes agent text, micromanages procedure steps, encodes prompt-level security, makes direct LLM calls outside the sandbox, or hardcodes model identifiers.\n\nSpecific positives from an agent-design lens:\n\n- **Role-boundary awareness (Q4 in feedback-1, lines 199):** the analysis explicitly raises that committing `.egg-state/brc-history/-implement-slice-.{json,md}` must be orchestrator-authored because gateway allowlists prevent coder/tester from writing under `.egg-state/brc-history/`. That is exactly the right architectural framing \u2014 sandbox-enforced boundaries, not prompt-enforced ones.\n- **Format/consumer pairing (decision-2, lines 158-163):** the per-slice split keeps `.json` (machine-readable consensus state for downstream tooling and audit) paired with `.md` (human-readable on the PR diff). Right format for the right consumer; not \"JSON for humans\".\n- **Option D rejection (lines 113-128)** is on durability/auditability grounds, not on anti-pattern grounds \u2014 a charitable reading even allows it. Good calibration.\n- **Recommended approach (lines 129-141)** routes all writes through the orchestrator using existing primitives (`_commit_statefiles_to_worktree()`, `gateway.create_slice_integration_branch()`) rather than asking an agent to produce structured output that downstream code parses.\n\n### Non-blocking\n- None.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/drafts/2548-analysis.md" + ], + "reason": "\nReviewed `.egg-state/drafts/2548-analysis.md` against agent-mode design criteria. No anti-patterns to flag.\n\nThe proposal is squarely an **orchestrator / infrastructure** redesign \u2014 context-PR branch creation, statefile commits authored by the orchestrator, contract schema delta on `PRMetadata`, slice-aware `_write_brc_history()` / `_rewrite_brc_history_for_pr()`, and a fallback adjustment in `stacked_pr_reconciler._resolve_extant_new_base()`. None of the four options (A\u2013D) modifies agent prompts, bakes large diffs into prompts, demands JSON output for human-facing surfaces, post-processes agent text, micromanages procedure steps, encodes prompt-level security, makes direct LLM calls outside the sandbox, or hardcodes model identifiers.\n\nSpecific positives from an agent-design lens:\n\n- **Role-boundary awareness (Q4 in feedback-1, lines 199):** the analysis explicitly raises that committing `.egg-state/brc-history/-implement-slice-.{json,md}` must be orchestrator-authored because gateway allowlists prevent coder/tester from writing under `.egg-state/brc-history/`. That is exactly the right architectural framing \u2014 sandbox-enforced boundaries, not prompt-enforced ones.\n- **Format/consumer pairing (decision-2, lines 158-163):** the per-slice split keeps `.json` (machine-readable consensus state for downstream tooling and audit) paired with `.md` (human-readable on the PR diff). Right format for the right consumer; not \"JSON for humans\".\n- **Option D rejection (lines 113-128)** is on durability/auditability grounds, not on anti-pattern grounds \u2014 a charitable reading even allows it. Good calibration.\n- **Recommended approach (lines 129-141)** routes all writes through the orchestrator using existing primitives (`_commit_statefiles_to_worktree()`, `gateway.create_slice_integration_branch()`) rather than asking an agent to produce structured output that downstream code parses.\n\n### Non-blocking\n- None.\n", + "ack_version": 1 + }, + "version": 1 + }, + "timestamp": "2026-05-07T17:52:36.036767+00:00", + "phase": "refine" + }, + { + "id": "b140cc68-dcb9-4e", + "pipeline_id": "issue-2548", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T17:52:36.125920+00:00", + "phase": "refine" + }, + { + "id": "3928aac3-cbfd-42", + "pipeline_id": "issue-2548", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:52:40.133347+00:00" + }, + "timestamp": "2026-05-07T17:52:40.164591+00:00", + "phase": "refine" + }, + { + "id": "174ae130-5627-4d", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by reviewer_agent_design", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-05-07T17:52:42.032938+00:00", + "phase": "refine" + }, + { + "id": "183185c3-7123-42", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:52:48.566299+00:00" + }, + "timestamp": "2026-05-07T17:52:48.617251+00:00", + "phase": "refine" + }, + { + "id": "68cd9b5f-e28a-49", + "pipeline_id": "issue-2548", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:52:40.133347+00:00" + }, + "timestamp": "2026-05-07T17:53:40.206557+00:00", + "phase": "refine" + }, + { + "id": "61ce74d8-59b4-48", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:52:48.566299+00:00" + }, + "timestamp": "2026-05-07T17:53:48.714595+00:00", + "phase": "refine" + }, + { + "id": "3212c96a-863b-43", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "refiner", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_refine for refiner", + "body": "\nReviewed `.egg-state/drafts/2548-analysis.md` (215 lines) end-to-end against the issue body and the cited code paths. Cross-checked every line-number citation in `orchestrator/routes/pipelines.py`, `orchestrator/stacked_pr_reconciler.py`, and `shared/egg_contracts/models.py`. Verified the contract via `mcp__sdlc__show_contract`: 5 HITL decisions (decision-1 through decision-5) and feedback-1 (Q1\u2013Q5) are all registered and unresolved \u2014 matches the analysis prose 1:1. The HITL markers in the draft (``, ``) align with the actual conventions: `egg-feedback` (not `egg-hitl-feedback`) is the real marker per `shared/egg_contracts/feedback.py:56` (FEEDBACK_MARKER_PATTERN) and `:102` \u2014 the criteria text's \"egg-hitl-feedback\" guidance is generic, not the implementation reality.\n\n### Section-by-section evaluation\n\n**1. Problem Understanding** \u2014 Strong. The Problem Statement (lines 5\u201314) correctly captures the issue: slice PRs lack analysis/plan/BRC visibility against `main`, with the two specific mechanisms the issue calls for (context PR + per-slice BRC). The Current Behavior section (lines 16\u201335) goes further than just paraphrasing the issue \u2014 it surfaces the deeper \"egg//work is never merged to main\" gap (line 35) which is a genuine new insight the issue body did NOT spell out and which feeds Q1 of feedback-1. The reproduction (lines 28\u201333) cites real refs (PR #2533 baseRefName/headRefName) consistent with the issue's reproduction.\n\n**2. Research Quality** \u2014 Strong, with two minor citation inaccuracies (non-blocking).\n - VERIFIED: `_commit_statefiles_to_worktree()` lives at `orchestrator/routes/pipelines.py:7179` (signature confirmed), per the analysis's \"7179-7318\".\n - VERIFIED: `_run_one_slice_inner()` at `:12405`, slice base resolution at `:12407-12410` (parent_branch = pipeline_branch for slice-1, else `f\"{issue_branch}/{parent_slice_id}\"`).\n - VERIFIED: `_resolve_extant_new_base()` at `orchestrator/stacked_pr_reconciler.py:87-132` and the `pipeline_branch` fallback at line 132 \u2014 quoting the docstring \"If the entire chain has been deleted, fall back to the pipeline branch\".\n - VERIFIED: `PRMetadata` at `shared/egg_contracts/models.py:371` (it actually extends to ~409 with the `_coerce_legacy_deferred_actions` validator, but 371-395 covers the field declarations as cited).\n - VERIFIED: 30 s reconciler cadence at `stacked_pr_reconciler.py:10` (\"default 30 s, env var\").\n - VERIFIED: gateway-enforced file boundaries (the constraint that coder/tester cannot push under `.egg-state/brc-history/`) is consistent with how the orchestrator authors statefile commits.\n - INACCURACY 1 (non-blocking): Line 21 cites `_write_brc_history()` as \"lines 8110-8228\". The function actually starts at `:8110` but the next def (`_rewrite_brc_history_for_pr`) is at `:8265`, so the body is ~8110-8264, not 8228. The conclusion (BRC history is single-aggregate, keyed by phase) is unaffected, but the upper bound should be tightened on a future revision.\n - INACCURACY 2 (non-blocking): Line 20 says lines 5141-5203 \"fetch them from per-agent worktrees during phase transitions\". Reading 5141-5203, the loop iterates `[(\"analysis\", \"-analysis.md\"), (\"plan\", \"-plan.md\")]` and reads via `_git_show_draft(repo_path, source_branch, expected_path)` against `origin/{source_branch}` \u2014 that is a remote-branch read with `git show` / `git ls-tree`, not a per-agent-worktree fetch. The mechanism is \"read from the work branch on origin\", not \"fetch from per-agent worktrees\". The strategic conclusion (artifacts are committed to `egg//work` and read back from there) is intact, but the implementation gloss is imprecise.\n\n**3. Options Analysis** \u2014 Strong. Options A\u2013D (lines 60\u2013127) are meaningfully different along the axes that matter (where docs land in the diff, whether a new branch/PR is introduced, which slice carries the context). Pros/cons articulate real trade-offs:\n - Option A correctly identifies the need for a contract schema delta (`pr.context_branch` / `pr.context_pr_number`) and the reconciler-fallback change.\n - Option B correctly identifies that \"docs never reach main\" is unresolved.\n - Option C correctly identifies the inverted reviewer flow (strategic context arrives only after N-1 code slices are reviewed).\n - Option D correctly identifies that PR-body-only is non-durable in `git log`.\n None of the options is a strawman; each represents a genuine architectural position.\n\n**4. Constraints and Dependencies** \u2014 Strong. Lines 39\u201356 cover gateway enforcement, BRC aggregate-vs-per-slice schema, reconciler invariants, contract model schema bump, HITL gate / merge ordering interaction with `deferred_actions`, and reconciler latency. Adjacent issues (#2534, #2541, #2543, #2354, #2538) are correctly cited and the partial-fix status of #2534 is acknowledged (line 51).\n\n**5. Open Questions** \u2014 Strong, and verifiable.\n - 5 HITL decisions registered on contract (decision-1 through decision-5, all `resolved: false`). Each has 4\u20135 options including \"Other (explain in reply)\".\n - 1 feedback object registered (feedback-1) carrying Q1\u2013Q5 as open-ended questions.\n - Questions are specific and actionable: each names the artifact under question (where docs live, how BRC history is split, whether context PR goes through BRC, rollout scope, where in the stack the context PR sits).\n - Q1 in particular (\"Is work-branch-as-base intentional and out of scope here?\") is a critical scope question that the analysis correctly elevates to HITL rather than silently assuming. Q4 surfaces the gateway role-boundary concern which is a real constraint.\n\n**6. Recommendation Quality** \u2014 Strong. Line 131 names Option A as recommended, conditional on operator selection in decision-1, decision-3, decision-5. The four-point rationale (lines 133\u2013139) is justified by the prior analysis (durability on main, separation of concerns, bounded infrastructure cost, forward-compat with #2534). Line 140 explicitly acknowledges Option B as an acceptable stepping stone if the operator prefers a smaller blast radius \u2014 which is appropriate given the issue is high-impact and the operator may want incremental rollout.\n\n**7. HITL Decision Registration** \u2014 VERIFIED. Every prose-level question in the analysis appears as a corresponding decision or feedback entry on the contract. Cross-checked the question text in the analysis (line 148, 158, 167, 176, 185 for decisions; lines 196\u2013200 for feedback) against `mcp__sdlc__show_contract` output \u2014 match is 1:1.\n\n### Non-blocking\n- **2548-analysis.md:21** \u2014 `_write_brc_history()` body is `:8110-:8264` (next `def` is at 8265), not 8228. Tighten on future revision.\n- **2548-analysis.md:20** \u2014 \"fetch them from per-agent worktrees during phase transitions\" misdescribes the lines 5141-5203 mechanism. The actual code reads `origin/{source_branch}` via `git show` / `git ls-tree`. Reword as e.g. \"are read back from the work branch on origin during phase transitions\" to avoid implying worktree-local I/O.\n- **2548-analysis.md:42** \u2014 The constraint says \"BRC history is currently aggregate, not per-slice (`{id}-implement.{json,md}`). Per-slice splitting requires a code change in `_write_brc_history()`...\". Worth noting that the splitting also has to integrate with `_rewrite_brc_history_for_pr` (called from `:16487`) which currently rebuilds a single aggregate file at PR-creation time \u2014 that is the read-side counterpart and the analysis already mentions it (line 42 cites `:8265-8328`); good. No change needed, just confirming the surface area is captured.\n- **2548-analysis.md:35** \u2014 The \"deeper issue\" paragraph is excellent insight but ends without elevating its own implication: if `egg//work` truly never reaches `main`, then Option A (which retargets slice-1 to `egg//context` based on `main`) is doing double duty \u2014 it solves the visibility problem AND silently fixes a separate \"docs never merge to main\" gap. The plan phase will benefit if the recommendation section makes that connection explicit so the planner doesn't accidentally pick Option B without also opening a follow-up issue for the work\u2192main gap.\n- **2548-analysis.md:62 (Option A)** \u2014 When describing slice-1's new base, the option says \"Slice-1's integration branch is then created from `egg//context`\". For the planner: the parent-branch-resolution code path at `:12407-12410` (\"if parent_slice_id is None: parent_branch = pipeline_branch\") would need to learn about a context branch in addition to the existing forest-root case. Worth flagging in the plan-phase tasks but not blocking here.\n\nThe analysis is approved for the plan phase. Recommendation is sound, options are well-developed, HITL questions are specific and registered.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/drafts/2548-analysis.md", + ".egg-state/contracts/issue-2548.json", + "orchestrator/routes/pipelines.py", + "orchestrator/stacked_pr_reconciler.py", + "shared/egg_contracts/models.py", + "shared/egg_contracts/feedback.py" + ], + "reason": "\nReviewed `.egg-state/drafts/2548-analysis.md` (215 lines) end-to-end against the issue body and the cited code paths. Cross-checked every line-number citation in `orchestrator/routes/pipelines.py`, `orchestrator/stacked_pr_reconciler.py`, and `shared/egg_contracts/models.py`. Verified the contract via `mcp__sdlc__show_contract`: 5 HITL decisions (decision-1 through decision-5) and feedback-1 (Q1\u2013Q5) are all registered and unresolved \u2014 matches the analysis prose 1:1. The HITL markers in the draft (``, ``) align with the actual conventions: `egg-feedback` (not `egg-hitl-feedback`) is the real marker per `shared/egg_contracts/feedback.py:56` (FEEDBACK_MARKER_PATTERN) and `:102` \u2014 the criteria text's \"egg-hitl-feedback\" guidance is generic, not the implementation reality.\n\n### Section-by-section evaluation\n\n**1. Problem Understanding** \u2014 Strong. The Problem Statement (lines 5\u201314) correctly captures the issue: slice PRs lack analysis/plan/BRC visibility against `main`, with the two specific mechanisms the issue calls for (context PR + per-slice BRC). The Current Behavior section (lines 16\u201335) goes further than just paraphrasing the issue \u2014 it surfaces the deeper \"egg//work is never merged to main\" gap (line 35) which is a genuine new insight the issue body did NOT spell out and which feeds Q1 of feedback-1. The reproduction (lines 28\u201333) cites real refs (PR #2533 baseRefName/headRefName) consistent with the issue's reproduction.\n\n**2. Research Quality** \u2014 Strong, with two minor citation inaccuracies (non-blocking).\n - VERIFIED: `_commit_statefiles_to_worktree()` lives at `orchestrator/routes/pipelines.py:7179` (signature confirmed), per the analysis's \"7179-7318\".\n - VERIFIED: `_run_one_slice_inner()` at `:12405`, slice base resolution at `:12407-12410` (parent_branch = pipeline_branch for slice-1, else `f\"{issue_branch}/{parent_slice_id}\"`).\n - VERIFIED: `_resolve_extant_new_base()` at `orchestrator/stacked_pr_reconciler.py:87-132` and the `pipeline_branch` fallback at line 132 \u2014 quoting the docstring \"If the entire chain has been deleted, fall back to the pipeline branch\".\n - VERIFIED: `PRMetadata` at `shared/egg_contracts/models.py:371` (it actually extends to ~409 with the `_coerce_legacy_deferred_actions` validator, but 371-395 covers the field declarations as cited).\n - VERIFIED: 30 s reconciler cadence at `stacked_pr_reconciler.py:10` (\"default 30 s, env var\").\n - VERIFIED: gateway-enforced file boundaries (the constraint that coder/tester cannot push under `.egg-state/brc-history/`) is consistent with how the orchestrator authors statefile commits.\n - INACCURACY 1 (non-blocking): Line 21 cites `_write_brc_history()` as \"lines 8110-8228\". The function actually starts at `:8110` but the next def (`_rewrite_brc_history_for_pr`) is at `:8265`, so the body is ~8110-8264, not 8228. The conclusion (BRC history is single-aggregate, keyed by phase) is unaffected, but the upper bound should be tightened on a future revision.\n - INACCURACY 2 (non-blocking): Line 20 says lines 5141-5203 \"fetch them from per-agent worktrees during phase transitions\". Reading 5141-5203, the loop iterates `[(\"analysis\", \"-analysis.md\"), (\"plan\", \"-plan.md\")]` and reads via `_git_show_draft(repo_path, source_branch, expected_path)` against `origin/{source_branch}` \u2014 that is a remote-branch read with `git show` / `git ls-tree`, not a per-agent-worktree fetch. The mechanism is \"read from the work branch on origin\", not \"fetch from per-agent worktrees\". The strategic conclusion (artifacts are committed to `egg//work` and read back from there) is intact, but the implementation gloss is imprecise.\n\n**3. Options Analysis** \u2014 Strong. Options A\u2013D (lines 60\u2013127) are meaningfully different along the axes that matter (where docs land in the diff, whether a new branch/PR is introduced, which slice carries the context). Pros/cons articulate real trade-offs:\n - Option A correctly identifies the need for a contract schema delta (`pr.context_branch` / `pr.context_pr_number`) and the reconciler-fallback change.\n - Option B correctly identifies that \"docs never reach main\" is unresolved.\n - Option C correctly identifies the inverted reviewer flow (strategic context arrives only after N-1 code slices are reviewed).\n - Option D correctly identifies that PR-body-only is non-durable in `git log`.\n None of the options is a strawman; each represents a genuine architectural position.\n\n**4. Constraints and Dependencies** \u2014 Strong. Lines 39\u201356 cover gateway enforcement, BRC aggregate-vs-per-slice schema, reconciler invariants, contract model schema bump, HITL gate / merge ordering interaction with `deferred_actions`, and reconciler latency. Adjacent issues (#2534, #2541, #2543, #2354, #2538) are correctly cited and the partial-fix status of #2534 is acknowledged (line 51).\n\n**5. Open Questions** \u2014 Strong, and verifiable.\n - 5 HITL decisions registered on contract (decision-1 through decision-5, all `resolved: false`). Each has 4\u20135 options including \"Other (explain in reply)\".\n - 1 feedback object registered (feedback-1) carrying Q1\u2013Q5 as open-ended questions.\n - Questions are specific and actionable: each names the artifact under question (where docs live, how BRC history is split, whether context PR goes through BRC, rollout scope, where in the stack the context PR sits).\n - Q1 in particular (\"Is work-branch-as-base intentional and out of scope here?\") is a critical scope question that the analysis correctly elevates to HITL rather than silently assuming. Q4 surfaces the gateway role-boundary concern which is a real constraint.\n\n**6. Recommendation Quality** \u2014 Strong. Line 131 names Option A as recommended, conditional on operator selection in decision-1, decision-3, decision-5. The four-point rationale (lines 133\u2013139) is justified by the prior analysis (durability on main, separation of concerns, bounded infrastructure cost, forward-compat with #2534). Line 140 explicitly acknowledges Option B as an acceptable stepping stone if the operator prefers a smaller blast radius \u2014 which is appropriate given the issue is high-impact and the operator may want incremental rollout.\n\n**7. HITL Decision Registration** \u2014 VERIFIED. Every prose-level question in the analysis appears as a corresponding decision or feedback entry on the contract. Cross-checked the question text in the analysis (line 148, 158, 167, 176, 185 for decisions; lines 196\u2013200 for feedback) against `mcp__sdlc__show_contract` output \u2014 match is 1:1.\n\n### Non-blocking\n- **2548-analysis.md:21** \u2014 `_write_brc_history()` body is `:8110-:8264` (next `def` is at 8265), not 8228. Tighten on future revision.\n- **2548-analysis.md:20** \u2014 \"fetch them from per-agent worktrees during phase transitions\" misdescribes the lines 5141-5203 mechanism. The actual code reads `origin/{source_branch}` via `git show` / `git ls-tree`. Reword as e.g. \"are read back from the work branch on origin during phase transitions\" to avoid implying worktree-local I/O.\n- **2548-analysis.md:42** \u2014 The constraint says \"BRC history is currently aggregate, not per-slice (`{id}-implement.{json,md}`). Per-slice splitting requires a code change in `_write_brc_history()`...\". Worth noting that the splitting also has to integrate with `_rewrite_brc_history_for_pr` (called from `:16487`) which currently rebuilds a single aggregate file at PR-creation time \u2014 that is the read-side counterpart and the analysis already mentions it (line 42 cites `:8265-8328`); good. No change needed, just confirming the surface area is captured.\n- **2548-analysis.md:35** \u2014 The \"deeper issue\" paragraph is excellent insight but ends without elevating its own implication: if `egg//work` truly never reaches `main`, then Option A (which retargets slice-1 to `egg//context` based on `main`) is doing double duty \u2014 it solves the visibility problem AND silently fixes a separate \"docs never merge to main\" gap. The plan phase will benefit if the recommendation section makes that connection explicit so the planner doesn't accidentally pick Option B without also opening a follow-up issue for the work\u2192main gap.\n- **2548-analysis.md:62 (Option A)** \u2014 When describing slice-1's new base, the option says \"Slice-1's integration branch is then created from `egg//context`\". For the planner: the parent-branch-resolution code path at `:12407-12410` (\"if parent_slice_id is None: parent_branch = pipeline_branch\") would need to learn about a context branch in addition to the existing forest-root case. Worth flagging in the plan-phase tasks but not blocking here.\n\nThe analysis is approved for the plan phase. Recommendation is sound, options are well-developed, HITL questions are specific and registered.\n", + "ack_version": 1 + }, + "version": 1 + }, + "timestamp": "2026-05-07T17:54:27.733852+00:00", + "phase": "refine" + }, + { + "id": "15e5fee0-2a46-4a", + "pipeline_id": "issue-2548", + "from_role": "orchestrator", + "to_role": "refiner", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1 + }, + "timestamp": "2026-05-07T17:54:27.733927+00:00", + "phase": "refine" + }, + { + "id": "a7d4b881-068a-45", + "pipeline_id": "issue-2548", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T17:54:27.795144+00:00", + "phase": "refine" + }, + { + "id": "f5f08f9d-3184-49", + "pipeline_id": "issue-2548", + "from_role": "refiner", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by refiner", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-05-07T17:54:31.258114+00:00", + "phase": "refine" + }, + { + "id": "56a3249b-a292-4d", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T17:54:31.337912+00:00", + "phase": "refine" + }, + { + "id": "f8f5da3e-aa1c-4b", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by reviewer_refine", + "body": "", + "metadata": { + "consensus_reached": true + }, + "timestamp": "2026-05-07T17:54:33.110606+00:00", + "phase": "refine" + }, + { + "id": "9afe557a-3056-45", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:54:38.344801+00:00" + }, + "timestamp": "2026-05-07T17:54:38.393031+00:00", + "phase": "refine" + }, + { + "id": "028939f7-694f-4a", + "pipeline_id": "issue-2548", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-05-07T17:54:38.493100+00:00", + "phase": "refine" + }, + { + "id": "2b387014-df99-45", + "pipeline_id": "issue-2548", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:54:39.197307+00:00" + }, + "timestamp": "2026-05-07T17:54:39.245161+00:00", + "phase": "refine" + }, + { + "id": "2d2742a8-ab7e-4c", + "pipeline_id": "issue-2548", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-05-07T17:54:40.015512+00:00" + }, + "timestamp": "2026-05-07T17:54:40.050276+00:00", + "phase": "refine" + } +] \ No newline at end of file diff --git a/.egg-state/brc-history/2548-refine.md b/.egg-state/brc-history/2548-refine.md new file mode 100644 index 0000000000..3d403064b3 --- /dev/null +++ b/.egg-state/brc-history/2548-refine.md @@ -0,0 +1,689 @@ +# BRC Consensus History — refine phase + +Generated: 2026-05-07T17:54:40Z +Pipeline: issue-2548 + +### [2026-05-07T17:26:19Z] orchestrator (AGENT_FAILED): Agent refiner failed + +Container exited with code -1 + +````yaml +id: 022ea11d-caf7-4a +phase: refine +```` + +### [2026-05-07T17:43:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 5e2f05fe-be1e-4b +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:18.198623+00:00' +```` + +### [2026-05-07T17:43:28Z] overseer (HEARTBEAT): heartbeat: WORKING + +Cycle 1 complete. Refiner AGENT_FAILED at 17:26:19 was already resolved by orchestrator auto-restart. All three refine agents (refiner, reviewer_refine, reviewer_agent_design) running as of 17:42:33. Pipeline status: running / refine phase. No corrective action required; continuing monitor loop. + +````yaml +id: 658b3dcb-0cad-44 +phase: refine +metadata: + state: WORKING +```` + +### [2026-05-07T17:43:28Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 01f06216-454c-46 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:22.501256+00:00' +```` + +### [2026-05-07T17:44:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: ca5864f1-b0ae-4f +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:18.198623+00:00' +```` + +### [2026-05-07T17:44:28Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 1fb78ad8-4dd1-42 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:22.501256+00:00' +```` + +### [2026-05-07T17:45:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 4918e11d-cca8-4d +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:18.198623+00:00' +```` + +### [2026-05-07T17:45:28Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 090c7818-b2ac-4c +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:22.501256+00:00' +```` + +### [2026-05-07T17:46:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 162a1b66-5f09-42 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:18.198623+00:00' +```` + +### [2026-05-07T17:46:28Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: f34674ac-9ace-45 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:22.501256+00:00' +```` + +### [2026-05-07T17:47:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 60c88b2e-6234-49 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:18.198623+00:00' +```` + +### [2026-05-07T17:47:28Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 090baff8-3262-4e +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:22.501256+00:00' +```` + +### [2026-05-07T17:48:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: cfe44292-ba11-41 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:18.198623+00:00' +```` + +### [2026-05-07T17:48:28Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 2d6e73bc-a2e1-48 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:22.501256+00:00' +```` + +### [2026-05-07T17:49:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 14161a36-53f1-4e +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:18.198623+00:00' +```` + +### [2026-05-07T17:49:28Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: a013a9af-d639-4f +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:22.501256+00:00' +```` + +### [2026-05-07T17:50:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 8b50e57b-b74a-4d +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:18.198623+00:00' +```` + +### [2026-05-07T17:50:28Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 2457567f-9e9b-40 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:43:22.501256+00:00' +```` + +### [2026-05-07T17:51:15Z] refiner (CONSENSUS_PROPOSE): Proposal from refiner + +Refine analysis for #2548 (missing analysis/plan/BRC docs on slice PRs). Verified current behavior in orchestrator/routes/pipelines.py (work-branch persistence, slice base resolution, BRC aggregate file) and stacked_pr_reconciler.py (orphan rebase fallback). Compared four options (context PR / embed-in-slice-1 / embed-in-terminal-slice / PR-body render); recommended Option A (dedicated context PR base=main + per-slice implement BRC split) as the only path that durably lands strategic context on main. Registered five HITL decisions (decision-1..5) and five open feedback questions (feedback-1) covering placement, BRC split, review model, rollout, stack ordering, and orchestrator-authored final commits to slice integration branches. Surfaced the deeper unintended-state issue that egg/<id>/work today never merges to main. Draft committed to .egg-state/drafts/2548-analysis.md at 8706bd6346. + +````yaml +id: f8f01d4c-f520-4e +phase: refine +metadata: + payload: + summary: 'Refine analysis for #2548 (missing analysis/plan/BRC docs on slice PRs). + Verified current behavior in orchestrator/routes/pipelines.py (work-branch persistence, + slice base resolution, BRC aggregate file) and stacked_pr_reconciler.py (orphan + rebase fallback). Compared four options (context PR / embed-in-slice-1 / embed-in-terminal-slice + / PR-body render); recommended Option A (dedicated context PR base=main + per-slice + implement BRC split) as the only path that durably lands strategic context on + main. Registered five HITL decisions (decision-1..5) and five open feedback + questions (feedback-1) covering placement, BRC split, review model, rollout, + stack ordering, and orchestrator-authored final commits to slice integration + branches. Surfaced the deeper unintended-state issue that egg/<id>/work + today never merges to main. Draft committed to .egg-state/drafts/2548-analysis.md + at 8706bd6346.' + attestation: {} + artifacts: + - .egg-state/drafts/2548-analysis.md + risk_considered: 'Risk: recommending Option A locks in a non-trivial schema/code + change. Mitigation: registered decision-1 so the operator can pick a smaller-blast-radius + alternative (B or C) and decision-5 for stack ordering. Risk: per-slice BRC + split touches every implement-phase write/read site. Mitigation: registered + decision-2 to let the operator pick split-only vs aggregate-plus-per-slice vs + PR-body-only.' + commit_sha: 8706bd6346522b450099dc4c1cbdc310c94821f6 + files_changed: + - .egg-state/drafts/2548-analysis.md + tests_run: [] + tasks_satisfied: [] + version: 1 + commit_sha: 8706bd6346522b450099dc4c1cbdc310c94821f6 +```` + +### [2026-05-07T17:51:15Z] reviewer_refine (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: bc12eb6a-d206-40 +phase: refine +metadata: + state: WORKING +```` + +### [2026-05-07T17:51:15Z] reviewer_agent_design (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 47d3b861-a659-4f +phase: refine +metadata: + state: WORKING +```` + +### [2026-05-07T17:51:43Z] refiner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: e326eb09-bfc3-4c +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:51:24.811975+00:00' +```` + +### [2026-05-07T17:52:36Z] reviewer_agent_design → refiner (CONSENSUS_ACK): ACK from reviewer_agent_design for refiner + + +Reviewed `.egg-state/drafts/2548-analysis.md` against agent-mode design criteria. No anti-patterns to flag. + +The proposal is squarely an **orchestrator / infrastructure** redesign — context-PR branch creation, statefile commits authored by the orchestrator, contract schema delta on `PRMetadata`, slice-aware `_write_brc_history()` / `_rewrite_brc_history_for_pr()`, and a fallback adjustment in `stacked_pr_reconciler._resolve_extant_new_base()`. None of the four options (A–D) modifies agent prompts, bakes large diffs into prompts, demands JSON output for human-facing surfaces, post-processes agent text, micromanages procedure steps, encodes prompt-level security, makes direct LLM calls outside the sandbox, or hardcodes model identifiers. + +Specific positives from an agent-design lens: + +- **Role-boundary awareness (Q4 in feedback-1, lines 199):** the analysis explicitly raises that committing `.egg-state/brc-history/-implement-slice-.{json,md}` must be orchestrator-authored because gateway allowlists prevent coder/tester from writing under `.egg-state/brc-history/`. That is exactly the right architectural framing — sandbox-enforced boundaries, not prompt-enforced ones. +- **Format/consumer pairing (decision-2, lines 158-163):** the per-slice split keeps `.json` (machine-readable consensus state for downstream tooling and audit) paired with `.md` (human-readable on the PR diff). Right format for the right consumer; not "JSON for humans". +- **Option D rejection (lines 113-128)** is on durability/auditability grounds, not on anti-pattern grounds — a charitable reading even allows it. Good calibration. +- **Recommended approach (lines 129-141)** routes all writes through the orchestrator using existing primitives (`_commit_statefiles_to_worktree()`, `gateway.create_slice_integration_branch()`) rather than asking an agent to produce structured output that downstream code parses. + +### Non-blocking +- None. + + +````yaml +id: a5d00d38-4b56-4f +phase: refine +metadata: + payload: + artifact_references: + - .egg-state/drafts/2548-analysis.md + reason: "\nReviewed `.egg-state/drafts/2548-analysis.md` against agent-mode design\ + \ criteria. No anti-patterns to flag.\n\nThe proposal is squarely an **orchestrator\ + \ / infrastructure** redesign \u2014 context-PR branch creation, statefile commits\ + \ authored by the orchestrator, contract schema delta on `PRMetadata`, slice-aware\ + \ `_write_brc_history()` / `_rewrite_brc_history_for_pr()`, and a fallback adjustment\ + \ in `stacked_pr_reconciler._resolve_extant_new_base()`. None of the four options\ + \ (A\u2013D) modifies agent prompts, bakes large diffs into prompts, demands\ + \ JSON output for human-facing surfaces, post-processes agent text, micromanages\ + \ procedure steps, encodes prompt-level security, makes direct LLM calls outside\ + \ the sandbox, or hardcodes model identifiers.\n\nSpecific positives from an\ + \ agent-design lens:\n\n- **Role-boundary awareness (Q4 in feedback-1, lines\ + \ 199):** the analysis explicitly raises that committing `.egg-state/brc-history/-implement-slice-.{json,md}`\ + \ must be orchestrator-authored because gateway allowlists prevent coder/tester\ + \ from writing under `.egg-state/brc-history/`. That is exactly the right architectural\ + \ framing \u2014 sandbox-enforced boundaries, not prompt-enforced ones.\n- **Format/consumer\ + \ pairing (decision-2, lines 158-163):** the per-slice split keeps `.json` (machine-readable\ + \ consensus state for downstream tooling and audit) paired with `.md` (human-readable\ + \ on the PR diff). Right format for the right consumer; not \"JSON for humans\"\ + .\n- **Option D rejection (lines 113-128)** is on durability/auditability grounds,\ + \ not on anti-pattern grounds \u2014 a charitable reading even allows it. Good\ + \ calibration.\n- **Recommended approach (lines 129-141)** routes all writes\ + \ through the orchestrator using existing primitives (`_commit_statefiles_to_worktree()`,\ + \ `gateway.create_slice_integration_branch()`) rather than asking an agent to\ + \ produce structured output that downstream code parses.\n\n### Non-blocking\n\ + - None.\n" + ack_version: 1 + version: 1 +```` + +### [2026-05-07T17:52:36Z] refiner (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: b140cc68-dcb9-4e +phase: refine +metadata: + state: WORKING +```` + +### [2026-05-07T17:52:40Z] refiner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 3928aac3-cbfd-42 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:52:40.133347+00:00' +```` + +### [2026-05-07T17:52:42Z] reviewer_agent_design (CONSENSUS_CONFIRMED): Confirmed by reviewer_agent_design + +````yaml +id: 174ae130-5627-4d +phase: refine +metadata: + consensus_reached: false +```` + +### [2026-05-07T17:52:48Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 183185c3-7123-42 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:52:48.566299+00:00' +```` + +### [2026-05-07T17:53:40Z] refiner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,STATUS,OVERSEER_ALERT + +````yaml +id: 68cd9b5f-e28a-49 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:52:40.133347+00:00' +```` + +### [2026-05-07T17:53:48Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 61ce74d8-59b4-48 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:52:48.566299+00:00' +```` + +### [2026-05-07T17:54:27Z] reviewer_refine → refiner (CONSENSUS_ACK): ACK from reviewer_refine for refiner + + +Reviewed `.egg-state/drafts/2548-analysis.md` (215 lines) end-to-end against the issue body and the cited code paths. Cross-checked every line-number citation in `orchestrator/routes/pipelines.py`, `orchestrator/stacked_pr_reconciler.py`, and `shared/egg_contracts/models.py`. Verified the contract via `mcp__sdlc__show_contract`: 5 HITL decisions (decision-1 through decision-5) and feedback-1 (Q1–Q5) are all registered and unresolved — matches the analysis prose 1:1. The HITL markers in the draft (``, ``) align with the actual conventions: `egg-feedback` (not `egg-hitl-feedback`) is the real marker per `shared/egg_contracts/feedback.py:56` (FEEDBACK_MARKER_PATTERN) and `:102` — the criteria text's "egg-hitl-feedback" guidance is generic, not the implementation reality. + +### Section-by-section evaluation + +**1. Problem Understanding** — Strong. The Problem Statement (lines 5–14) correctly captures the issue: slice PRs lack analysis/plan/BRC visibility against `main`, with the two specific mechanisms the issue calls for (context PR + per-slice BRC). The Current Behavior section (lines 16–35) goes further than just paraphrasing the issue — it surfaces the deeper "egg//work is never merged to main" gap (line 35) which is a genuine new insight the issue body did NOT spell out and which feeds Q1 of feedback-1. The reproduction (lines 28–33) cites real refs (PR #2533 baseRefName/headRefName) consistent with the issue's reproduction. + +**2. Research Quality** — Strong, with two minor citation inaccuracies (non-blocking). + - VERIFIED: `_commit_statefiles_to_worktree()` lives at `orchestrator/routes/pipelines.py:7179` (signature confirmed), per the analysis's "7179-7318". + - VERIFIED: `_run_one_slice_inner()` at `:12405`, slice base resolution at `:12407-12410` (parent_branch = pipeline_branch for slice-1, else `f"{issue_branch}/{parent_slice_id}"`). + - VERIFIED: `_resolve_extant_new_base()` at `orchestrator/stacked_pr_reconciler.py:87-132` and the `pipeline_branch` fallback at line 132 — quoting the docstring "If the entire chain has been deleted, fall back to the pipeline branch". + - VERIFIED: `PRMetadata` at `shared/egg_contracts/models.py:371` (it actually extends to ~409 with the `_coerce_legacy_deferred_actions` validator, but 371-395 covers the field declarations as cited). + - VERIFIED: 30 s reconciler cadence at `stacked_pr_reconciler.py:10` ("default 30 s, env var"). + - VERIFIED: gateway-enforced file boundaries (the constraint that coder/tester cannot push under `.egg-state/brc-history/`) is consistent with how the orchestrator authors statefile commits. + - INACCURACY 1 (non-blocking): Line 21 cites `_write_brc_history()` as "lines 8110-8228". The function actually starts at `:8110` but the next def (`_rewrite_brc_history_for_pr`) is at `:8265`, so the body is ~8110-8264, not 8228. The conclusion (BRC history is single-aggregate, keyed by phase) is unaffected, but the upper bound should be tightened on a future revision. + - INACCURACY 2 (non-blocking): Line 20 says lines 5141-5203 "fetch them from per-agent worktrees during phase transitions". Reading 5141-5203, the loop iterates `[("analysis", "-analysis.md"), ("plan", "-plan.md")]` and reads via `_git_show_draft(repo_path, source_branch, expected_path)` against `origin/{source_branch}` — that is a remote-branch read with `git show` / `git ls-tree`, not a per-agent-worktree fetch. The mechanism is "read from the work branch on origin", not "fetch from per-agent worktrees". The strategic conclusion (artifacts are committed to `egg//work` and read back from there) is intact, but the implementation gloss is imprecise. + +**3. Options Analysis** — Strong. Options A–D (lines 60–127) are meaningfully different along the axes that matter (where docs land in the diff, whether a new branch/PR is introduced, which slice carries the context). Pros/cons articulate real trade-offs: + - Option A correctly identifies the need for a contract schema delta (`pr.context_branch` / `pr.context_pr_number`) and the reconciler-fallback change. + - Option B correctly identifies that "docs never reach main" is unresolved. + - Option C correctly identifies the inverted reviewer flow (strategic context arrives only after N-1 code slices are reviewed). + - Option D correctly identifies that PR-body-only is non-durable in `git log`. + None of the options is a strawman; each represents a genuine architectural position. + +**4. Constraints and Dependencies** — Strong. Lines 39–56 cover gateway enforcement, BRC aggregate-vs-per-slice schema, reconciler invariants, contract model schema bump, HITL gate / merge ordering interaction with `deferred_actions`, and reconciler latency. Adjacent issues (#2534, #2541, #2543, #2354, #2538) are correctly cited and the partial-fix status of #2534 is acknowledged (line 51). + +**5. Open Questions** — Strong, and verifiable. + - 5 HITL decisions registered on contract (decision-1 through decision-5, all `resolved: false`). Each has 4–5 options including "Other (explain in reply)". + - 1 feedback object registered (feedback-1) carrying Q1–Q5 as open-ended questions. + - Questions are specific and actionable: each names the artifact under question (where docs live, how BRC history is split, whether context PR goes through BRC, rollout scope, where in the stack the context PR sits). + - Q1 in particular ("Is work-branch-as-base intentional and out of scope here?") is a critical scope question that the analysis correctly elevates to HITL rather than silently assuming. Q4 surfaces the gateway role-boundary concern which is a real constraint. + +**6. Recommendation Quality** — Strong. Line 131 names Option A as recommended, conditional on operator selection in decision-1, decision-3, decision-5. The four-point rationale (lines 133–139) is justified by the prior analysis (durability on main, separation of concerns, bounded infrastructure cost, forward-compat with #2534). Line 140 explicitly acknowledges Option B as an acceptable stepping stone if the operator prefers a smaller blast radius — which is appropriate given the issue is high-impact and the operator may want incremental rollout. + +**7. HITL Decision Registration** — VERIFIED. Every prose-level question in the analysis appears as a corresponding decision or feedback entry on the contract. Cross-checked the question text in the analysis (line 148, 158, 167, 176, 185 for decisions; lines 196–200 for feedback) against `mcp__sdlc__show_contract` output — match is 1:1. + +### Non-blocking +- **2548-analysis.md:21** — `_write_brc_history()` body is `:8110-:8264` (next `def` is at 8265), not 8228. Tighten on future revision. +- **2548-analysis.md:20** — "fetch them from per-agent worktrees during phase transitions" misdescribes the lines 5141-5203 mechanism. The actual code reads `origin/{source_branch}` via `git show` / `git ls-tree`. Reword as e.g. "are read back from the work branch on origin during phase transitions" to avoid implying worktree-local I/O. +- **2548-analysis.md:42** — The constraint says "BRC history is currently aggregate, not per-slice (`{id}-implement.{json,md}`). Per-slice splitting requires a code change in `_write_brc_history()`...". Worth noting that the splitting also has to integrate with `_rewrite_brc_history_for_pr` (called from `:16487`) which currently rebuilds a single aggregate file at PR-creation time — that is the read-side counterpart and the analysis already mentions it (line 42 cites `:8265-8328`); good. No change needed, just confirming the surface area is captured. +- **2548-analysis.md:35** — The "deeper issue" paragraph is excellent insight but ends without elevating its own implication: if `egg//work` truly never reaches `main`, then Option A (which retargets slice-1 to `egg//context` based on `main`) is doing double duty — it solves the visibility problem AND silently fixes a separate "docs never merge to main" gap. The plan phase will benefit if the recommendation section makes that connection explicit so the planner doesn't accidentally pick Option B without also opening a follow-up issue for the work→main gap. +- **2548-analysis.md:62 (Option A)** — When describing slice-1's new base, the option says "Slice-1's integration branch is then created from `egg//context`". For the planner: the parent-branch-resolution code path at `:12407-12410` ("if parent_slice_id is None: parent_branch = pipeline_branch") would need to learn about a context branch in addition to the existing forest-root case. Worth flagging in the plan-phase tasks but not blocking here. + +The analysis is approved for the plan phase. Recommendation is sound, options are well-developed, HITL questions are specific and registered. + + +````yaml +id: 3212c96a-863b-43 +phase: refine +metadata: + payload: + artifact_references: + - .egg-state/drafts/2548-analysis.md + - .egg-state/contracts/issue-2548.json + - orchestrator/routes/pipelines.py + - orchestrator/stacked_pr_reconciler.py + - shared/egg_contracts/models.py + - shared/egg_contracts/feedback.py + reason: "\nReviewed `.egg-state/drafts/2548-analysis.md` (215 lines) end-to-end\ + \ against the issue body and the cited code paths. Cross-checked every line-number\ + \ citation in `orchestrator/routes/pipelines.py`, `orchestrator/stacked_pr_reconciler.py`,\ + \ and `shared/egg_contracts/models.py`. Verified the contract via `mcp__sdlc__show_contract`:\ + \ 5 HITL decisions (decision-1 through decision-5) and feedback-1 (Q1\u2013\ + Q5) are all registered and unresolved \u2014 matches the analysis prose 1:1.\ + \ The HITL markers in the draft (``,\ + \ ``) align with the actual conventions:\ + \ `egg-feedback` (not `egg-hitl-feedback`) is the real marker per `shared/egg_contracts/feedback.py:56`\ + \ (FEEDBACK_MARKER_PATTERN) and `:102` \u2014 the criteria text's \"egg-hitl-feedback\"\ + \ guidance is generic, not the implementation reality.\n\n### Section-by-section\ + \ evaluation\n\n**1. Problem Understanding** \u2014 Strong. The Problem Statement\ + \ (lines 5\u201314) correctly captures the issue: slice PRs lack analysis/plan/BRC\ + \ visibility against `main`, with the two specific mechanisms the issue calls\ + \ for (context PR + per-slice BRC). The Current Behavior section (lines 16\u2013\ + 35) goes further than just paraphrasing the issue \u2014 it surfaces the deeper\ + \ \"egg//work is never merged to main\" gap (line 35) which is a genuine\ + \ new insight the issue body did NOT spell out and which feeds Q1 of feedback-1.\ + \ The reproduction (lines 28\u201333) cites real refs (PR #2533 baseRefName/headRefName)\ + \ consistent with the issue's reproduction.\n\n**2. Research Quality** \u2014\ + \ Strong, with two minor citation inaccuracies (non-blocking).\n - VERIFIED:\ + \ `_commit_statefiles_to_worktree()` lives at `orchestrator/routes/pipelines.py:7179`\ + \ (signature confirmed), per the analysis's \"7179-7318\".\n - VERIFIED: `_run_one_slice_inner()`\ + \ at `:12405`, slice base resolution at `:12407-12410` (parent_branch = pipeline_branch\ + \ for slice-1, else `f\"{issue_branch}/{parent_slice_id}\"`).\n - VERIFIED:\ + \ `_resolve_extant_new_base()` at `orchestrator/stacked_pr_reconciler.py:87-132`\ + \ and the `pipeline_branch` fallback at line 132 \u2014 quoting the docstring\ + \ \"If the entire chain has been deleted, fall back to the pipeline branch\"\ + .\n - VERIFIED: `PRMetadata` at `shared/egg_contracts/models.py:371` (it actually\ + \ extends to ~409 with the `_coerce_legacy_deferred_actions` validator, but\ + \ 371-395 covers the field declarations as cited).\n - VERIFIED: 30 s reconciler\ + \ cadence at `stacked_pr_reconciler.py:10` (\"default 30 s, env var\").\n -\ + \ VERIFIED: gateway-enforced file boundaries (the constraint that coder/tester\ + \ cannot push under `.egg-state/brc-history/`) is consistent with how the orchestrator\ + \ authors statefile commits.\n - INACCURACY 1 (non-blocking): Line 21 cites\ + \ `_write_brc_history()` as \"lines 8110-8228\". The function actually starts\ + \ at `:8110` but the next def (`_rewrite_brc_history_for_pr`) is at `:8265`,\ + \ so the body is ~8110-8264, not 8228. The conclusion (BRC history is single-aggregate,\ + \ keyed by phase) is unaffected, but the upper bound should be tightened on\ + \ a future revision.\n - INACCURACY 2 (non-blocking): Line 20 says lines 5141-5203\ + \ \"fetch them from per-agent worktrees during phase transitions\". Reading\ + \ 5141-5203, the loop iterates `[(\"analysis\", \"-analysis.md\"), (\"plan\"\ + , \"-plan.md\")]` and reads via `_git_show_draft(repo_path, source_branch, expected_path)`\ + \ against `origin/{source_branch}` \u2014 that is a remote-branch read with\ + \ `git show` / `git ls-tree`, not a per-agent-worktree fetch. The mechanism\ + \ is \"read from the work branch on origin\", not \"fetch from per-agent worktrees\"\ + . The strategic conclusion (artifacts are committed to `egg//work` and read\ + \ back from there) is intact, but the implementation gloss is imprecise.\n\n\ + **3. Options Analysis** \u2014 Strong. Options A\u2013D (lines 60\u2013127)\ + \ are meaningfully different along the axes that matter (where docs land in\ + \ the diff, whether a new branch/PR is introduced, which slice carries the context).\ + \ Pros/cons articulate real trade-offs:\n - Option A correctly identifies the\ + \ need for a contract schema delta (`pr.context_branch` / `pr.context_pr_number`)\ + \ and the reconciler-fallback change.\n - Option B correctly identifies that\ + \ \"docs never reach main\" is unresolved.\n - Option C correctly identifies\ + \ the inverted reviewer flow (strategic context arrives only after N-1 code\ + \ slices are reviewed).\n - Option D correctly identifies that PR-body-only\ + \ is non-durable in `git log`.\n None of the options is a strawman; each represents\ + \ a genuine architectural position.\n\n**4. Constraints and Dependencies** \u2014\ + \ Strong. Lines 39\u201356 cover gateway enforcement, BRC aggregate-vs-per-slice\ + \ schema, reconciler invariants, contract model schema bump, HITL gate / merge\ + \ ordering interaction with `deferred_actions`, and reconciler latency. Adjacent\ + \ issues (#2534, #2541, #2543, #2354, #2538) are correctly cited and the partial-fix\ + \ status of #2534 is acknowledged (line 51).\n\n**5. Open Questions** \u2014\ + \ Strong, and verifiable.\n - 5 HITL decisions registered on contract (decision-1\ + \ through decision-5, all `resolved: false`). Each has 4\u20135 options including\ + \ \"Other (explain in reply)\".\n - 1 feedback object registered (feedback-1)\ + \ carrying Q1\u2013Q5 as open-ended questions.\n - Questions are specific and\ + \ actionable: each names the artifact under question (where docs live, how BRC\ + \ history is split, whether context PR goes through BRC, rollout scope, where\ + \ in the stack the context PR sits).\n - Q1 in particular (\"Is work-branch-as-base\ + \ intentional and out of scope here?\") is a critical scope question that the\ + \ analysis correctly elevates to HITL rather than silently assuming. Q4 surfaces\ + \ the gateway role-boundary concern which is a real constraint.\n\n**6. Recommendation\ + \ Quality** \u2014 Strong. Line 131 names Option A as recommended, conditional\ + \ on operator selection in decision-1, decision-3, decision-5. The four-point\ + \ rationale (lines 133\u2013139) is justified by the prior analysis (durability\ + \ on main, separation of concerns, bounded infrastructure cost, forward-compat\ + \ with #2534). Line 140 explicitly acknowledges Option B as an acceptable stepping\ + \ stone if the operator prefers a smaller blast radius \u2014 which is appropriate\ + \ given the issue is high-impact and the operator may want incremental rollout.\n\ + \n**7. HITL Decision Registration** \u2014 VERIFIED. Every prose-level question\ + \ in the analysis appears as a corresponding decision or feedback entry on the\ + \ contract. Cross-checked the question text in the analysis (line 148, 158,\ + \ 167, 176, 185 for decisions; lines 196\u2013200 for feedback) against `mcp__sdlc__show_contract`\ + \ output \u2014 match is 1:1.\n\n### Non-blocking\n- **2548-analysis.md:21**\ + \ \u2014 `_write_brc_history()` body is `:8110-:8264` (next `def` is at 8265),\ + \ not 8228. Tighten on future revision.\n- **2548-analysis.md:20** \u2014 \"\ + fetch them from per-agent worktrees during phase transitions\" misdescribes\ + \ the lines 5141-5203 mechanism. The actual code reads `origin/{source_branch}`\ + \ via `git show` / `git ls-tree`. Reword as e.g. \"are read back from the work\ + \ branch on origin during phase transitions\" to avoid implying worktree-local\ + \ I/O.\n- **2548-analysis.md:42** \u2014 The constraint says \"BRC history is\ + \ currently aggregate, not per-slice (`{id}-implement.{json,md}`). Per-slice\ + \ splitting requires a code change in `_write_brc_history()`...\". Worth noting\ + \ that the splitting also has to integrate with `_rewrite_brc_history_for_pr`\ + \ (called from `:16487`) which currently rebuilds a single aggregate file at\ + \ PR-creation time \u2014 that is the read-side counterpart and the analysis\ + \ already mentions it (line 42 cites `:8265-8328`); good. No change needed,\ + \ just confirming the surface area is captured.\n- **2548-analysis.md:35** \u2014\ + \ The \"deeper issue\" paragraph is excellent insight but ends without elevating\ + \ its own implication: if `egg//work` truly never reaches `main`, then Option\ + \ A (which retargets slice-1 to `egg//context` based on `main`) is doing\ + \ double duty \u2014 it solves the visibility problem AND silently fixes a separate\ + \ \"docs never merge to main\" gap. The plan phase will benefit if the recommendation\ + \ section makes that connection explicit so the planner doesn't accidentally\ + \ pick Option B without also opening a follow-up issue for the work\u2192main\ + \ gap.\n- **2548-analysis.md:62 (Option A)** \u2014 When describing slice-1's\ + \ new base, the option says \"Slice-1's integration branch is then created from\ + \ `egg//context`\". For the planner: the parent-branch-resolution code path\ + \ at `:12407-12410` (\"if parent_slice_id is None: parent_branch = pipeline_branch\"\ + ) would need to learn about a context branch in addition to the existing forest-root\ + \ case. Worth flagging in the plan-phase tasks but not blocking here.\n\nThe\ + \ analysis is approved for the plan phase. Recommendation is sound, options\ + \ are well-developed, HITL questions are specific and registered.\n" + ack_version: 1 + version: 1 +```` + +### [2026-05-07T17:54:27Z] orchestrator → refiner (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: 15e5fee0-2a46-4a +phase: refine +metadata: + ready_to_confirm: true + version: 1 +```` + +### [2026-05-07T17:54:27Z] refiner (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: a7d4b881-068a-45 +phase: refine +metadata: + state: WORKING +```` + +### [2026-05-07T17:54:31Z] refiner (CONSENSUS_CONFIRMED): Confirmed by refiner + +````yaml +id: f5f08f9d-3184-49 +phase: refine +metadata: + consensus_reached: false +```` + +### [2026-05-07T17:54:31Z] reviewer_agent_design (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 56a3249b-a292-4d +phase: refine +metadata: + state: WORKING +```` + +### [2026-05-07T17:54:33Z] reviewer_refine (CONSENSUS_CONFIRMED): Confirmed by reviewer_refine + +````yaml +id: f8f5da3e-aa1c-4b +phase: refine +metadata: + consensus_reached: true +```` + +### [2026-05-07T17:54:38Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 9afe557a-3056-45 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:54:38.344801+00:00' +```` + +### [2026-05-07T17:54:38Z] reviewer_agent_design (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 028939f7-694f-4a +phase: refine +metadata: + state: WORKING +```` + +### [2026-05-07T17:54:39Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 2b387014-df99-45 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:54:39.197307+00:00' +```` + +### [2026-05-07T17:54:40Z] refiner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 2d2742a8-ab7e-4c +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-05-07T17:54:40.015512+00:00' +```` diff --git a/.egg-state/contracts/issue-2474-v2.json b/.egg-state/contracts/issue-2474-v2.json new file mode 100644 index 0000000000..850b0e4829 --- /dev/null +++ b/.egg-state/contracts/issue-2474-v2.json @@ -0,0 +1,475 @@ +{ + "schemaVersion": "1.0", + "issue": { + "number": 2474, + "title": "Issue #2474", + "url": "https://github.com/jwbron/egg/issues/2474" + }, + "pipeline_id": "issue-2474-v2", + "current_phase": "refine", + "acceptance_criteria": [], + "slices": [ + { + "id": "slice-1", + "name": "Cleanup \u2014 k3s only, drop dead test tiers", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-1-1", + "description": "In `integration_tests/conftest.py` and `integration_tests/local_pipeline/conftest.py`,\nremove `_docker_egg_stack()` and the runtime-selection branch in\n`egg_stack`. Always call `_k8s_egg_stack()`; skip with clear\nmessage if `kubectl` unavailable. Remove `docker_available`\nimport and call sites. Drop stale docker-compose comments.\nFlip default `EGG_RUNTIME` from \"docker\" to \"kubernetes\" or remove.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "`grep -n \"EGG_RUNTIME=docker\\|_docker_egg_stack\\|docker_available\" integration_tests/conftest.py integration_tests/local_pipeline/conftest.py`\nreturns no hits.", + "files_affected": [ + "integration_tests/conftest.py", + "integration_tests/local_pipeline/conftest.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-1-2", + "description": "Delete entire `tests/functional/` directory (5 files). Remove\n`functional:` marker from `pyproject.toml`. Remove\n`tests/functional/conftest.py` and `integration_tests/docker-compose.yml`\nallowlist entries from `scripts/check-hardcoded-ports.py`.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "`tests/functional/` no longer exists. `make test-all` passes.\n`grep -rn \"tests.functional\\|@pytest.mark.functional\"` returns no hits.", + "files_affected": [ + "tests/functional/conftest.py", + "tests/functional/test_git_wrappers.py", + "tests/functional/test_network_modes.py", + "tests/functional/test_session_lifecycle.py", + "tests/functional/__init__.py", + "pyproject.toml", + "scripts/check-hardcoded-ports.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-1-3", + "description": "Delete `.github/workflows/test-e2e.yml`,\n`integration_tests/test_e2e_workflow.py`,\n`integration_tests/test_agent_security_fuzz.py`, and\n`integration_tests/agent_findings.py`. Remove `e2e` and\n`agent_flaky` markers from `pyproject.toml`. Remove `test-e2e:`\ntarget from `Makefile`. Update `test-integration:` docstring.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "The 4 files are gone. `make test-e2e` is no longer a valid target.\n`make help` does not advertise `test-e2e`. `make lint` and\n`make test-all` pass.", + "files_affected": [ + ".github/workflows/test-e2e.yml", + "integration_tests/test_e2e_workflow.py", + "integration_tests/test_agent_security_fuzz.py", + "integration_tests/agent_findings.py", + "pyproject.toml", + "Makefile" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-1-4", + "description": "In `integration_tests/conftest.py`, remove `run_claude_structured()`,\n`assert_agent_verdict()`, the `infrastructure_failure` field on\n`AgentVerdict` dataclass, and orphan helpers used only by those.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "`grep -rn \"run_claude_structured\\|assert_agent_verdict\"` returns no hits.\n`make test-integration` and `make test-all` pass.", + "files_affected": [ + "integration_tests/conftest.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + }, + { + "id": "slice-2", + "name": "Promote ScriptedProvider to public testing API", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-2-1", + "description": "Create `shared/egg_harness/testing/__init__.py` and\n`shared/egg_harness/testing/scripted_provider.py` containing the\nclass verbatim plus `_stream_events`.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "`python -c \"from shared.egg_harness.testing import ScriptedProvider; print(ScriptedProvider.__name__)\"`\nprints `ScriptedProvider`. `make lint` passes.", + "files_affected": [ + "shared/egg_harness/testing/__init__.py", + "shared/egg_harness/testing/scripted_provider.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-2-2", + "description": "In `shared/tests/test_egg_harness/test_integration.py`, replace\ninline ScriptedProvider class with re-export shim. Keep five call\nsites resolvable. `RecordingRegistry` stays inline.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "File no longer contains `class ScriptedProvider` or `_stream_events`\ndefinitions. `make test` passes.", + "files_affected": [ + "shared/tests/test_egg_harness/test_integration.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-2-3", + "description": "Add `shared/tests/test_egg_harness/test_scripted_provider.py` with\ntwo tests: import works, public API surface matches.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "New test passes. Removing `scripted_provider.py` causes ImportError.", + "files_affected": [ + "shared/tests/test_egg_harness/test_scripted_provider.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [ + "slice-1" + ], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + }, + { + "id": "slice-3", + "name": "Add k3s integration tests for recent regressions and invariants", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-3-1", + "description": "Create `integration_tests/regression/__init__.py` and `conftest.py`.\nConftest re-exports parent k8s fixtures and adds `start_pipeline()`\nhelper returning deterministic pipeline_id from test nodeid.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "`make test-integration -m integration` includes new dir, \"0 errors\" on collection.", + "files_affected": [ + "integration_tests/regression/__init__.py", + "integration_tests/regression/conftest.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-2", + "description": "Add `test_slice_branch_env.py` covering #2428. Spin 2-slice DAG;\nassert each slice coder pod's `EGG_BRANCH` matches its slice ref\nvia `kubectl get pod -o jsonpath`.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "Test passes on `main`. Reverting #2428 fix causes failure with clear assertion.", + "files_affected": [ + "integration_tests/regression/test_slice_branch_env.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-3", + "description": "Add `test_unpushed_commit_salvage.py` covering #2429. Trigger\ngateway push rejection by attempting push outside role allowlist\n(no test backdoor). Assert recovery branch ref appears.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "Test passes on `main`. Reverting salvage code causes \"recovery ref not found\".", + "files_affected": [ + "integration_tests/regression/test_unpushed_commit_salvage.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-4", + "description": "Add `test_live_pod_guard.py` covering #2420. Start pipeline, wait\nfor slice pods Running, call `start_pipeline` again WITHOUT force=true;\nassert refused. Retry with force=true; assert new pipeline replaces old.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "Test passes on `main`. Reverting #2420 makes second start succeed.", + "files_affected": [ + "integration_tests/regression/test_live_pod_guard.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-5", + "description": "Add `test_hitl_round_trip.py` covering #2430. Drive refine pipeline\nthat registers HITL decision; observe AWAITING_HUMAN; call provide_input;\nassert pipeline resumes. Use `ScriptedProvider`.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "Test passes on `main`. Reverting alive-signal bypass causes timeout.", + "files_affected": [ + "integration_tests/regression/test_hitl_round_trip.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-6", + "description": "Add `test_brc_single_cycle.py` (BRC happy path: PROPOSE \u2192 ACK \u2192\nCONFIRMED, exact counts) and `test_slice_dag_restart.py` (3-slice\nDAG, mid-flight restart_agent, assert slice-2 branch unchanged).", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "Both tests pass on `main`. BRC test asserts exact counts.", + "files_affected": [ + "integration_tests/regression/test_brc_single_cycle.py", + "integration_tests/regression/test_slice_dag_restart.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-7", + "description": "Add `test_phase_aware_timeout.py`. Configure\n`phase_configs.plan.consensus_timeout_s = 30`; have planner not\npropose; assert `CONSENSUS_TIMEOUT` event lands within 30\u00b15s;\nassert other phases unaffected.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "Test passes on `main`. Setting timeout to 600 fails deadline assertion.", + "files_affected": [ + "integration_tests/regression/test_phase_aware_timeout.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-8", + "description": "Add `test_babysit_pr_single_push.py`. Drive babysit-PR across 2\ncoder revisions. Query gateway audit log for pushes to PR head ref;\nassert exactly 1 successful push.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "Test passes on `main`. If regression makes coder push twice, test fails.", + "files_affected": [ + "integration_tests/regression/test_babysit_pr_single_push.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [ + "slice-2" + ], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + }, + { + "id": "slice-4", + "name": "Wire integration tests into PR CI", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-4-1", + "description": "In `.github/workflows/test.yml`, add `integration:` job (sibling\nof `unit:` and `security:`) that uses `test-integration.yml`.\nInclude in `aggregate:` job's `needs:` list. Add\n`timeout-minutes: 30`. Add `concurrency:` block to\n`test-integration.yml` mirroring `test.yml`.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "PR shows new `Test / integration` check and updated\n`Test / aggregate` check that depends on it. Both run within\n30-min timeout. `make lint` passes.", + "files_affected": [ + ".github/workflows/test.yml", + ".github/workflows/test-integration.yml" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-4-2", + "description": "In `.github/workflows/test-integration.yml`, add flake-guard steps:\nretry \"Import images into k3s\" once on failure; explicit\n`kubectl wait --for=condition=Available deployment/egg-orchestrator --timeout=120s`.\nAdd per-step `timeout-minutes:`. On failure, capture k3s logs as artifact.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "PR runs integration job to green. Image-import flake recovered by retry.\nOn forced failure, uploads `k3s-debug.log` artifact.", + "files_affected": [ + ".github/workflows/test-integration.yml" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [ + "slice-3" + ], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + }, + { + "id": "slice-5", + "name": "Documentation \u2014 point agents at the integration tier", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-5-1", + "description": "In `CLAUDE.md`, add Quick Reference bullet:\n`make test-integration # Cross-module regressions; requires k3s (see docs/guides/testing.md)`.\nAdd new section after \"Key Entry Points\" titled \"Integration tests\"\nwith paragraph pointing at `integration_tests/regression/` for\ncross-module bugs. Update Repo Layout row.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "`CLAUDE.md` contains new bullet, new section, and updated Repo Layout row.\n`make lint` passes.", + "files_affected": [ + "CLAUDE.md" + ], + "role": "documenter", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-5-2", + "description": "In `docs/guides/testing.md`, add \"Integration tests\" section with\nthree subsections:\n1. **What it covers** \u2014 k3s + mocked LLMs.\n2. **Running locally** \u2014 k3s-on-host recipe ONLY. Do NOT document\n kind or minikube as alternatives. Mention macOS users need a\n Linux VM. Mention required-check name `Test / aggregate`.\n3. **CI gating** \u2014 integration tier runs on every PR. `make test-all`\n remains unit-only.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "New section with three subsections. NO mention of kind or minikube\nas alternative local-dev runtimes. `make lint` passes.", + "files_affected": [ + "docs/guides/testing.md" + ], + "role": "documenter", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-5-3", + "description": "Clean up stale references in adjacent docs:\n- `docs/architecture/kubernetes-migration.md`: mark\n `integration_tests/docker-compose.yml` row as historical with\n \"(docker path retired in #2474)\" annotation.\n- `docs/development/STRUCTURE.md`: remove `test_e2e_workflow.py`\n entry; remove or annotate `docker-compose.yml` entries.\nDo NOT delete historical sections \u2014 only annotate retired artifacts.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "`grep -n \"test_e2e_workflow\" docs/development/STRUCTURE.md` returns no live references.\n`kubernetes-migration.md` mentions #2474 next to retired entries. `make lint` passes.", + "files_affected": [ + "docs/architecture/kubernetes-migration.md", + "docs/development/STRUCTURE.md" + ], + "role": "documenter", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [ + "slice-4" + ], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + } + ], + "decisions": [], + "workflow_owner": null, + "audit_log": [], + "refine_review_cycles": 0, + "refine_review_feedback": "", + "plan_review_cycles": 0, + "plan_review_feedback": "", + "pr": { + "title": "Wire integration tests into PR CI; retire dead tiers; expand coverage", + "description": "Wire `test-integration.yml` into PR CI; retire dead test tiers\n(Docker-compose runtime, tests/functional/, real-LLM e2e); expand\nintegration coverage with 8 new k3s scenarios; document agent\nguidance. Multi-phase / multi-PR delivery via 5-slice stacked train.", + "test_plan": "- Automated:\n * `make test-all` continues to pass on every slice.\n * `make lint` continues to pass.\n * `make test-integration` passes locally on k3s after slice 1 and after slice 3.\n * Slice 4 onwards: GitHub Actions integration job runs on each slice PR.\n- Manual:\n * Confirm new `Integration Tests / aggregate` check appears on a sample PR after slice 4.\n * Reviewer follows `docs/guides/testing.md` k3s-on-host recipe on a fresh laptop.", + "manual_steps": "Pre-merge (slice 4): trigger `test-integration.yml` via workflow_dispatch on slice-4 branch; confirm green and within budget.\nPost-merge (slice 4 + 5): maintainer flips `Test / aggregate` to required in branch protection; close #2449 if absorbed.", + "deferred_actions": [] + }, + "feedback": null, + "phase_configs": null, + "agent_executions": [] +} diff --git a/.egg-state/contracts/issue-2548.json b/.egg-state/contracts/issue-2548.json new file mode 100644 index 0000000000..d10b296e1e --- /dev/null +++ b/.egg-state/contracts/issue-2548.json @@ -0,0 +1,877 @@ +{ + "schemaVersion": "1.0", + "issue": { + "number": 2548, + "title": "Issue #2548", + "url": "https://github.com/jwbron/egg/issues/2548" + }, + "pipeline_id": "issue-2548", + "current_phase": "refine", + "acceptance_criteria": [], + "slices": [ + { + "id": "slice-1", + "name": "Contract schema delta + planner prompt", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-1-1", + "description": "Extend `PRMetadata` in `shared/egg_contracts/models.py` with four\nnew optional fields:\n- `context_title: str | None = None` \u2014 title for the context PR\n (the planner-emitted \"Strategic plan for #N\" framing).\n- `context_description: str | None = None` \u2014 body for the context\n PR.\n- `context_branch: str | None = None` \u2014 branch name `egg//context`\n once the orchestrator has created it. Persisted by slice-3.\n- `context_pr_number: int | None = None` \u2014 GitHub PR number once the\n context PR has been opened. Persisted by slice-3.\nBump the contract `schemaVersion` default from `\"1.0\"` to `\"1.1\"`\n(in the `EggContract` root model). Add a model-level migration so\ncontracts loaded with `schemaVersion=\"1.0\"` still parse cleanly with\nthe new optional fields defaulted to `None`.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- `from shared.egg_contracts.models import PRMetadata` exposes the\n four new fields with `None` defaults.\n- Loading a 1.0 contract round-trips through the new model with the\n new fields defaulted to `None`.\n- `make lint` and `make test` (changeset-aware) pass.", + "files_affected": [ + "shared/egg_contracts/models.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-1-2", + "description": "Add unit tests under `shared/egg_contracts/tests/` covering the new\n`PRMetadata.context_*` fields:\n- Round-trip a `PRMetadata` with all four context fields populated.\n- Round-trip a `PRMetadata` with all four context fields omitted\n (must default to `None`).\n- Round-trip a contract serialised with `schemaVersion=\"1.0\"` and\n no context fields, and confirm migration to `1.1` populates the\n defaults.\n- Confirm `context_pr_number=0` and negative values are rejected if\n we add a `ge=1` validator (apply a sensible validator).", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- The new tests pass under `make test`.\n- Coverage on the new code paths is non-zero.", + "files_affected": [ + "shared/egg_contracts/tests/test_pr_metadata.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-1-3", + "description": "Update the task_planner prompt in `orchestrator/routes/pipelines.py`\n(block starting at line ~11046, anchor \"Decompose the architecture\nanalysis into a single-PR implementation plan.\") so the YAML\nexample and prose:\n- Document the new `pr.context_title` and `pr.context_description`\n fields and recommend when to use them (\"framing for the strategic\n plan PR; defaults to `pr.title` / `pr.description` if omitted\").\n- Update the `# yaml-tasks` example to include `context_title:` and\n `context_description:` block scalars.\n- Note that `pr.context_branch` and `pr.context_pr_number` are\n populated by the orchestrator (not the planner) and should NOT be\n emitted by the planner.\nAlso update the YAML ingestion path in\n`.github/scripts/checks/plan_yaml_check.py` and any matching\ningestion under `orchestrator/routes/phases.py` (search for\n`yaml-tasks` in pipelines.py / phases.py per the survey) so the\nnew keys are accepted (but optional).", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- A planner-emitted YAML containing `context_title:` and\n `context_description:` is parsed without error and the values\n land on `contract.pr.context_title` / `pr.context_description`.\n- A planner-emitted YAML omitting the new keys still parses and\n both context fields default to `None`.\n- `plan_yaml_check.py` runs cleanly on both inputs.", + "files_affected": [ + "orchestrator/routes/pipelines.py", + "orchestrator/routes/phases.py", + ".github/scripts/checks/plan_yaml_check.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + }, + { + "id": "slice-2", + "name": "Per-slice implement-phase BRC history", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-2-1", + "description": "Refactor `_write_brc_history()` (`orchestrator/routes/pipelines.py`\n~line 8110-8228) so that when `phase == \"implement\"` and a slice\ncontext is in scope, it writes\n`.egg-state/brc-history/-implement-slice-.{json,md}`\ninstead of `-implement.{json,md}`. Implement-phase BRC\nmessages are partitioned by their slice scope (the orchestrator\nattaches a `slice_id` to each implement-phase BRC message; if the\nmessage lacks a slice_id, log a warning and skip it \u2014 the\npartitioning is mandatory under D4).\nRefine, plan, and pr phases continue to write the aggregate\n`-{phase}.{json,md}` filename \u2014 only implement is\nper-slice.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- For a pipeline with N slices, `_write_brc_history(phase=\"implement\")`\n produces N files named `-implement-slice-1.{json,md}` \u2026\n `-implement-slice-N.{json,md}` and zero\n `-implement.{json,md}` files.\n- For phases other than implement, behavior is unchanged.\n- `make lint` passes.", + "files_affected": [ + "orchestrator/routes/pipelines.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-2-2", + "description": "Update `_rewrite_brc_history_for_pr()` (lines ~8265-8330) and\n`_persist_phase_brc_history()` (~8355-8392) callers so that:\n- Implement-phase persistence iterates all known slices and persists\n one file per slice.\n- The PR-rewrite path (used by babysit_pr) finds and rewrites the\n per-slice files instead of the aggregate file.\n- Any callers that previously read `-implement.{json,md}` are\n updated to enumerate the per-slice files (search for the\n `-implement.json` and `-implement.md` literals).", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- `grep -n \"-implement\\\\.\\\\(json\\\\|md\\\\)\" orchestrator/` (or\n equivalent) returns no remaining direct references to the\n aggregate file in production code paths.\n- `_persist_phase_brc_history()` and `_rewrite_brc_history_for_pr()`\n still complete cleanly and idempotently for refine/plan/pr phases.", + "files_affected": [ + "orchestrator/routes/pipelines.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-2-3", + "description": "Add and update tests in `orchestrator/tests/test_brc_history.py`\n(and `test_brc_history_identifier_babysit_pr.py` if relevant) that:\n- Exercise the per-slice implement-phase writer end-to-end.\n- Assert no aggregate `-implement.{json,md}` file is produced\n for an implement-phase pipeline run with multiple slices.\n- Cover the case where an implement-phase BRC message lacks a\n slice_id (writer logs a warning and skips it).\n- Existing tests that asserted on the aggregate filename are\n rewritten to assert on the per-slice filenames (hard switchover \u2014\n no aggregate-file test path remains).", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- `make test` (changeset-aware) green on the BRC-history test\n modules.\n- The aggregate-file assertion no longer appears in the test suite.", + "files_affected": [ + "orchestrator/tests/test_brc_history.py", + "orchestrator/tests/test_brc_history_identifier_babysit_pr.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [ + "slice-1" + ], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + }, + { + "id": "slice-3", + "name": "Context branch + doc-only context PR", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-3-1", + "description": "Add a new gateway primitive `create_context_branch()` to\n`orchestrator/gateway_client.py` modeled on\n`create_slice_integration_branch()` (lines 1696-1760). It must:\n- Resolve the parent SHA via `git ls-remote refs/heads/`.\n- Create a remote branch `egg//context` pointing at\n that SHA via a synthetic gateway session push.\n- Be idempotent: if the branch already exists at the same SHA,\n return success; if it exists at a different SHA, raise.\n- Use `pipeline.base_branch` (NOT a hardcoded `main`) as the parent\n ref.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- Calling `create_context_branch(pipeline_id, base_branch=\"main\")`\n on a clean test fixture produces an `egg//context`\n ref pointing at the same SHA as `main`.\n- Calling it twice in a row is idempotent.\n- Calling it when the branch exists at a different SHA raises.", + "files_affected": [ + "orchestrator/gateway_client.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-2", + "description": "Add an orchestrator hook that runs **after plan_gate approval and\nbefore slice-1 provisioning**. It must:\n1. Call `create_context_branch(pipeline_id, pipeline.base_branch)`.\n2. Check out the context branch into a temporary worktree.\n3. Copy the following files from the pipeline work branch onto the\n context worktree:\n - `.egg-state/drafts/-analysis.md`\n - `.egg-state/drafts/-plan.md`\n - `.egg-state/brc-history/-refine.json`\n - `.egg-state/brc-history/-refine.md`\n - `.egg-state/brc-history/-plan.json`\n - `.egg-state/brc-history/-plan.md`\n - All `.egg-state/agent-outputs/-refine-*.{md,json}`\n - All `.egg-state/agent-outputs/-plan-*.{md,json}`\n4. Commit (orchestrator-authored, `--no-verify`) and push using the\n same primitive `_commit_statefiles_to_worktree()` follows.\n5. Open a PR with `base = pipeline.base_branch`, `head =\n egg//context`, `title = contract.pr.context_title or\n contract.pr.title`, `body = contract.pr.context_description or\n contract.pr.description`. PR is opened **doc-only auto-open**:\n the orchestrator does not block on its merge before slicing\n (D3).\n6. Persist the branch name and PR number on\n `contract.pr.context_branch` and `contract.pr.context_pr_number`\n via the standard contract-write path.\nThe hook MUST guard against double-opening (idempotent on retry).", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- On a fresh pipeline, after plan_gate approves, the context PR is\n opened against the configured base branch and `contract.pr.{context_branch,context_pr_number}`\n are populated.\n- The PR diff contains analysis.md, plan.md, refine/plan BRC\n files, and refine/plan agent transcripts.\n- Re-running the hook is a no-op (idempotent).\n- Slice-1 provisioning is **not** blocked on context-PR merge (D3).", + "files_affected": [ + "orchestrator/routes/pipelines.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-3-3", + "description": "Add tests covering the new gateway primitive and orchestrator hook:\n- `orchestrator/tests/test_create_context_branch.py` (new) \u2014\n primitive-level tests: idempotency, branch-already-exists-at-different-SHA\n error, base_branch is honored (not hardcoded main).\n- `orchestrator/tests/test_context_pr.py` (new) \u2014 orchestrator\n hook tests using existing pipeline-fixture infra: assert the PR\n is opened with the right base/head/title/body, the diff contains\n the expected files, and `contract.pr.{context_branch,context_pr_number}`\n are persisted.\n- Integration-style test that the slice-1 spawn that follows does\n **not** block on context-PR merge (D3).", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- All new tests pass under `make test`.\n- Test files do not introduce any read of an aggregate\n `-implement.{json,md}` file.", + "files_affected": [ + "orchestrator/tests/test_create_context_branch.py", + "orchestrator/tests/test_context_pr.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [ + "slice-2" + ], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + }, + { + "id": "slice-4", + "name": "Slice-1 base wiring + per-slice BRC commit + reconciler fallback", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-4-1", + "description": "In `_run_one_slice_inner()` (`orchestrator/routes/pipelines.py`\n~line 12405-12454), change the slice-1 (root) `parent_branch`\nresolution: instead of `parent_branch = pipeline_branch`, use\n`parent_branch = contract.pr.context_branch or pipeline_branch`\n(the `or pipeline_branch` is a defensive fallback only \u2014 under D4\nthe context branch must always be present by the time slice-1\nprovisions; log a warning if the fallback fires).\nSlice-N>1 logic is unchanged (`f\"{issue_branch}/{parent_slice_id}\"`).", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- With a populated `contract.pr.context_branch`, slice-1's\n integration branch is created from that branch (verifiable via\n `git merge-base` in a fixture).\n- With the field absent, the warning fires and slice-1 falls back\n to `pipeline_branch` for legacy fixtures (covered by a single\n regression test).", + "files_affected": [ + "orchestrator/routes/pipelines.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-4-2", + "description": "After each slice's implement-phase BRC reaches consensus and before\n`create_slice_pr()` is called (~line 12631-12648), commit the\nslice's `.egg-state/brc-history/-implement-slice-.{json,md}`\nfiles onto the slice's integration branch as a final\norchestrator-authored commit. Reuse `_commit_statefiles_to_worktree()`\n(lines ~7179-7318), narrowing the file glob to the per-slice files\nvia the existing `pipeline_identifier`-scoped pattern.\nThe commit must be:\n- Orchestrator-authored (default committer; matches existing\n `_commit_statefiles_to_worktree()` semantics, addressing Q4).\n- Idempotent (re-running mid-flight produces no new commit if the\n files match HEAD).\n- Pushed to the slice integration branch before the slice PR is\n opened so the BRC files are part of the PR's diff.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- On a multi-slice pipeline, each slice PR's \"Files changed\" tab\n includes its own `-implement-slice-.{json,md}` files.\n- No slice PR contains another slice's BRC files.\n- Re-running the per-slice commit step is a no-op when the files\n already match.", + "files_affected": [ + "orchestrator/routes/pipelines.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-4-3", + "description": "Update `_resolve_extant_new_base()` in\n`orchestrator/stacked_pr_reconciler.py` (~line 87-132) so the\nlast-resort fallback prefers `contract.pr.context_branch` when set\nand falls back to `pipeline_branch` only if the context branch is\nabsent or has been deleted. The new ordering:\n 1. Walk slice DAG via `dependencies[0]` until extant branch found.\n 2. If the chain is exhausted, return `contract.pr.context_branch`\n when present and extant on the remote.\n 3. Final fallback: `pipeline_branch` (current behavior).\nDocument the new ordering in a code comment.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- With `contract.pr.context_branch` populated and extant, the\n reconciler returns it as the orphan-rebase target.\n- With the context branch missing, the reconciler falls back to\n `pipeline_branch` (regression test).", + "files_affected": [ + "orchestrator/stacked_pr_reconciler.py" + ], + "role": "coder", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + }, + { + "id": "task-4-4", + "description": "Add and update tests covering the slice-1 base rewiring, per-slice\nBRC commit, and reconciler fallback:\n- `orchestrator/tests/test_pipeline_*.py` \u2014 slice-1 base resolution\n asserts `parent_branch == egg//context` when the context\n branch is set.\n- `orchestrator/tests/test_stacked_pr_reconciler.py` \u2014 fallback\n prefers `egg//context` over `pipeline_branch`; missing-context\n regression covered.\n- `orchestrator/tests/test_brc_history.py` (or a new\n `test_per_slice_brc_commit.py`) \u2014 integration-style: the per-slice\n BRC files land on the slice integration branch before the slice\n PR is opened.\n- End-to-end smoke test (existing `test_auto_pr.py` or equivalent)\n verifies a multi-slice pipeline produces:\n * 1 context PR with refine/plan artifacts\n * N slice PRs each with their own `-implement-slice-.{json,md}`\n * No aggregate `-implement.{json,md}` file anywhere\n * Slice-1 base = `egg//context`\n * Slice-N>1 base = `egg//slice-` (unchanged)", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- All new and updated tests pass under `make test-all` on this\n terminal-code slice.", + "files_affected": [ + "orchestrator/tests/test_stacked_pr_reconciler.py", + "orchestrator/tests/test_per_slice_brc_commit.py" + ], + "role": "tester", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [ + "slice-3" + ], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + }, + { + "id": "slice-5", + "name": "Documentation", + "status": "pending", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "escalation_reason": null, + "tasks": [ + { + "id": "task-5-1", + "description": "Update the following docs to describe the context PR, the\nper-slice implement BRC layout, and slice-1's new base resolution:\n- `docs/guides/concurrent-execution.md` \u2014 PR-stack diagram + an\n explicit \"Context PR is opened first\" subsection.\n- `docs/architecture/orchestrator.md` \u2014 slice-DAG diagram updated\n to show context branch as the new root, BRC-history file naming\n section updated to call out the per-slice implement files.\n- `docs/reference/orchestrator-cli.md` \u2014 if any `egg-orch` commands\n surface the context PR (e.g. status output), document the field.\n- `docs/guides/babysit-pr.md` \u2014 note that babysit_pr now reads\n per-slice implement BRC files, not the aggregate file.\nCross-reference issue #2548 in each affected doc.", + "status": "pending", + "commit": null, + "checkpoint_id": null, + "notes": "", + "acceptance_criteria": "- All four docs render cleanly (`make lint` includes markdown\n checks).\n- Search for the literal string `-implement.json` (or `.md`)\n in the docs: zero remaining matches outside changelog/historical\n references.", + "files_affected": [ + "docs/guides/concurrent-execution.md", + "docs/architecture/orchestrator.md", + "docs/reference/orchestrator-cli.md", + "docs/guides/babysit-pr.md" + ], + "role": "documenter", + "review_cycles": 0, + "max_cycles": 3, + "escalated": false, + "gaps": [] + } + ], + "dependencies": [ + "slice-4" + ], + "serialized_chain_order": [], + "parent_branch_at_creation": null, + "commit": null, + "review_feedback": [] + } + ], + "decisions": [ + { + "id": "decision-1", + "question": "Where should refine/plan analysis docs and BRC consensus history live so they are reviewable on PRs targeting main?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Dedicated context PR (new egg//context branch based on main; slice-1 stacks on top of it)", + "description": null + }, + { + "id": "opt-2", + "label": "Embed in slice-1's diff (commit analysis.md/plan.md/refine+plan BRC history to slice-1 integration branch on top of egg//work)", + "description": null + }, + { + "id": "opt-3", + "label": "Embed in terminal slice's diff (terminal slice already carries the program narrative; co-locate the docs there)", + "description": null + }, + { + "id": "opt-4", + "label": "All slices carry a snapshot of analysis/plan as part of the slice integration branch", + "description": null + }, + { + "id": "opt-5", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Dedicated context PR (new egg//context branch based on main; slice-1 stacks on top of it)\"}", + "resolved_by": "human", + "resolved_at": "2026-05-07T18:15:45.073982Z", + "debounce_until": null + }, + { + "id": "decision-2", + "question": "How should the implement-phase BRC consensus history be split so each slice PR carries its own slice's history?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Split file at write time: orchestrator writes to .egg-state/brc-history/-implement-slice-.{json,md} (one per slice; no aggregate file)", + "description": null + }, + { + "id": "opt-2", + "label": "Keep single .egg-state/brc-history/-implement.{json,md} but also write per-slice files for slice PR diffs", + "description": null + }, + { + "id": "opt-3", + "label": "Keep single file unchanged; rely on a per-slice 'view' rendered into the slice PR body (no per-slice .json/.md committed)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Split file at write time: orchestrator writes to .egg-state/brc-history/-implement-slice-.{json,md} (one per slice; no aggregate file)\"}", + "resolved_by": "human", + "resolved_at": "2026-05-07T18:15:45.086730Z", + "debounce_until": null + }, + { + "id": "decision-3", + "question": "Should the new context PR go through BRC review (reviewer_refine + reviewer_agent_design + reviewer_plan), or land as a doc-only PR auto-merged after plan_gate approval?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "BRC-reviewed (treated like any other producer PR; reviewers ACK the docs PR before slice-1 spawns)", + "description": null + }, + { + "id": "opt-2", + "label": "Doc-only auto-open (orchestrator opens; humans review on the PR; pipeline does not block on its merge before slicing)", + "description": null + }, + { + "id": "opt-3", + "label": "Doc-only with merge gate (pipeline blocks slicing until human merges the context PR)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Doc-only auto-open (orchestrator opens; humans review on the PR; pipeline does not block on its merge before slicing)\"}", + "resolved_by": "human", + "resolved_at": "2026-05-07T18:15:45.099743Z", + "debounce_until": null + }, + { + "id": "decision-4", + "question": "Rollout scope: which pipelines should the context-PR / per-slice BRC mechanism apply to?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Only new pipelines started after the change lands", + "description": null + }, + { + "id": "opt-2", + "label": "New pipelines + retroactively backfill in-flight pipelines (e.g. issue-2474-v2) by opening a context PR mid-stream", + "description": null + }, + { + "id": "opt-3", + "label": "New pipelines + provide a one-shot 'egg-contract emit-context-pr' CLI for operators to backfill on demand", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"no backfill/backwards compat. Hard switchover\"}", + "resolved_by": "human", + "resolved_at": "2026-05-07T18:16:34.966742Z", + "debounce_until": null + }, + { + "id": "decision-5", + "question": "Where in the stack should the context PR sit (and what should slice-1's PR base be)?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Context PR base=main, slice-1 base=egg//context (slice-1 stacks on context; context merges first to main, then slices cascade-merge)", + "description": null + }, + { + "id": "opt-2", + "label": "Context PR base=main, slice-1 base=egg//work (context PR is a side-channel docs PR; slice stack is unchanged)", + "description": null + }, + { + "id": "opt-3", + "label": "No context PR; instead retarget egg//work itself to be the merge target on main (slice-N terminal merges into work, then a final 'merge work to main' PR is opened automatically)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Option 1 with base= (not hardcoded main): Context PR base=, slice-1 base=egg//context. Slice-1 stacks on context; context merges first, then slices cascade-merge.\"}", + "resolved_by": "human", + "resolved_at": "2026-05-07T18:26:47.376438Z", + "debounce_until": null + }, + { + "id": "decision-6", + "question": "3 container(s) exited with non-zero code; consensus incomplete; agents never confirmed: refiner, reviewer_agent_design, reviewer_refine. Committed work is preserved on the per-role branch \u2014 'Retry phase' restarts with artifacts intact. How to proceed?", + "type": "hitl", + "phase": null, + "options": [ + { + "id": "opt-1", + "label": "Retry phase", + "description": null + }, + { + "id": "opt-2", + "label": "Accept current state", + "description": null + }, + { + "id": "opt-3", + "label": "Abort phase", + "description": null + } + ], + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Retry phase\"}", + "resolved_by": "human", + "resolved_at": "2026-05-07T17:40:20.736368Z", + "debounce_until": null + }, + { + "id": "decision-7", + "question": "Open feedback request feedback-1", + "type": "hitl", + "phase": null, + "options": [], + "resolved": true, + "resolution": "{\"action\": \"submit_feedback\", \"answers\": {\"Q1\": \"In scope \u2014 fix as part of #2548. The work\u2192main gap is the deeper problem; closing it via the context PR mechanism is on-scope.\", \"Q2\": \"Add separate contract.pr.context_title / pr.context_description fields. Lets context framing differ from slice framing.\", \"Q3\": \"Include agent transcripts as well (.egg-state/agent-outputs/-refine-*.md and similar). Maximum transparency.\", \"Q4\": \"Orchestrator-authored. Matches existing _commit_statefiles_to_worktree pattern; coder/tester gateway boundaries forbid them from pushing under .egg-state/brc-history/ anyway.\", \"Q5\": \"Per-slice BRC only \u2014 each slice PR carries its own; no cross-slice aggregate file (consistent with decision-4).\"}}", + "resolved_by": "human", + "resolved_at": "2026-05-07T18:26:48.933152Z", + "debounce_until": null + } + ], + "workflow_owner": null, + "audit_log": [ + { + "timestamp": "2026-05-07T17:47:59.031379Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.0", + "old_value": null, + "new_value": { + "id": "decision-1", + "question": "Where should refine/plan analysis docs and BRC consensus history live so they are reviewable on PRs targeting main?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Dedicated context PR (new egg//context branch based on main; slice-1 stacks on top of it)", + "description": null + }, + { + "id": "opt-2", + "label": "Embed in slice-1's diff (commit analysis.md/plan.md/refine+plan BRC history to slice-1 integration branch on top of egg//work)", + "description": null + }, + { + "id": "opt-3", + "label": "Embed in terminal slice's diff (terminal slice already carries the program narrative; co-locate the docs there)", + "description": null + }, + { + "id": "opt-4", + "label": "All slices carry a snapshot of analysis/plan as part of the slice integration branch", + "description": null + }, + { + "id": "opt-5", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Where should refine/plan analysis docs and BRC con...", + "checkpoint_id": null + }, + { + "timestamp": "2026-05-07T17:48:12.267469Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.1", + "old_value": null, + "new_value": { + "id": "decision-2", + "question": "How should the implement-phase BRC consensus history be split so each slice PR carries its own slice's history?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Split file at write time: orchestrator writes to .egg-state/brc-history/-implement-slice-.{json,md} (one per slice; no aggregate file)", + "description": null + }, + { + "id": "opt-2", + "label": "Keep single .egg-state/brc-history/-implement.{json,md} but also write per-slice files for slice PR diffs", + "description": null + }, + { + "id": "opt-3", + "label": "Keep single file unchanged; rely on a per-slice 'view' rendered into the slice PR body (no per-slice .json/.md committed)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: How should the implement-phase BRC consensus histo...", + "checkpoint_id": null + }, + { + "timestamp": "2026-05-07T17:48:12.809253Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.2", + "old_value": null, + "new_value": { + "id": "decision-3", + "question": "Should the new context PR go through BRC review (reviewer_refine + reviewer_agent_design + reviewer_plan), or land as a doc-only PR auto-merged after plan_gate approval?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "BRC-reviewed (treated like any other producer PR; reviewers ACK the docs PR before slice-1 spawns)", + "description": null + }, + { + "id": "opt-2", + "label": "Doc-only auto-open (orchestrator opens; humans review on the PR; pipeline does not block on its merge before slicing)", + "description": null + }, + { + "id": "opt-3", + "label": "Doc-only with merge gate (pipeline blocks slicing until human merges the context PR)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Should the new context PR go through BRC review (r...", + "checkpoint_id": null + }, + { + "timestamp": "2026-05-07T17:48:13.294096Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.3", + "old_value": null, + "new_value": { + "id": "decision-4", + "question": "Rollout scope: which pipelines should the context-PR / per-slice BRC mechanism apply to?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Only new pipelines started after the change lands", + "description": null + }, + { + "id": "opt-2", + "label": "New pipelines + retroactively backfill in-flight pipelines (e.g. issue-2474-v2) by opening a context PR mid-stream", + "description": null + }, + { + "id": "opt-3", + "label": "New pipelines + provide a one-shot 'egg-contract emit-context-pr' CLI for operators to backfill on demand", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Rollout scope: which pipelines should the context-...", + "checkpoint_id": null + }, + { + "timestamp": "2026-05-07T17:48:31.613417Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.4", + "old_value": null, + "new_value": { + "id": "decision-5", + "question": "Where in the stack should the context PR sit (and what should slice-1's PR base be)?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Context PR base=main, slice-1 base=egg//context (slice-1 stacks on context; context merges first to main, then slices cascade-merge)", + "description": null + }, + { + "id": "opt-2", + "label": "Context PR base=main, slice-1 base=egg//work (context PR is a side-channel docs PR; slice stack is unchanged)", + "description": null + }, + { + "id": "opt-3", + "label": "No context PR; instead retarget egg//work itself to be the merge target on main (slice-N terminal merges into work, then a final 'merge work to main' PR is opened automatically)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Where in the stack should the context PR sit (and ...", + "checkpoint_id": null + }, + { + "timestamp": "2026-05-07T17:48:32.086509Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "feedback", + "old_value": null, + "new_value": { + "id": "feedback-1", + "phase": "refine", + "questions": [ + { + "id": "Q1", + "question": "Today, slice PRs target egg//work (not main directly), and there's no automatic 'merge work to main' PR. Is that an existing gap that this issue should also fix, or is the work-branch-as-base intentional and out of scope here?", + "answer": null + }, + { + "id": "Q2", + "question": "The proposal says context PR uses contract.pr.title and pr.description (per #2534). #2534 has already been partially fixed in #2541 and #2543 (slice attribution + program narrative on every slice). Do you want context-PR title/body to be authored from those same contract fields, or should the planner emit a separate contract.pr.context_title / pr.context_description so the context PR can have a different framing than the slice PRs (e.g. 'Strategic plan for #N' vs 'Implement #N')?", + "answer": null + }, + { + "id": "Q3", + "question": "Should the context PR include the per-phase agent transcripts (e.g. .egg-state/agent-outputs/-refine-*.md)? Or only the final analysis.md, plan.md, and BRC consensus records?", + "answer": null + }, + { + "id": "Q4", + "question": "When a slice's implement-phase BRC concludes, the orchestrator would need to commit .egg-state/brc-history/-implement-slice-.{json,md} to the slice's integration branch as a final commit before opening the slice PR. Is that final orchestrator-authored commit acceptable, or should it be authored by the coder/tester role (and would that conflict with role file boundaries \u2014 coder cannot push under .egg-state/brc-history/)?", + "answer": null + }, + { + "id": "Q5", + "question": "The issue lists 'analysis + plan + refine/plan BRC histories' for the context PR. Should the implement-phase aggregate BRC history (cross-slice) ALSO live on the context PR, or is each slice's BRC history sufficient for audit purposes?", + "answer": null + } + ], + "submitted": false, + "submitted_by": null, + "submitted_at": null, + "comment_id": null, + "debounce_until": null + }, + "reason": "Created feedback request with 5 question(s)", + "checkpoint_id": null + } + ], + "refine_review_cycles": 0, + "refine_review_feedback": "", + "plan_review_cycles": 0, + "plan_review_feedback": "", + "pr": { + "title": "Add context PR + per-slice BRC history (closes #2548)", + "description": "## Context\n\nSlice PRs today review only their own code diff against `egg//work`.\nReviewers cannot see the refine-phase analysis, the plan-phase plan, or any\nBRC consensus history for the changes they are reviewing \u2014 those artifacts\nlive on a side branch (`egg//work`) that is never part of any slice PR's\nreview surface against `main`. As a result the strategic narrative and the\nconsensus that approved each artifact never reach `main` at all.\n\n## Changes\n\n1. **New `pr.context_*` contract fields.** `PRMetadata` grows\n `context_title`, `context_description`, `context_branch`, and\n `context_pr_number`. The planner prompt now emits the new fields so the\n context PR can be framed independently from the slice PRs.\n2. **Per-slice implement-phase BRC history.** `_write_brc_history()` now\n writes `.egg-state/brc-history/-implement-slice-.{json,md}` (one\n file per slice). The aggregate `-implement.{json,md}` file is\n removed \u2014 hard switchover, no backwards-compat (D4).\n3. **Context branch + context PR.** A new gateway primitive creates\n `egg//context` from the pipeline's base branch (NOT hardcoded\n `main`). After plan_gate approval the orchestrator commits\n `analysis.md`, `plan.md`, refine/plan BRC files, and refine/plan agent\n transcripts onto that branch, then opens a doc-only auto-open PR\n targeting the configured base branch.\n4. **Slice-1 stacks on context.** Slice-1's `parent_branch` resolves to\n `egg//context` instead of `egg//work`. Each slice's\n implement-phase BRC `.json`/`.md` is committed to the slice integration\n branch as a final orchestrator-authored commit before the slice PR is\n opened. The stacked-PR reconciler's last-resort fallback prefers the\n context branch over `pipeline_branch`.\n5. **Docs refresh.** Reference and guide pages that describe the PR-stack\n shape, BRC-history file layout, and slice-1 base resolution are updated\n to match the new behavior.\n\n## Impact\n\nReviewers approaching any PR see the consensus history that produced it.\n`git log -- .egg-state/drafts/` and `git log -- .egg-state/brc-history/` on\n`main` produce a real audit trail once the context PR merges. The\nwork-branch-as-permanent-base gap (Q1) is closed. The change is a hard\nswitchover; in-flight pipelines are not backfilled (D4).", + "test_plan": "- Automated:\n - `shared/egg_contracts/tests/` \u2014 new `PRMetadata.context_*` field tests\n and schema-1.1 round-trip.\n - `orchestrator/tests/test_brc_history.py` \u2014 per-slice implement-phase\n writer; assert no aggregate file is produced.\n - `orchestrator/tests/test_create_slice_integration_branch.py` plus new\n `test_create_context_branch.py` \u2014 gateway primitive for context branch.\n - `orchestrator/tests/test_context_pr.py` (new) \u2014 orchestrator hook that\n opens the context PR with correct base, head, title, body, and files.\n - `orchestrator/tests/test_stacked_pr_reconciler.py` \u2014 fallback prefers\n the context branch over the work branch.\n - `orchestrator/tests/test_pipeline_*.py` \u2014 slice-1 base-resolution\n tests assert `parent_branch == egg//context`.\n - `make test` (changeset-aware) on every slice; `make test-all` on the\n terminal slice.\n- Manual:\n - Run a fresh pipeline against a throwaway issue and confirm the context\n PR is opened against the configured base branch with `analysis.md`,\n `plan.md`, refine/plan BRC `.json`/`.md`, and refine/plan agent\n transcripts in the diff.\n - Confirm slice-1's PR has `base = egg//context`.\n - Confirm each slice PR's diff includes its own\n `.egg-state/brc-history/-implement-slice-.{json,md}` and no\n aggregate `-implement.{json,md}` exists anywhere.\n - Merge the context PR; confirm slice-1 retargets onto the base branch\n and the orphan reconciler completes the rebase cleanly.", + "manual_steps": "Pre-merge: none beyond standard PR review. If any `.github/` workflow\nchanges turn out to be required, the coder will stage them under\n`.github-staging/` and the merge reviewer should `git mv` them into place\nbefore merging (per the existing convention).\n\nPost-merge: hard switchover \u2014 no migration, no feature flag, no\nbackwards-compat shim (per D4). Existing in-flight pipelines\n(e.g. issue-2474-v2) will NOT be backfilled.", + "deferred_actions": [] + }, + "feedback": { + "id": "feedback-1", + "phase": "refine", + "questions": [ + { + "id": "Q1", + "question": "Today, slice PRs target egg//work (not main directly), and there's no automatic 'merge work to main' PR. Is that an existing gap that this issue should also fix, or is the work-branch-as-base intentional and out of scope here?", + "answer": "In scope \u2014 fix as part of #2548. The work\u2192main gap is the deeper problem; closing it via the context PR mechanism is on-scope." + }, + { + "id": "Q2", + "question": "The proposal says context PR uses contract.pr.title and pr.description (per #2534). #2534 has already been partially fixed in #2541 and #2543 (slice attribution + program narrative on every slice). Do you want context-PR title/body to be authored from those same contract fields, or should the planner emit a separate contract.pr.context_title / pr.context_description so the context PR can have a different framing than the slice PRs (e.g. 'Strategic plan for #N' vs 'Implement #N')?", + "answer": "Add separate contract.pr.context_title / pr.context_description fields. Lets context framing differ from slice framing." + }, + { + "id": "Q3", + "question": "Should the context PR include the per-phase agent transcripts (e.g. .egg-state/agent-outputs/-refine-*.md)? Or only the final analysis.md, plan.md, and BRC consensus records?", + "answer": "Include agent transcripts as well (.egg-state/agent-outputs/-refine-*.md and similar). Maximum transparency." + }, + { + "id": "Q4", + "question": "When a slice's implement-phase BRC concludes, the orchestrator would need to commit .egg-state/brc-history/-implement-slice-.{json,md} to the slice's integration branch as a final commit before opening the slice PR. Is that final orchestrator-authored commit acceptable, or should it be authored by the coder/tester role (and would that conflict with role file boundaries \u2014 coder cannot push under .egg-state/brc-history/)?", + "answer": "Orchestrator-authored. Matches existing _commit_statefiles_to_worktree pattern; coder/tester gateway boundaries forbid them from pushing under .egg-state/brc-history/ anyway." + }, + { + "id": "Q5", + "question": "The issue lists 'analysis + plan + refine/plan BRC histories' for the context PR. Should the implement-phase aggregate BRC history (cross-slice) ALSO live on the context PR, or is each slice's BRC history sufficient for audit purposes?", + "answer": "Per-slice BRC only \u2014 each slice PR carries its own; no cross-slice aggregate file (consistent with decision-4)." + } + ], + "submitted": true, + "submitted_by": "human", + "submitted_at": "2026-05-07T18:27:11.510145Z", + "comment_id": null, + "debounce_until": null + }, + "phase_configs": null, + "agent_executions": [] +} diff --git a/.egg-state/drafts/2548-analysis.md b/.egg-state/drafts/2548-analysis.md new file mode 100644 index 0000000000..cde4139f60 --- /dev/null +++ b/.egg-state/drafts/2548-analysis.md @@ -0,0 +1,215 @@ +# Analysis: Slice PRs are missing analysis/plan docs and all BRC history; need a 'context' PR for refine+plan phases plus per-slice BRC in each slice PR + +> Issue: #2548 | Phase: refine + +## Problem Statement + +When a multi-slice pipeline lands on GitHub today, reviewers approaching any slice PR see only the slice's code-level diff. They cannot see (a) the refine-phase analysis document, (b) the plan-phase plan document, or (c) the BRC consensus history (proposals, NACKs, ACKs, CONFIRMs) that produced any of it. All of those artifacts are committed to a side branch (`egg//work`) that is not part of any slice PR's review surface against `main`. + +The desired outcome: every PR that reviewers approach carries enough in-tree, in-diff context to understand the strategic decision (refine + plan) and the consensus that approved each artifact (BRC histories) — without external state archaeology. + +Concretely the issue calls for two new mechanisms: + +1. A "context" / "preface" PR (new branch `egg//context` based on `main`) that ships the program-level analysis, plan, and refine/plan BRC histories. +2. Per-slice implement-phase BRC histories (`.egg-state/brc-history/-implement-slice-.{json,md}`) committed to each slice's integration branch as a final commit before the slice PR is opened, so the slice PR's diff includes its own BRC consensus record. + +## Current Behavior + +**Where artifacts live today** (verified against the codebase): + +- Refine and plan agents write their outputs to `.egg-state/drafts/-{analysis,plan}.md` on the agent's worktree, and `_commit_statefiles_to_worktree()` (`orchestrator/routes/pipelines.py:7179-7318`) commits them to the umbrella **work branch** `egg//work` (line 5141-5203 fetch them from per-agent worktrees during phase transitions). +- BRC history is written by `_write_brc_history()` (lines 8110-8228) to `.egg-state/brc-history/-{phase}.{json,md}` and committed at phase boundaries by `_persist_phase_brc_history()` (lines 8355-8392). The implement-phase file is a **single, cross-slice aggregate** keyed by phase name only — there is no per-slice split today. +- Slice integration branches are created by `_run_one_slice_inner()` (lines 12405-12454) via `gateway.create_slice_integration_branch()`. The base branch resolution (lines 12407-12410) is: + - Slice-1 (root): `parent_branch = pipeline_branch` → `egg//work` + - Slice-N (N>1): `parent_branch = f"{issue_branch}/{parent_slice_id}"` → previous slice's integration branch. +- Slice PRs are opened by `create_slice_pr()` (lines 12631-12648) with `base = parent_branch` and `head = integration_branch`. After #2541 the PR author is `orchestrator`; after #2543 every slice PR (terminal and non-terminal) carries the program narrative from `contract.pr.{title,description,test_plan,manual_steps}`. +- The stacked-PR reconciler (`orchestrator/stacked_pr_reconciler.py`) heals orphan child PRs after a parent merges by walking up the slice DAG via `_resolve_extant_new_base()` (lines 87-132). The fallback when the entire ancestor chain is gone is `pipeline_branch` (`egg//work`) — **not** `main`. + +**Concrete reproduction** — slice-1 PR [#2533](https://github.com/jwbron/egg/pull/2533) of pipeline `issue-2474-v2`: + +- `baseRefName = "egg/issue-2474-v2/work"`, `headRefName = "egg/issue-2474-v2/slice-1"`. Confirmed merged into `work`, never directly into `main`. +- 21 files in the diff, **zero** of which are under `.egg-state/drafts/` or `.egg-state/brc-history/`. +- PR body has the program description (post-#2543) and a task bullet list — but no link to `2474-analysis.md`, `2474-plan.md`, or any BRC consensus record. +- `2474-analysis.md` (committed at `0c92bab7d` during refine) and `2474-plan.md` (committed at `6d325e9f4` during plan) are present on `egg/issue-2474-v2/work` but invisible to anyone reviewing `#2533` against `main`. + +**The deeper issue surfaced by this analysis**: there is no automatic mechanism today to merge `egg//work` into `main` at all. Slice PRs target the work branch (or each other in the stack), and the orphan reconciler retargets up to the work branch as a fallback. The contract has no `pr.context_branch` / `pr.context_pr_number` field. The work-branch-as-permanent-base appears to be an unintended state — none of the docs / BRC artifacts ever reach `main` under the current setup. Any solution to #2548 has to also implicitly answer "how does this content reach `main`?" (see Open Questions Q1). + +## Constraints + +**Technical** + +- **Gateway-enforced file boundaries**: roles cannot push files outside their allowlist. Coder/tester cannot push under `.egg-state/brc-history/` or `.egg-state/drafts/` — only the orchestrator (or a refiner/planner role) can. The "commit BRC history to slice integration branch" step in the proposal must be orchestrator-authored. +- **BRC history is currently aggregate, not per-slice** (`{id}-implement.{json,md}`). Per-slice splitting requires a code change in `_write_brc_history()` so writes are routed to `{id}-implement-slice-.{json,md}` when a slice context is in scope, plus a corresponding read-side change in `_rewrite_brc_history_for_pr()` (lines 8265-8328). +- **Stacked-PR reconciler invariants**: the reconciler assumes parent → child base relationships drawn from `slice.dependencies`. If we insert a context PR as the new root (slice-1 base = `egg//context`), the reconciler's `_resolve_extant_new_base()` fallback (`pipeline_branch`, line 132) needs to be reconsidered — the new root of the chain should fall back to the context branch, not the work branch. +- **Contract model**: `PRMetadata` (`shared/egg_contracts/models.py:371-395`) has no `context_pr_number` / `context_branch` / `context_title` / `context_description` fields today. Adding them requires a contract schema bump. +- **HITL gate / merge ordering**: the terminal slice carries `deferred_actions` (`contract.pr.deferred_actions`) that block merge until obligations resolve. If we put the context PR at the bottom of the stack, the merge order becomes context → slice-1 → … → slice-N (terminal). The deferred-action gate stays on slice-N. +- **Orphan reconciler latency**: it runs on a ~30s cadence (`stacked_pr_reconciler.py:1-324`); inserting another PR layer adds one more level the reconciler must walk after parent merges. + +**Business / scope** + +- The issue is reported against an in-flight pipeline (`issue-2474-v2`). Whether the fix backports retroactively or is forward-only changes scope significantly (see Q4 / decision-4). +- This change is adjacent to #2534 (slice PR titles + attribution), which has been partially fixed in #2541 and #2543. Some of the proposal text (e.g. "drop the duplicated 'slice slice-1' prefix") is already addressed; the remaining surface is the missing-context problem itself. + +**Dependencies** + +- Depends on `_commit_statefiles_to_worktree()` and the gateway's `create_slice_integration_branch()` flow staying as the canonical commit/branch primitives. +- Adjacent to: #2534 (slice PR titles + attribution; partially fixed), #2538 (program narrative on every slice; merged), #2354 (deferred actions on umbrella PR), #2543 (PR body rendering). + +## Options Considered + +### Option A: Dedicated context PR, slice-1 stacks on context + +**Approach**: After plan_gate approval, the orchestrator creates `egg//context` from `main`, cherry-picks (or rebases) the analysis/plan/refine-BRC/plan-BRC commits onto it, and opens a PR with base=`main`, head=`egg//context`. Slice-1's integration branch is then created from `egg//context` (instead of `egg//work`). Slice-N>1 stacks on slice-(N-1) as today. Each slice's implement-phase BRC is committed to its integration branch as a final orchestrator-authored commit before the slice PR opens. Merge order: context PR first → slice-1 → … → slice-N (terminal). The orphan reconciler's fallback for the bottom-most slice changes from `pipeline_branch` to the context branch. + +**Pros**: + +- Reviewers approaching any PR can navigate up the stack to the context PR for strategic background. +- Analysis/plan/BRC docs reach `main` once the context PR merges — closes the discoverability gap for future readers using `git log` / `git blame`. +- Replay-able: a pipeline that restarts can rebuild context by reading in-tree BRC history rather than ephemeral `.egg-state/`. +- Cleanly separates "this is the program-level rationale" from "this is one slice's code change". +- Dovetails with the existing stacked-PR reconciler: it already handles multi-PR stacks where parents merge before children. + +**Cons**: + +- Adds a new PR type with its own state machine (creation, retry on failure, what to do if a human refuses to merge it before slicing). +- Schema change: contract grows `pr.context_branch` / `pr.context_pr_number` (or similar) so slice provisioning can find the right base. +- Per-slice implement-BRC split is mandatory (otherwise slice PR diffs still wouldn't carry their BRC). +- Modifies the slice-1 base resolution code path that recently stabilized after #2535 (slice-N consensus inheritance) and #2532 (gateway boundary fix). + +### Option B: Embed in slice-1's diff (no separate context PR) + +**Approach**: Continue creating slice-1 from `egg//work`, but commit `2548-analysis.md`, `2548-plan.md`, `2548-refine.{json,md}`, `2548-plan.{json,md}` on top of slice-1's integration branch as final orchestrator-authored commits before slice-1's PR is opened. Per-slice implement-BRC files are also committed to each slice's branch (same as Option A). No new branch, no new PR. + +**Pros**: + +- Smallest delta from current orchestrator code: reuse `_commit_statefiles_to_worktree()` against the slice integration branch. +- No contract schema change. +- No new state machine for "context PR" lifecycle. +- Slice-1 PR's "Files changed" tab now shows analysis + plan + refine/plan BRC + slice-1 code together. + +**Cons**: + +- Strategic context is bundled with one slice's code change; reviewers approaching slice-2+ first still don't see it in the slice's own diff. +- Mixes concerns in slice-1's PR: docs review and code review become one workflow. +- Does not solve the root problem that `egg//work` is never merged to `main` (see Q1) — analysis/plan still arrive on main as part of slice-1's eventual cascade-merge, not as a standalone reviewable artifact. +- If slice-1 fails or is dropped, the docs disappear too. + +### Option C: Embed in terminal slice's diff (co-locate with program narrative) + +**Approach**: Commit analysis + plan + all BRC histories (refine, plan, implement-aggregate) onto the terminal slice's integration branch. Terminal-slice PR already carries the program-level narrative (post-#2543) and the merge-gate banner; co-locating the docs there centralizes "the program ends here" in one PR. + +**Pros**: + +- Reuses the existing "terminal slice is special" mechanism (#2354 deferred actions, #2543 program narrative). +- One PR carries the full program context at merge time. +- Lowest schema/code surface. + +**Cons**: + +- Reviewers see strategic context only after they've already reviewed N-1 code slices — the inverse of the issue's desired flow. +- For a 5-slice pipeline, slice-1 reviewer still has zero context until they navigate to PR-N. +- Terminal slice PR becomes large and mixed-purpose. + +### Option D: Render context into slice PR bodies (no in-diff files) + +**Approach**: Don't commit any new files into slice diffs. Instead, the orchestrator renders a "Strategic context" section into each slice PR body containing inline links / inline excerpts of the analysis, plan, and BRC consensus summary. + +**Pros**: + +- Zero git diff change. +- No new branches, no new PRs. +- Discoverability via PR body (already where reviewers look first). + +**Cons**: + +- Not durable in `git log` / `git blame`. Future readers cannot use `git show ` to retrieve the analysis that motivated a change — the PR body is GitHub-only. +- Doesn't satisfy the issue's "auditability via git" requirement. +- BRC history doesn't survive PR body length limits / character escaping. + +## Recommended Approach + +**Option A (dedicated context PR) + per-slice implement BRC files** is the recommended approach, contingent on operator selection in decision-1. + +Rationale: + +1. **It is the only option that durably lands the strategic context on `main`.** Once the context PR merges, `git log -- .egg-state/drafts/` and `git log -- .egg-state/brc-history/` produce a real history — Options B/C only land docs as a side-effect of the eventual slice cascade, and Option D never lands them at all. +2. **Separation of concerns** matches how reviewers actually work. A reviewer who is approving "the strategic plan for #N" is doing different work from one approving "the code for slice-3 of #N". +3. **The infrastructure cost is bounded.** The orphan reconciler already handles multi-PR stacks; the contract schema delta is small (`pr.context_branch` / `pr.context_pr_number`); the orchestrator already has the primitives to commit statefiles to a branch. +4. **It is forward-compatible with #2534's vision of contract-driven PR narratives**: context PR uses the program-level `contract.pr.{title,description}`, slice PRs use slice-level metadata + a backlink (already done after #2543). + +The recommendation is conditional on the operator's answers in `decision-1`, `decision-3`, and `decision-5`. If the operator prefers a smaller-blast-radius change, Option B is acceptable as a stepping stone — but it leaves the "docs never reach main" gap unfixed. + +## Open Questions + +The full set of decisions and feedback questions has been registered via `egg-contract` so they appear on the issue for the operator. Snapshot: + + + +**Where should refine/plan analysis docs and BRC consensus history live so they are reviewable on PRs targeting main?** + +- [ ] Dedicated context PR (new egg//context branch based on main; slice-1 stacks on top of it) +- [ ] Embed in slice-1's diff (commit analysis.md/plan.md/refine+plan BRC history to slice-1 integration branch on top of egg//work) +- [ ] Embed in terminal slice's diff (terminal slice already carries the program narrative; co-locate the docs there) +- [ ] All slices carry a snapshot of analysis/plan as part of the slice integration branch +- [ ] Other (explain in reply) + + + +**How should the implement-phase BRC consensus history be split so each slice PR carries its own slice's history?** + +- [ ] Split file at write time: orchestrator writes to .egg-state/brc-history/-implement-slice-.{json,md} (one per slice; no aggregate file) +- [ ] Keep single .egg-state/brc-history/-implement.{json,md} but also write per-slice files for slice PR diffs +- [ ] Keep single file unchanged; rely on a per-slice 'view' rendered into the slice PR body (no per-slice .json/.md committed) +- [ ] Other (explain in reply) + + + +**Should the new context PR go through BRC review (reviewer_refine + reviewer_agent_design + reviewer_plan), or land as a doc-only PR auto-merged after plan_gate approval?** + +- [ ] BRC-reviewed (treated like any other producer PR; reviewers ACK the docs PR before slice-1 spawns) +- [ ] Doc-only auto-open (orchestrator opens; humans review on the PR; pipeline does not block on its merge before slicing) +- [ ] Doc-only with merge gate (pipeline blocks slicing until human merges the context PR) +- [ ] Other (explain in reply) + + + +**Rollout scope: which pipelines should the context-PR / per-slice BRC mechanism apply to?** + +- [ ] Only new pipelines started after the change lands +- [ ] New pipelines + retroactively backfill in-flight pipelines (e.g. issue-2474-v2) by opening a context PR mid-stream +- [ ] New pipelines + provide a one-shot 'egg-contract emit-context-pr' CLI for operators to backfill on demand +- [ ] Other (explain in reply) + + + +**Where in the stack should the context PR sit (and what should slice-1's PR base be)?** + +- [ ] Context PR base=main, slice-1 base=egg//context (slice-1 stacks on context; context merges first to main, then slices cascade-merge) +- [ ] Context PR base=main, slice-1 base=egg//work (context PR is a side-channel docs PR; slice stack is unchanged) +- [ ] No context PR; instead retarget egg//work itself to be the merge target on main (slice-N terminal merges into work, then a final 'merge work to main' PR is opened automatically) +- [ ] Other (explain in reply) + + + +**Open-ended feedback** (registered as `feedback-1`): + +- **Q1**: Today, slice PRs target `egg//work` (not main directly), and there's no automatic 'merge work to main' PR. Is that an existing gap that this issue should also fix, or is the work-branch-as-base intentional and out of scope here? +- **Q2**: The proposal says context PR uses `contract.pr.title` and `pr.description` (per #2534). #2534 has already been partially fixed in #2541 and #2543 (slice attribution + program narrative on every slice). Do you want context-PR title/body to be authored from those same contract fields, or should the planner emit a separate `contract.pr.context_title` / `pr.context_description` so the context PR can have a different framing than the slice PRs (e.g. 'Strategic plan for #N' vs 'Implement #N')? +- **Q3**: Should the context PR include the per-phase agent transcripts (e.g. `.egg-state/agent-outputs/-refine-*.md`)? Or only the final analysis.md, plan.md, and BRC consensus records? +- **Q4**: When a slice's implement-phase BRC concludes, the orchestrator would need to commit `.egg-state/brc-history/-implement-slice-.{json,md}` to the slice's integration branch as a final commit before opening the slice PR. Is that final orchestrator-authored commit acceptable, or should it be authored by the coder/tester role (and would that conflict with role file boundaries — coder cannot push under `.egg-state/brc-history/`)? +- **Q5**: The issue lists 'analysis + plan + refine/plan BRC histories' for the context PR. Should the implement-phase aggregate BRC history (cross-slice) ALSO live on the context PR, or is each slice's BRC history sufficient for audit purposes? + +## Complexity Assessment + +**Complexity: high** + +Rationale: + +- Multi-component change touching `orchestrator/routes/pipelines.py` (slice provisioning, base-branch resolution, BRC persistence), `orchestrator/stacked_pr_reconciler.py` (orphan-rebase fallback), `orchestrator/gateway_client.py` (new branch creation primitive), and `shared/egg_contracts/models.py` (contract schema delta). +- Introduces a new PR type with its own lifecycle (creation, retry, gating semantics, deferred-action interaction) that must integrate with the existing stacked-PR reconciler's invariants and the HITL plan_gate. +- Per-slice BRC split is itself a non-trivial refactor of `_write_brc_history()` and `_rewrite_brc_history_for_pr()` — every implement-phase write site has to learn about slice context. +- Naturally decomposable into independent slices (contract schema, BRC split, context-branch creation primitive, slice-1 rebasing, reconciler updates, slice PR body re-render). + +--- + +*Authored-by: egg* diff --git a/.egg-state/drafts/2548-plan.md b/.egg-state/drafts/2548-plan.md new file mode 100644 index 0000000000..bf238d098e --- /dev/null +++ b/.egg-state/drafts/2548-plan.md @@ -0,0 +1,603 @@ +# Plan: Context PR for refine + plan artifacts and per-slice BRC history in slice PRs + +> Issue: #2548 | Phase: plan | Recommended approach (decision-1): **Option A — dedicated context PR** + +## Approach + +The refiner's analysis recommended Option A (dedicated context PR rooted at the +pipeline's base branch, with slice-1 stacked on top), and the operator confirmed +all five HITL decisions: + +| Decision | Selected resolution | +|---|---| +| **D1**: artifact location | Dedicated context PR (`egg//context` branch, slice-1 stacks on it) | +| **D2**: implement-phase BRC layout | Hard split — `-implement-slice-.{json,md}` per slice; **no aggregate file** | +| **D3**: context-PR review | Doc-only auto-open. Pipeline does **not** block on its merge before slicing | +| **D4**: rollout | **Hard switchover** — no backfill, no feature flag, no backwards-compat | +| **D5**: stack root | Context PR base = pipeline.base_branch (NOT hardcoded `main`); slice-1 base = `egg//context` | + +Plus the operator's feedback on the open questions: + +* **Q1**: the missing work→main path is in scope; the context PR mechanism is + the path that lands docs on `main`. +* **Q2**: add separate `pr.context_title` / `pr.context_description` so the + context PR can be framed differently from slice PRs. +* **Q3**: include agent transcripts (`.egg-state/agent-outputs/-refine-*.md`, + `.egg-state/agent-outputs/-plan-*.md`) on the context PR for maximum + transparency. +* **Q4**: the per-slice implement-BRC commit is orchestrator-authored — coder + and tester are gateway-blocked from `.egg-state/brc-history/`. +* **Q5**: per-slice BRC only; no cross-slice aggregate file. + +The plan decomposes into **five serialized slices** in a single forest chain +(every slice has exactly one DAG parent), in dependency order: + +1. **Slice 1 — Contract schema + planner prompt** (foundation): extend + `PRMetadata` with `context_title`, `context_description`, `context_branch`, + `context_pr_number`; teach the planner prompt to emit the new fields; bump + `schemaVersion` to `1.1`. +2. **Slice 2 — Per-slice implement BRC writer** (hard switchover): rewrite + `_write_brc_history()` so implement-phase writes route to + `-implement-slice-.{json,md}`; update `_rewrite_brc_history_for_pr()` + and `_persist_phase_brc_history()` callers; drop aggregate-file writes. +3. **Slice 3 — Context branch creation and doc-only PR opener**: add a gateway + primitive that creates `egg//context` from `pipeline.base_branch`; + orchestrator hook after plan_gate copies analysis.md, plan.md, refine BRC + files, plan BRC files, and refine/plan agent transcripts onto that branch; + open the context PR (base=pipeline.base_branch, head=`egg//context`) + using `pr.context_title` / `pr.context_description`; persist the PR number + on `contract.pr.context_pr_number`. Auto-open, no merge gate (D3). +4. **Slice 4 — Stack rewiring**: slice-1's `parent_branch` resolves to + `egg//context` instead of `egg//work`; final orchestrator-authored + commit of `-implement-slice-.{json,md}` lands on each slice's + integration branch before the slice PR is opened; the stacked-PR reconciler's + last-resort fallback prefers the context branch over `pipeline_branch`. +5. **Slice 5 — Documentation**: update guides/references that describe the + PR-stack shape, BRC-history layout, and slice-1 base resolution. + +The chain is strictly serialized because each slice consumes types or behavior +established by the previous one. Slice-3 depends on the new schema fields from +slice-1; slice-4 depends on both the per-slice file naming from slice-2 and the +context branch from slice-3. + +## Test strategy + +* **Automated**: + * `shared/egg_contracts/tests/` — new tests for `PRMetadata` context fields + and schema 1.1 round-trip. + * `orchestrator/tests/test_brc_history.py` — extend with per-slice writer + coverage for implement phase; assert no aggregate file is produced. + * `orchestrator/tests/test_create_slice_integration_branch.py` and a new + `test_create_context_branch.py` — gateway primitive for context branch. + * `orchestrator/tests/test_context_pr.py` (new) — end-to-end orchestrator + hook that opens the context PR with the right base, head, title, body, and + set of files. + * `orchestrator/tests/test_stacked_pr_reconciler.py` — assert the new + fallback chooses `egg//context` ahead of `pipeline_branch`. + * `orchestrator/tests/test_pipeline_*.py` — slice-1 base-resolution tests + that assert `parent_branch == egg//context` once the context branch is + set on the contract. + * `make test` (changeset-aware) on each slice; `make test-all` on the final + slice as the regression gate. +* **Manual verification**: + * Run a fresh pipeline against a throwaway issue. Confirm a context PR is + opened first against the configured base branch with `analysis.md`, + `plan.md`, refine/plan BRC `.json`/`.md`, and refine/plan agent + transcripts in the diff. + * Confirm slice-1's PR has `base = egg//context`. + * Confirm each slice PR's diff includes its own + `.egg-state/brc-history/-implement-slice-.{json,md}`. + * Confirm no aggregate `-implement.{json,md}` file is produced. + * Merge the context PR; confirm slice-1 retargets onto the base branch and + the orphan reconciler is happy. + +## Manual steps + +* **Pre-merge**: none expected from the reviewer beyond standard PR review. + No CI workflow changes (`.github/`) are anticipated; if any are needed, the + coder will stage them under `.github-staging/` and the merge reviewer will + run `git mv` per the standard convention. +* **Post-merge**: hard switchover — no migration, no feature flag, no + backwards-compat shim (per D4). Existing in-flight pipelines (e.g. + `issue-2474-v2`) will **not** be backfilled. + +## Risks (flagged for the risk_analyst) + +* **Stacked-PR reconciler invariants**: changing the last-resort fallback from + `pipeline_branch` to `context_branch` can affect orphan rebase behavior for + any pipeline already mid-flight at the moment of merge. The hard-switchover + policy makes this acceptable but the change should be feature-isolated to + the new code path. +* **D3's "doc-only auto-open" semantics**: the pipeline does not block on + context-PR merge before slice-1 spawns. This means slice-1 can be opened + while the context PR is still open and unreviewed. Reviewers must understand + the relationship; we surface it via a backlink in the slice PR body + ("Strategic context: #"). +* **Branch-creation race**: `egg//context` is created from + `pipeline.base_branch` at plan-gate time; if the base branch has advanced + significantly between plan and slice-1, slice-1 will be a couple of commits + behind. Acceptable — the slice PR's "behind by N" GitHub UI surfaces it, + and the user can rebase the context branch. +* **Hard switchover** (D4): any pipeline restarted after the change lands + must complete cleanly with the new shape; there's no toggle to fall back + to the old aggregate BRC file. + +```yaml +# yaml-tasks +pr: + title: |- + Add context PR + per-slice BRC history (closes #2548) + description: | + ## Context + + Slice PRs today review only their own code diff against `egg//work`. + Reviewers cannot see the refine-phase analysis, the plan-phase plan, or any + BRC consensus history for the changes they are reviewing — those artifacts + live on a side branch (`egg//work`) that is never part of any slice PR's + review surface against `main`. As a result the strategic narrative and the + consensus that approved each artifact never reach `main` at all. + + ## Changes + + 1. **New `pr.context_*` contract fields.** `PRMetadata` grows + `context_title`, `context_description`, `context_branch`, and + `context_pr_number`. The planner prompt now emits the new fields so the + context PR can be framed independently from the slice PRs. + 2. **Per-slice implement-phase BRC history.** `_write_brc_history()` now + writes `.egg-state/brc-history/-implement-slice-.{json,md}` (one + file per slice). The aggregate `-implement.{json,md}` file is + removed — hard switchover, no backwards-compat (D4). + 3. **Context branch + context PR.** A new gateway primitive creates + `egg//context` from the pipeline's base branch (NOT hardcoded + `main`). After plan_gate approval the orchestrator commits + `analysis.md`, `plan.md`, refine/plan BRC files, and refine/plan agent + transcripts onto that branch, then opens a doc-only auto-open PR + targeting the configured base branch. + 4. **Slice-1 stacks on context.** Slice-1's `parent_branch` resolves to + `egg//context` instead of `egg//work`. Each slice's + implement-phase BRC `.json`/`.md` is committed to the slice integration + branch as a final orchestrator-authored commit before the slice PR is + opened. The stacked-PR reconciler's last-resort fallback prefers the + context branch over `pipeline_branch`. + 5. **Docs refresh.** Reference and guide pages that describe the PR-stack + shape, BRC-history file layout, and slice-1 base resolution are updated + to match the new behavior. + + ## Impact + + Reviewers approaching any PR see the consensus history that produced it. + `git log -- .egg-state/drafts/` and `git log -- .egg-state/brc-history/` on + `main` produce a real audit trail once the context PR merges. The + work-branch-as-permanent-base gap (Q1) is closed. The change is a hard + switchover; in-flight pipelines are not backfilled (D4). + test_plan: | + - Automated: + - `shared/egg_contracts/tests/` — new `PRMetadata.context_*` field tests + and schema-1.1 round-trip. + - `orchestrator/tests/test_brc_history.py` — per-slice implement-phase + writer; assert no aggregate file is produced. + - `orchestrator/tests/test_create_slice_integration_branch.py` plus new + `test_create_context_branch.py` — gateway primitive for context branch. + - `orchestrator/tests/test_context_pr.py` (new) — orchestrator hook that + opens the context PR with correct base, head, title, body, and files. + - `orchestrator/tests/test_stacked_pr_reconciler.py` — fallback prefers + the context branch over the work branch. + - `orchestrator/tests/test_pipeline_*.py` — slice-1 base-resolution + tests assert `parent_branch == egg//context`. + - `make test` (changeset-aware) on every slice; `make test-all` on the + terminal slice. + - Manual: + - Run a fresh pipeline against a throwaway issue and confirm the context + PR is opened against the configured base branch with `analysis.md`, + `plan.md`, refine/plan BRC `.json`/`.md`, and refine/plan agent + transcripts in the diff. + - Confirm slice-1's PR has `base = egg//context`. + - Confirm each slice PR's diff includes its own + `.egg-state/brc-history/-implement-slice-.{json,md}` and no + aggregate `-implement.{json,md}` exists anywhere. + - Merge the context PR; confirm slice-1 retargets onto the base branch + and the orphan reconciler completes the rebase cleanly. + manual_steps: | + Pre-merge: none beyond standard PR review. If any `.github/` workflow + changes turn out to be required, the coder will stage them under + `.github-staging/` and the merge reviewer should `git mv` them into place + before merging (per the existing convention). + + Post-merge: hard switchover — no migration, no feature flag, no + backwards-compat shim (per D4). Existing in-flight pipelines + (e.g. issue-2474-v2) will NOT be backfilled. +slices: + - id: 1 + name: |- + Contract schema delta + planner prompt + goal: |- + Add the `pr.context_title`, `pr.context_description`, `pr.context_branch`, + and `pr.context_pr_number` fields to `PRMetadata`, bump the contract + schemaVersion to `1.1`, and teach the task_planner prompt + plan-yaml + ingestion to round-trip the new fields. This is the foundation slice; + every later slice references one or more of these fields. + tasks: + - id: TASK-1-1 + description: |- + Extend `PRMetadata` in `shared/egg_contracts/models.py` with four + new optional fields: + - `context_title: str | None = None` — title for the context PR + (the planner-emitted "Strategic plan for #N" framing). + - `context_description: str | None = None` — body for the context + PR. + - `context_branch: str | None = None` — branch name `egg//context` + once the orchestrator has created it. Persisted by slice-3. + - `context_pr_number: int | None = None` — GitHub PR number once the + context PR has been opened. Persisted by slice-3. + Bump the contract `schemaVersion` default from `"1.0"` to `"1.1"` + (in the `EggContract` root model). Add a model-level migration so + contracts loaded with `schemaVersion="1.0"` still parse cleanly with + the new optional fields defaulted to `None`. + acceptance: |- + - `from shared.egg_contracts.models import PRMetadata` exposes the + four new fields with `None` defaults. + - Loading a 1.0 contract round-trips through the new model with the + new fields defaulted to `None`. + - `make lint` and `make test` (changeset-aware) pass. + role: coder + files: + - shared/egg_contracts/models.py + - id: TASK-1-2 + description: |- + Add unit tests under `shared/egg_contracts/tests/` covering the new + `PRMetadata.context_*` fields: + - Round-trip a `PRMetadata` with all four context fields populated. + - Round-trip a `PRMetadata` with all four context fields omitted + (must default to `None`). + - Round-trip a contract serialised with `schemaVersion="1.0"` and + no context fields, and confirm migration to `1.1` populates the + defaults. + - Confirm `context_pr_number=0` and negative values are rejected if + we add a `ge=1` validator (apply a sensible validator). + acceptance: |- + - The new tests pass under `make test`. + - Coverage on the new code paths is non-zero. + role: tester + files: + - shared/egg_contracts/tests/test_pr_metadata.py + - id: TASK-1-3 + description: |- + Update the task_planner prompt in `orchestrator/routes/pipelines.py` + (block starting at line ~11046, anchor "Decompose the architecture + analysis into a single-PR implementation plan.") so the YAML + example and prose: + - Document the new `pr.context_title` and `pr.context_description` + fields and recommend when to use them ("framing for the strategic + plan PR; defaults to `pr.title` / `pr.description` if omitted"). + - Update the `# yaml-tasks` example to include `context_title:` and + `context_description:` block scalars. + - Note that `pr.context_branch` and `pr.context_pr_number` are + populated by the orchestrator (not the planner) and should NOT be + emitted by the planner. + Also update the YAML ingestion path in + `.github/scripts/checks/plan_yaml_check.py` and any matching + ingestion under `orchestrator/routes/phases.py` (search for + `yaml-tasks` in pipelines.py / phases.py per the survey) so the + new keys are accepted (but optional). + acceptance: |- + - A planner-emitted YAML containing `context_title:` and + `context_description:` is parsed without error and the values + land on `contract.pr.context_title` / `pr.context_description`. + - A planner-emitted YAML omitting the new keys still parses and + both context fields default to `None`. + - `plan_yaml_check.py` runs cleanly on both inputs. + role: coder + files: + - orchestrator/routes/pipelines.py + - orchestrator/routes/phases.py + - .github/scripts/checks/plan_yaml_check.py + - id: 2 + name: |- + Per-slice implement-phase BRC history + goal: |- + Switch the implement-phase BRC writer from one aggregate + `-implement.{json,md}` file to one per slice + (`-implement-slice-.{json,md}`). Hard switchover: no aggregate + file is produced (D2 + D4). + dependencies: + - slice-1 + tasks: + - id: TASK-2-1 + description: |- + Refactor `_write_brc_history()` (`orchestrator/routes/pipelines.py` + ~line 8110-8228) so that when `phase == "implement"` and a slice + context is in scope, it writes + `.egg-state/brc-history/-implement-slice-.{json,md}` + instead of `-implement.{json,md}`. Implement-phase BRC + messages are partitioned by their slice scope (the orchestrator + attaches a `slice_id` to each implement-phase BRC message; if the + message lacks a slice_id, log a warning and skip it — the + partitioning is mandatory under D4). + Refine, plan, and pr phases continue to write the aggregate + `-{phase}.{json,md}` filename — only implement is + per-slice. + acceptance: |- + - For a pipeline with N slices, `_write_brc_history(phase="implement")` + produces N files named `-implement-slice-1.{json,md}` … + `-implement-slice-N.{json,md}` and zero + `-implement.{json,md}` files. + - For phases other than implement, behavior is unchanged. + - `make lint` passes. + role: coder + files: + - orchestrator/routes/pipelines.py + - id: TASK-2-2 + description: |- + Update `_rewrite_brc_history_for_pr()` (lines ~8265-8330) and + `_persist_phase_brc_history()` (~8355-8392) callers so that: + - Implement-phase persistence iterates all known slices and persists + one file per slice. + - The PR-rewrite path (used by babysit_pr) finds and rewrites the + per-slice files instead of the aggregate file. + - Any callers that previously read `-implement.{json,md}` are + updated to enumerate the per-slice files (search for the + `-implement.json` and `-implement.md` literals). + acceptance: |- + - `grep -n "-implement\\.\\(json\\|md\\)" orchestrator/` (or + equivalent) returns no remaining direct references to the + aggregate file in production code paths. + - `_persist_phase_brc_history()` and `_rewrite_brc_history_for_pr()` + still complete cleanly and idempotently for refine/plan/pr phases. + role: coder + files: + - orchestrator/routes/pipelines.py + - id: TASK-2-3 + description: |- + Add and update tests in `orchestrator/tests/test_brc_history.py` + (and `test_brc_history_identifier_babysit_pr.py` if relevant) that: + - Exercise the per-slice implement-phase writer end-to-end. + - Assert no aggregate `-implement.{json,md}` file is produced + for an implement-phase pipeline run with multiple slices. + - Cover the case where an implement-phase BRC message lacks a + slice_id (writer logs a warning and skips it). + - Existing tests that asserted on the aggregate filename are + rewritten to assert on the per-slice filenames (hard switchover — + no aggregate-file test path remains). + acceptance: |- + - `make test` (changeset-aware) green on the BRC-history test + modules. + - The aggregate-file assertion no longer appears in the test suite. + role: tester + files: + - orchestrator/tests/test_brc_history.py + - orchestrator/tests/test_brc_history_identifier_babysit_pr.py + - id: 3 + name: |- + Context branch + doc-only context PR + goal: |- + Create `egg//context` from the pipeline's `base_branch`, populate + it with the refine + plan artifacts (analysis.md, plan.md, refine BRC + json/md, plan BRC json/md, refine + plan agent transcripts), and open a + doc-only auto-open PR targeting `pipeline.base_branch`. Persist the + branch name and PR number on `contract.pr.context_branch` / + `pr.context_pr_number`. + dependencies: + - slice-2 + tasks: + - id: TASK-3-1 + description: |- + Add a new gateway primitive `create_context_branch()` to + `orchestrator/gateway_client.py` modeled on + `create_slice_integration_branch()` (lines 1696-1760). It must: + - Resolve the parent SHA via `git ls-remote refs/heads/`. + - Create a remote branch `egg//context` pointing at + that SHA via a synthetic gateway session push. + - Be idempotent: if the branch already exists at the same SHA, + return success; if it exists at a different SHA, raise. + - Use `pipeline.base_branch` (NOT a hardcoded `main`) as the parent + ref. + acceptance: |- + - Calling `create_context_branch(pipeline_id, base_branch="main")` + on a clean test fixture produces an `egg//context` + ref pointing at the same SHA as `main`. + - Calling it twice in a row is idempotent. + - Calling it when the branch exists at a different SHA raises. + role: coder + files: + - orchestrator/gateway_client.py + - id: TASK-3-2 + description: |- + Add an orchestrator hook that runs **after plan_gate approval and + before slice-1 provisioning**. It must: + 1. Call `create_context_branch(pipeline_id, pipeline.base_branch)`. + 2. Check out the context branch into a temporary worktree. + 3. Copy the following files from the pipeline work branch onto the + context worktree: + - `.egg-state/drafts/-analysis.md` + - `.egg-state/drafts/-plan.md` + - `.egg-state/brc-history/-refine.json` + - `.egg-state/brc-history/-refine.md` + - `.egg-state/brc-history/-plan.json` + - `.egg-state/brc-history/-plan.md` + - All `.egg-state/agent-outputs/-refine-*.{md,json}` + - All `.egg-state/agent-outputs/-plan-*.{md,json}` + 4. Commit (orchestrator-authored, `--no-verify`) and push using the + same primitive `_commit_statefiles_to_worktree()` follows. + 5. Open a PR with `base = pipeline.base_branch`, `head = + egg//context`, `title = contract.pr.context_title or + contract.pr.title`, `body = contract.pr.context_description or + contract.pr.description`. PR is opened **doc-only auto-open**: + the orchestrator does not block on its merge before slicing + (D3). + 6. Persist the branch name and PR number on + `contract.pr.context_branch` and `contract.pr.context_pr_number` + via the standard contract-write path. + The hook MUST guard against double-opening (idempotent on retry). + acceptance: |- + - On a fresh pipeline, after plan_gate approves, the context PR is + opened against the configured base branch and `contract.pr.{context_branch,context_pr_number}` + are populated. + - The PR diff contains analysis.md, plan.md, refine/plan BRC + files, and refine/plan agent transcripts. + - Re-running the hook is a no-op (idempotent). + - Slice-1 provisioning is **not** blocked on context-PR merge (D3). + role: coder + files: + - orchestrator/routes/pipelines.py + - id: TASK-3-3 + description: |- + Add tests covering the new gateway primitive and orchestrator hook: + - `orchestrator/tests/test_create_context_branch.py` (new) — + primitive-level tests: idempotency, branch-already-exists-at-different-SHA + error, base_branch is honored (not hardcoded main). + - `orchestrator/tests/test_context_pr.py` (new) — orchestrator + hook tests using existing pipeline-fixture infra: assert the PR + is opened with the right base/head/title/body, the diff contains + the expected files, and `contract.pr.{context_branch,context_pr_number}` + are persisted. + - Integration-style test that the slice-1 spawn that follows does + **not** block on context-PR merge (D3). + acceptance: |- + - All new tests pass under `make test`. + - Test files do not introduce any read of an aggregate + `-implement.{json,md}` file. + role: tester + files: + - orchestrator/tests/test_create_context_branch.py + - orchestrator/tests/test_context_pr.py + - id: 4 + name: |- + Slice-1 base wiring + per-slice BRC commit + reconciler fallback + goal: |- + Wire slice-1 to stack on `egg//context`, ensure each slice's + implement-phase BRC files land on its integration branch as a final + orchestrator-authored commit before the slice PR is opened, and update + the stacked-PR reconciler so its last-resort fallback prefers the + context branch over `pipeline_branch`. + dependencies: + - slice-3 + tasks: + - id: TASK-4-1 + description: |- + In `_run_one_slice_inner()` (`orchestrator/routes/pipelines.py` + ~line 12405-12454), change the slice-1 (root) `parent_branch` + resolution: instead of `parent_branch = pipeline_branch`, use + `parent_branch = contract.pr.context_branch or pipeline_branch` + (the `or pipeline_branch` is a defensive fallback only — under D4 + the context branch must always be present by the time slice-1 + provisions; log a warning if the fallback fires). + Slice-N>1 logic is unchanged (`f"{issue_branch}/{parent_slice_id}"`). + acceptance: |- + - With a populated `contract.pr.context_branch`, slice-1's + integration branch is created from that branch (verifiable via + `git merge-base` in a fixture). + - With the field absent, the warning fires and slice-1 falls back + to `pipeline_branch` for legacy fixtures (covered by a single + regression test). + role: coder + files: + - orchestrator/routes/pipelines.py + - id: TASK-4-2 + description: |- + After each slice's implement-phase BRC reaches consensus and before + `create_slice_pr()` is called (~line 12631-12648), commit the + slice's `.egg-state/brc-history/-implement-slice-.{json,md}` + files onto the slice's integration branch as a final + orchestrator-authored commit. Reuse `_commit_statefiles_to_worktree()` + (lines ~7179-7318), narrowing the file glob to the per-slice files + via the existing `pipeline_identifier`-scoped pattern. + The commit must be: + - Orchestrator-authored (default committer; matches existing + `_commit_statefiles_to_worktree()` semantics, addressing Q4). + - Idempotent (re-running mid-flight produces no new commit if the + files match HEAD). + - Pushed to the slice integration branch before the slice PR is + opened so the BRC files are part of the PR's diff. + acceptance: |- + - On a multi-slice pipeline, each slice PR's "Files changed" tab + includes its own `-implement-slice-.{json,md}` files. + - No slice PR contains another slice's BRC files. + - Re-running the per-slice commit step is a no-op when the files + already match. + role: coder + files: + - orchestrator/routes/pipelines.py + - id: TASK-4-3 + description: |- + Update `_resolve_extant_new_base()` in + `orchestrator/stacked_pr_reconciler.py` (~line 87-132) so the + last-resort fallback prefers `contract.pr.context_branch` when set + and falls back to `pipeline_branch` only if the context branch is + absent or has been deleted. The new ordering: + 1. Walk slice DAG via `dependencies[0]` until extant branch found. + 2. If the chain is exhausted, return `contract.pr.context_branch` + when present and extant on the remote. + 3. Final fallback: `pipeline_branch` (current behavior). + Document the new ordering in a code comment. + acceptance: |- + - With `contract.pr.context_branch` populated and extant, the + reconciler returns it as the orphan-rebase target. + - With the context branch missing, the reconciler falls back to + `pipeline_branch` (regression test). + role: coder + files: + - orchestrator/stacked_pr_reconciler.py + - id: TASK-4-4 + description: |- + Add and update tests covering the slice-1 base rewiring, per-slice + BRC commit, and reconciler fallback: + - `orchestrator/tests/test_pipeline_*.py` — slice-1 base resolution + asserts `parent_branch == egg//context` when the context + branch is set. + - `orchestrator/tests/test_stacked_pr_reconciler.py` — fallback + prefers `egg//context` over `pipeline_branch`; missing-context + regression covered. + - `orchestrator/tests/test_brc_history.py` (or a new + `test_per_slice_brc_commit.py`) — integration-style: the per-slice + BRC files land on the slice integration branch before the slice + PR is opened. + - End-to-end smoke test (existing `test_auto_pr.py` or equivalent) + verifies a multi-slice pipeline produces: + * 1 context PR with refine/plan artifacts + * N slice PRs each with their own `-implement-slice-.{json,md}` + * No aggregate `-implement.{json,md}` file anywhere + * Slice-1 base = `egg//context` + * Slice-N>1 base = `egg//slice-` (unchanged) + acceptance: |- + - All new and updated tests pass under `make test-all` on this + terminal-code slice. + role: tester + files: + - orchestrator/tests/test_stacked_pr_reconciler.py + - orchestrator/tests/test_per_slice_brc_commit.py + - id: 5 + name: |- + Documentation + goal: |- + Update the docs that describe the PR-stack shape, BRC-history file + layout, and slice-1 base resolution so they match the new behavior. + dependencies: + - slice-4 + tasks: + - id: TASK-5-1 + description: |- + Update the following docs to describe the context PR, the + per-slice implement BRC layout, and slice-1's new base resolution: + - `docs/guides/concurrent-execution.md` — PR-stack diagram + an + explicit "Context PR is opened first" subsection. + - `docs/architecture/orchestrator.md` — slice-DAG diagram updated + to show context branch as the new root, BRC-history file naming + section updated to call out the per-slice implement files. + - `docs/reference/orchestrator-cli.md` — if any `egg-orch` commands + surface the context PR (e.g. status output), document the field. + - `docs/guides/babysit-pr.md` — note that babysit_pr now reads + per-slice implement BRC files, not the aggregate file. + Cross-reference issue #2548 in each affected doc. + acceptance: |- + - All four docs render cleanly (`make lint` includes markdown + checks). + - Search for the literal string `-implement.json` (or `.md`) + in the docs: zero remaining matches outside changelog/historical + references. + role: documenter + files: + - docs/guides/concurrent-execution.md + - docs/architecture/orchestrator.md + - docs/reference/orchestrator-cli.md + - docs/guides/babysit-pr.md +``` diff --git a/.egg-state/drafts/issue-2474-v2-analysis.md b/.egg-state/drafts/issue-2474-v2-analysis.md new file mode 100644 index 0000000000..0d6bde215f --- /dev/null +++ b/.egg-state/drafts/issue-2474-v2-analysis.md @@ -0,0 +1,60 @@ +# Analysis: Wire integration tests into PR CI; retire dead test tiers; expand coverage + +> Issue: #2474 | Phase: refine + +## Problem Statement + +The repository ships a thoughtful integration-test suite at `integration_tests/` +(k3s-backed, with mocked LLMs, exercising gateway/container/network/policy +boundaries), but it is **not catching regressions** because: + +1. **`test-integration.yml` is never invoked from a PR workflow.** The file + only declares `workflow_call` and `workflow_dispatch` triggers, and no + sibling workflow under `.github/workflows/` calls it. PRs merge to `main` + without integration tests ever running. Compare with `test.yml`, which is + wired into PRs directly via its own `pull_request:` trigger and is the only + reason unit tests run on PRs today. +2. **Coverage gaps in the orchestrator pipeline state machine.** Recent + in-process state-mutation regressions (#2428 slice spawn `EGG_BRANCH` + threading, #2429 unpushed-commit salvage, #2420 live-pod guard on restart, + #2430 HITL alive-signal bypass) all merged through CI green. They are + plausibly catchable at the integration tier but no scenarios exercise the + relevant code paths. + +Two adjacent test tiers are dead code: + +- **`tests/functional/`** — three test files (`test_git_wrappers.py:314`, + `test_network_modes.py:254`, `test_session_lifecycle.py:345`) plus + `conftest.py:487`, ~1,400 LoC total, all gated on the `functional` pytest + marker. They start a docker-compose-based gateway. Last meaningful change + 2026-03-13 (#1053). No CI workflow references the marker; `make test-all` + excludes them via the testpaths config; they are never run. +- **`test-e2e.yml`** — real-LLM (`ANTHROPIC_OAUTH_TOKEN`) weekly-cron workflow + with two jobs (deterministic + agent-fuzz). Per the proposal text and + #2449, the agent-flaky tier is too noisy to be useful as a regression + signal. + +## Recommended Approach + +Option C: bundle A+B+C+D+F as the cleanup PRs, ship Part E as the expansion. All delivered within this single pipeline run via 5 sequential slices. + +## Resolved Decisions (operator pre-refine answers) + +- **decision-1** (PR shape): Multi-phase / multi-PR delivery within this single pipeline run. NOT one mega-PR, NOT deferred to a separate effort. +- **decision-2** (local-dev k3s runtime): **k3s only**. Do NOT document kind / minikube as alternatives in `docs/guides/testing.md`. Local k3s setup is a hard requirement for running integration tests. + +## Open Questions (for implement-phase agents to resolve via defaults or HITL) + +- **decision-3** (required-from-day-1): Whether the new integration check should be required for merge from day 1, non-blocking initially, path-conditional, or always-required. +- **decision-4** (e2e fate): Delete `test-e2e.yml` entirely, keep as placeholder, keep as manual-only, or defer to #2449. +- **decision-5** (ScriptedProvider location): Where `ScriptedProvider` should live and how it should be exposed. +- **decision-6** (push-rejection injection): Mechanism for injecting gateway push rejection in test E.3. +- **decision-7** (E.6 scenario): Precise scenario for test E.6 (Slice DAG with mid-flight `restart_agent`). +- **decision-8** (CLAUDE.md note placement): Where in `CLAUDE.md` the Part F note should live. +- **feedback-1 / Q1–Q6**: Wall-clock budget, flake guards, E.7/E.8 test shapes, test-tree placement, `make test-all` fold-in. + +The plan picks pragmatic defaults for these; implement-phase agents can override if HITL answers arrive. + +--- + +*Authored-by: egg (re-submitted at implement phase with qualifier v2 after pipeline state cleanup; original analysis at .egg-state/drafts/2474-analysis.md on origin/egg/issue-2474/work commit 0c92bab7d)* \ No newline at end of file diff --git a/.egg-state/drafts/issue-2474-v2-plan.md b/.egg-state/drafts/issue-2474-v2-plan.md new file mode 100644 index 0000000000..2b8fecee19 --- /dev/null +++ b/.egg-state/drafts/issue-2474-v2-plan.md @@ -0,0 +1,367 @@ +# Plan: Wire integration tests into PR CI; retire dead test tiers; expand coverage + +> Issue: #2474 | Phase: plan | Pipeline: issue-2474-v2 + +## Approach + +The refine analysis (`.egg-state/drafts/2474-analysis.md`) identified six +parts (A–F). All six parts ship together as a single stacked-PR train via +5 sequential slices. The slice DAG is **linear** — each slice has exactly +one parent, satisfying the forest constraint without needing +`serialized_chain_order`. + +**Slicing rationale**: order each slice so the next can depend on its +predecessor's invariants: + +1. **Slice 1 — Cleanup** (Parts B + C + D). Removes the dead docker-compose + runtime branch, deletes `tests/functional/`, and retires the + real-LLM e2e workflow. Lands first because it shrinks the test surface + that everything downstream depends on. Pure deletions + a few conftest + edits. +2. **Slice 2 — Promote `ScriptedProvider`** (Part E auxiliary). Moves + `ScriptedProvider` to a public `shared/egg_harness/testing/scripted_provider.py` + module so integration tests can import it. +3. **Slice 3 — New k3s integration tests** (Part E core). Adds 8 new + regression-and-invariant tests under `integration_tests/regression/`. + Depends on slice 2 and slice 1. +4. **Slice 4 — Wire integration tests into PR CI** (Part A). Adds a + `workflow_call` of `test-integration.yml` from the `Test` workflow. +5. **Slice 5 — Documentation** (Part F + supporting docs). `CLAUDE.md` + note + local-k3s recipe in `docs/guides/testing.md` (k3s only, + no kind/minikube alternatives per operator direction). + +## Risks and Mitigations + +| Risk | Mitigation | +|---|---| +| Slice 1 deletes 1,400+ LoC; reviewers may miss a transitive import | CI's unit suite catches Python-level dangling imports. | +| Slice 3 tests flake on cold k3s | Slice 4 pre-pulls images and adds `kubectl wait` timeouts. | +| Required-check flake on day 1 | decision-3 default keeps the check non-blocking until settle-in. | +| `ScriptedProvider` API drift breaks consumers | Slice 2 keeps the existing five test-file consumers passing as the canary. | +| #2449 (parallel issue) merges first | Part D becomes a no-op rebase. | + +--- + +```yaml +# yaml-tasks +pr: + title: |- + Wire integration tests into PR CI; retire dead tiers; expand coverage + description: | + Wire `test-integration.yml` into PR CI; retire dead test tiers + (Docker-compose runtime, tests/functional/, real-LLM e2e); expand + integration coverage with 8 new k3s scenarios; document agent + guidance. Multi-phase / multi-PR delivery via 5-slice stacked train. + test_plan: | + - Automated: + * `make test-all` continues to pass on every slice. + * `make lint` continues to pass. + * `make test-integration` passes locally on k3s after slice 1 and after slice 3. + * Slice 4 onwards: GitHub Actions integration job runs on each slice PR. + - Manual: + * Confirm new `Integration Tests / aggregate` check appears on a sample PR after slice 4. + * Reviewer follows `docs/guides/testing.md` k3s-on-host recipe on a fresh laptop. + manual_steps: | + Pre-merge (slice 4): trigger `test-integration.yml` via workflow_dispatch on slice-4 branch; confirm green and within budget. + Post-merge (slice 4 + 5): maintainer flips `Test / aggregate` to required in branch protection; close #2449 if absorbed. +slices: + - id: 1 + name: |- + Cleanup — k3s only, drop dead test tiers + goal: |- + Drop `EGG_RUNTIME=docker` branch from `integration_tests/conftest.py`; + delete `tests/functional/`; retire `.github/workflows/test-e2e.yml` + plus its test files; remove `functional`/`e2e`/`agent_flaky` markers + and now-orphan `run_claude_structured()` / `assert_agent_verdict()` helpers. + tasks: + - id: TASK-1-1 + description: |- + In `integration_tests/conftest.py` and `integration_tests/local_pipeline/conftest.py`, + remove `_docker_egg_stack()` and the runtime-selection branch in + `egg_stack`. Always call `_k8s_egg_stack()`; skip with clear + message if `kubectl` unavailable. Remove `docker_available` + import and call sites. Drop stale docker-compose comments. + Flip default `EGG_RUNTIME` from "docker" to "kubernetes" or remove. + acceptance: |- + `grep -n "EGG_RUNTIME=docker\|_docker_egg_stack\|docker_available" integration_tests/conftest.py integration_tests/local_pipeline/conftest.py` + returns no hits. + role: coder + files: + - integration_tests/conftest.py + - integration_tests/local_pipeline/conftest.py + - id: TASK-1-2 + description: |- + Delete entire `tests/functional/` directory (5 files). Remove + `functional:` marker from `pyproject.toml`. Remove + `tests/functional/conftest.py` and `integration_tests/docker-compose.yml` + allowlist entries from `scripts/check-hardcoded-ports.py`. + acceptance: |- + `tests/functional/` no longer exists. `make test-all` passes. + `grep -rn "tests.functional\|@pytest.mark.functional"` returns no hits. + role: tester + files: + - tests/functional/conftest.py + - tests/functional/test_git_wrappers.py + - tests/functional/test_network_modes.py + - tests/functional/test_session_lifecycle.py + - tests/functional/__init__.py + - pyproject.toml + - scripts/check-hardcoded-ports.py + - id: TASK-1-3 + description: |- + Delete `.github/workflows/test-e2e.yml`, + `integration_tests/test_e2e_workflow.py`, + `integration_tests/test_agent_security_fuzz.py`, and + `integration_tests/agent_findings.py`. Remove `e2e` and + `agent_flaky` markers from `pyproject.toml`. Remove `test-e2e:` + target from `Makefile`. Update `test-integration:` docstring. + acceptance: |- + The 4 files are gone. `make test-e2e` is no longer a valid target. + `make help` does not advertise `test-e2e`. `make lint` and + `make test-all` pass. + role: coder + files: + - .github/workflows/test-e2e.yml + - integration_tests/test_e2e_workflow.py + - integration_tests/test_agent_security_fuzz.py + - integration_tests/agent_findings.py + - pyproject.toml + - Makefile + - id: TASK-1-4 + description: |- + In `integration_tests/conftest.py`, remove `run_claude_structured()`, + `assert_agent_verdict()`, the `infrastructure_failure` field on + `AgentVerdict` dataclass, and orphan helpers used only by those. + acceptance: |- + `grep -rn "run_claude_structured\|assert_agent_verdict"` returns no hits. + `make test-integration` and `make test-all` pass. + role: coder + files: + - integration_tests/conftest.py + - id: 2 + name: |- + Promote ScriptedProvider to public testing API + goal: |- + Move `ScriptedProvider` and its private `_stream_events` helper to + `shared/egg_harness/testing/scripted_provider.py` so slice-3 integration + tests can hand each agent role a canned LLM trajectory. + dependencies: + - slice-1 + tasks: + - id: TASK-2-1 + description: |- + Create `shared/egg_harness/testing/__init__.py` and + `shared/egg_harness/testing/scripted_provider.py` containing the + class verbatim plus `_stream_events`. + acceptance: |- + `python -c "from shared.egg_harness.testing import ScriptedProvider; print(ScriptedProvider.__name__)"` + prints `ScriptedProvider`. `make lint` passes. + role: coder + files: + - shared/egg_harness/testing/__init__.py + - shared/egg_harness/testing/scripted_provider.py + - id: TASK-2-2 + description: |- + In `shared/tests/test_egg_harness/test_integration.py`, replace + inline ScriptedProvider class with re-export shim. Keep five call + sites resolvable. `RecordingRegistry` stays inline. + acceptance: |- + File no longer contains `class ScriptedProvider` or `_stream_events` + definitions. `make test` passes. + role: tester + files: + - shared/tests/test_egg_harness/test_integration.py + - id: TASK-2-3 + description: |- + Add `shared/tests/test_egg_harness/test_scripted_provider.py` with + two tests: import works, public API surface matches. + acceptance: |- + New test passes. Removing `scripted_provider.py` causes ImportError. + role: tester + files: + - shared/tests/test_egg_harness/test_scripted_provider.py + - id: 3 + name: |- + Add k3s integration tests for recent regressions and invariants + goal: |- + Land 8 Part-E scenarios under `integration_tests/regression/`: + regressions in #2428, #2429, #2420, #2430 plus 4 invariants. + dependencies: + - slice-2 + tasks: + - id: TASK-3-1 + description: |- + Create `integration_tests/regression/__init__.py` and `conftest.py`. + Conftest re-exports parent k8s fixtures and adds `start_pipeline()` + helper returning deterministic pipeline_id from test nodeid. + acceptance: |- + `make test-integration -m integration` includes new dir, "0 errors" on collection. + role: tester + files: + - integration_tests/regression/__init__.py + - integration_tests/regression/conftest.py + - id: TASK-3-2 + description: |- + Add `test_slice_branch_env.py` covering #2428. Spin 2-slice DAG; + assert each slice coder pod's `EGG_BRANCH` matches its slice ref + via `kubectl get pod -o jsonpath`. + acceptance: |- + Test passes on `main`. Reverting #2428 fix causes failure with clear assertion. + role: tester + files: + - integration_tests/regression/test_slice_branch_env.py + - id: TASK-3-3 + description: |- + Add `test_unpushed_commit_salvage.py` covering #2429. Trigger + gateway push rejection by attempting push outside role allowlist + (no test backdoor). Assert recovery branch ref appears. + acceptance: |- + Test passes on `main`. Reverting salvage code causes "recovery ref not found". + role: tester + files: + - integration_tests/regression/test_unpushed_commit_salvage.py + - id: TASK-3-4 + description: |- + Add `test_live_pod_guard.py` covering #2420. Start pipeline, wait + for slice pods Running, call `start_pipeline` again WITHOUT force=true; + assert refused. Retry with force=true; assert new pipeline replaces old. + acceptance: |- + Test passes on `main`. Reverting #2420 makes second start succeed. + role: tester + files: + - integration_tests/regression/test_live_pod_guard.py + - id: TASK-3-5 + description: |- + Add `test_hitl_round_trip.py` covering #2430. Drive refine pipeline + that registers HITL decision; observe AWAITING_HUMAN; call provide_input; + assert pipeline resumes. Use `ScriptedProvider`. + acceptance: |- + Test passes on `main`. Reverting alive-signal bypass causes timeout. + role: tester + files: + - integration_tests/regression/test_hitl_round_trip.py + - id: TASK-3-6 + description: |- + Add `test_brc_single_cycle.py` (BRC happy path: PROPOSE → ACK → + CONFIRMED, exact counts) and `test_slice_dag_restart.py` (3-slice + DAG, mid-flight restart_agent, assert slice-2 branch unchanged). + acceptance: |- + Both tests pass on `main`. BRC test asserts exact counts. + role: tester + files: + - integration_tests/regression/test_brc_single_cycle.py + - integration_tests/regression/test_slice_dag_restart.py + - id: TASK-3-7 + description: |- + Add `test_phase_aware_timeout.py`. Configure + `phase_configs.plan.consensus_timeout_s = 30`; have planner not + propose; assert `CONSENSUS_TIMEOUT` event lands within 30±5s; + assert other phases unaffected. + acceptance: |- + Test passes on `main`. Setting timeout to 600 fails deadline assertion. + role: tester + files: + - integration_tests/regression/test_phase_aware_timeout.py + - id: TASK-3-8 + description: |- + Add `test_babysit_pr_single_push.py`. Drive babysit-PR across 2 + coder revisions. Query gateway audit log for pushes to PR head ref; + assert exactly 1 successful push. + acceptance: |- + Test passes on `main`. If regression makes coder push twice, test fails. + role: tester + files: + - integration_tests/regression/test_babysit_pr_single_push.py + - id: 4 + name: |- + Wire integration tests into PR CI + goal: |- + Make integration tier run on every PR via `workflow_call` of + `test-integration.yml` from `test.yml`. Per decision-3 default, + check is NOT branch-protection-required from day 1. + dependencies: + - slice-3 + tasks: + - id: TASK-4-1 + description: |- + In `.github/workflows/test.yml`, add `integration:` job (sibling + of `unit:` and `security:`) that uses `test-integration.yml`. + Include in `aggregate:` job's `needs:` list. Add + `timeout-minutes: 30`. Add `concurrency:` block to + `test-integration.yml` mirroring `test.yml`. + acceptance: |- + PR shows new `Test / integration` check and updated + `Test / aggregate` check that depends on it. Both run within + 30-min timeout. `make lint` passes. + role: coder + files: + - .github/workflows/test.yml + - .github/workflows/test-integration.yml + - id: TASK-4-2 + description: |- + In `.github/workflows/test-integration.yml`, add flake-guard steps: + retry "Import images into k3s" once on failure; explicit + `kubectl wait --for=condition=Available deployment/egg-orchestrator --timeout=120s`. + Add per-step `timeout-minutes:`. On failure, capture k3s logs as artifact. + acceptance: |- + PR runs integration job to green. Image-import flake recovered by retry. + On forced failure, uploads `k3s-debug.log` artifact. + role: coder + files: + - .github/workflows/test-integration.yml + - id: 5 + name: |- + Documentation — point agents at the integration tier + goal: |- + Add Quick Reference bullet and dedicated subsection in `CLAUDE.md`. + Document local k3s recipe in `docs/guides/testing.md` (k3s ONLY per + operator direction; do NOT document kind/minikube as alternatives). + dependencies: + - slice-4 + tasks: + - id: TASK-5-1 + description: |- + In `CLAUDE.md`, add Quick Reference bullet: + `make test-integration # Cross-module regressions; requires k3s (see docs/guides/testing.md)`. + Add new section after "Key Entry Points" titled "Integration tests" + with paragraph pointing at `integration_tests/regression/` for + cross-module bugs. Update Repo Layout row. + acceptance: |- + `CLAUDE.md` contains new bullet, new section, and updated Repo Layout row. + `make lint` passes. + role: documenter + files: + - CLAUDE.md + - id: TASK-5-2 + description: |- + In `docs/guides/testing.md`, add "Integration tests" section with + three subsections: + 1. **What it covers** — k3s + mocked LLMs. + 2. **Running locally** — k3s-on-host recipe ONLY. Do NOT document + kind or minikube as alternatives. Mention macOS users need a + Linux VM. Mention required-check name `Test / aggregate`. + 3. **CI gating** — integration tier runs on every PR. `make test-all` + remains unit-only. + acceptance: |- + New section with three subsections. NO mention of kind or minikube + as alternative local-dev runtimes. `make lint` passes. + role: documenter + files: + - docs/guides/testing.md + - id: TASK-5-3 + description: |- + Clean up stale references in adjacent docs: + - `docs/architecture/kubernetes-migration.md`: mark + `integration_tests/docker-compose.yml` row as historical with + "(docker path retired in #2474)" annotation. + - `docs/development/STRUCTURE.md`: remove `test_e2e_workflow.py` + entry; remove or annotate `docker-compose.yml` entries. + Do NOT delete historical sections — only annotate retired artifacts. + acceptance: |- + `grep -n "test_e2e_workflow" docs/development/STRUCTURE.md` returns no live references. + `kubernetes-migration.md` mentions #2474 next to retired entries. `make lint` passes. + role: documenter + files: + - docs/architecture/kubernetes-migration.md + - docs/development/STRUCTURE.md +``` \ No newline at end of file diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml deleted file mode 100644 index e34ca839d1..0000000000 --- a/.github/workflows/test-e2e.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: E2E Tests - -on: - workflow_dispatch: - schedule: - # Run weekly on Monday at 06:00 UTC - - cron: "0 6 * * 1" - -jobs: - e2e-deterministic: - name: E2E Deterministic Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" - - - name: Install uv - uses: astral-sh/setup-uv@v4 - - - name: Install dependencies - run: uv sync --extra dev - - - name: Build containers - run: | - docker build -t egg-gateway -f gateway/Dockerfile . - docker build -t egg-sandbox -f sandbox/Dockerfile . - - - name: Set up k3s - run: | - curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--flannel-backend=none --disable-network-policy --write-kubeconfig-mode=644" sh - - export KUBECONFIG=/etc/rancher/k3s/k3s.yaml - echo "KUBECONFIG=/etc/rancher/k3s/k3s.yaml" >> "$GITHUB_ENV" - scripts/install-calico.sh - kubectl wait --for=condition=Ready node --all --timeout=120s - - - name: Import images into k3s - run: | - docker save egg-gateway:latest | sudo k3s ctr images import - - docker save egg-sandbox:latest | sudo k3s ctr images import - - - - name: Deploy egg to k3s - run: | - kubectl apply -k k8s/overlays/local/ - kubectl -n egg-system wait --for=condition=Available deployment/egg-orchestrator --timeout=120s - kubectl -n egg-system wait --for=condition=Available deployment/egg-gateway --timeout=120s - - - name: Run deterministic E2E tests - env: - ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }} - EGG_RUNTIME: kubernetes - KUBECONFIG: /etc/rancher/k3s/k3s.yaml - run: | - PYTHONPATH=shared .venv/bin/pytest integration_tests -v \ - -m "e2e and not agent_flaky" \ - --timeout=600 \ - --junitxml=e2e-deterministic-results.xml - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: e2e-deterministic-results - path: e2e-deterministic-results.xml - - - name: Cleanup - if: always() - run: | - kubectl delete namespace egg-test-agents --ignore-not-found=true 2>/dev/null || true - kubectl delete namespace egg-system --ignore-not-found=true 2>/dev/null || true - /usr/local/bin/k3s-uninstall.sh 2>/dev/null || true - - e2e-agent-fuzz: - name: E2E Agent Fuzz Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" - - - name: Install uv - uses: astral-sh/setup-uv@v4 - - - name: Install dependencies - run: uv sync --extra dev - - - name: Build containers - run: | - docker build -t egg-gateway -f gateway/Dockerfile . - docker build -t egg-sandbox -f sandbox/Dockerfile . - - - name: Set up k3s - run: | - curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--flannel-backend=none --disable-network-policy --write-kubeconfig-mode=644" sh - - export KUBECONFIG=/etc/rancher/k3s/k3s.yaml - echo "KUBECONFIG=/etc/rancher/k3s/k3s.yaml" >> "$GITHUB_ENV" - scripts/install-calico.sh - kubectl wait --for=condition=Ready node --all --timeout=120s - - - name: Import images into k3s - run: | - docker save egg-gateway:latest | sudo k3s ctr images import - - docker save egg-sandbox:latest | sudo k3s ctr images import - - - - name: Deploy egg to k3s - run: | - kubectl apply -k k8s/overlays/local/ - kubectl -n egg-system wait --for=condition=Available deployment/egg-orchestrator --timeout=120s - kubectl -n egg-system wait --for=condition=Available deployment/egg-gateway --timeout=120s - - - name: Run agent fuzz tests - env: - ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }} - AGENT_FINDINGS_DIR: ${{ github.workspace }}/agent-findings - EGG_RUNTIME: kubernetes - KUBECONFIG: /etc/rancher/k3s/k3s.yaml - run: | - PYTHONPATH=shared .venv/bin/pytest integration_tests -v \ - -m "e2e and agent_flaky" \ - --timeout=600 \ - --junitxml=e2e-agent-fuzz-results.xml - - - name: Upload fuzz results - if: always() - uses: actions/upload-artifact@v4 - with: - name: e2e-agent-fuzz-results - path: | - e2e-agent-fuzz-results.xml - agent-findings/ - - - name: Cleanup - if: always() - run: | - kubectl delete namespace egg-test-agents --ignore-not-found=true 2>/dev/null || true - kubectl delete namespace egg-system --ignore-not-found=true 2>/dev/null || true - /usr/local/bin/k3s-uninstall.sh 2>/dev/null || true diff --git a/Makefile b/Makefile index dc3fec6a56..89b4065148 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ EGG_IMAGE_TAG := $(shell git describe --always --dirty 2>/dev/null || echo lates setup deps venv sync-venv-if-uv sandbox-deps install-linters check-linters \ lint lint-python lint-shell lint-yaml lint-docker lint-actions lint-custom \ test test-all test-record-good security \ - test-integration test-e2e test-security smoketest-long-poll \ + test-integration test-security smoketest-long-poll \ lint-fix lint-python-fix lint-shell-fix lint-yaml-fix \ build \ k3s-setup k3s-secrets deploy redeploy k3s-teardown k3s-import @@ -63,9 +63,8 @@ help: @echo " make lint-actions - Actionlint (requires actionlint)" @echo " make lint-custom - Project-specific check scripts" @echo "" - @echo "Integration tests (requires Docker):" + @echo "Integration tests (requires k3s):" @echo " make test-integration - Run integration tests" - @echo " make test-e2e - Run E2E tests (requires API keys)" @echo " make test-security - Run security/pentesting tests" @echo "" @echo "Auto-fix (modifies local files):" @@ -319,9 +318,9 @@ test: sync-venv-if-uv head_sha=$$(git rev-parse HEAD 2>/dev/null || echo unknown); \ t0=$$(date +%s%N); \ if [ "$$bypass" = "1" ]; then \ - $(PYTEST) -v -m "not functional" $(PYTEST_ARGS); \ + $(PYTEST) -v $(PYTEST_ARGS); \ else \ - $(PYTEST) $$(cat "$$selected_file") -v -m "not functional" $(PYTEST_ARGS); \ + $(PYTEST) $$(cat "$$selected_file") -v $(PYTEST_ARGS); \ fi; \ pytest_rc=$$?; \ t1=$$(date +%s%N); \ @@ -345,7 +344,7 @@ test: sync-venv-if-uv test-all: export PYTHONPATH := shared:gateway:orchestrator test-all: sync-venv-if-uv ## Run the full unit-test suite + record LKG on green @echo "==> Running full unit-test suite (issue #1973: this updates LKG on green)..." - @$(PYTEST) tests/ gateway/tests/ orchestrator/tests/ shared/tests/ -v -m "not functional" $(PYTEST_ARGS); \ + @$(PYTEST) tests/ gateway/tests/ orchestrator/tests/ shared/tests/ -v $(PYTEST_ARGS); \ pytest_rc=$$?; \ if [ "$$pytest_rc" -eq 0 ]; then \ env -u PYTHONPATH $(PYTHON) scripts/select_tests/__main__.py --record-good \ @@ -387,17 +386,13 @@ security: sync-venv-if-uv fi # ============================================================================ -# Integration tests (native — requires Docker) +# Integration tests (native — requires k3s; see docs/guides/testing.md) # ============================================================================ test-integration: export PYTHONPATH := shared -test-integration: venv ## Run integration tests (requires Docker) +test-integration: venv ## Run integration tests on k3s (cross-module regressions) $(PYTEST) integration_tests -v -m integration --timeout=300 -test-e2e: export PYTHONPATH := shared -test-e2e: venv ## Run E2E tests (requires API keys) - $(PYTEST) integration_tests -v -m e2e --timeout=600 - test-security: export PYTHONPATH := shared test-security: venv ## Run security/pentesting tests $(PYTEST) integration_tests -v -m security --timeout=300 diff --git a/docs/architecture/sdlc-pipeline.md b/docs/architecture/sdlc-pipeline.md index b342004a88..ad22a55d66 100644 --- a/docs/architecture/sdlc-pipeline.md +++ b/docs/architecture/sdlc-pipeline.md @@ -83,7 +83,7 @@ The contract is a JSON document tracking the complete state of an issue through ```json { - "schemaVersion": "1.0", + "schemaVersion": "1.1", "issue": { "number": 133, "title": "...", "url": "..." }, "current_phase": "implement", "slices": [{ @@ -116,6 +116,27 @@ The contract is a JSON document tracking the complete state of an issue through > existing imports. See [Slice-DAG Implement Phase](slice-dag.md) for > the full design. +> **Schema 1.1 (#2548)**: `schemaVersion` was bumped from `1.0` to `1.1` +> to track the addition of four optional `pr.context_*` fields on +> `PRMetadata` (`context_title`, `context_description`, `context_branch`, +> `context_pr_number`) used by the dedicated context-PR mechanism. The +> bump is purely additive — pre-1.1 contracts load transparently via a +> Pydantic `model_validator(mode="after")` migration that stamps +> `schemaVersion = "1.1"` on every load when the on-disk value is exactly +> `"1.0"`; the migration is silent (no audit-log entry) and idempotent. +> `context_title` / `context_description` are planner-emitted optional +> framing for the strategic-plan PR; `context_branch` / +> `context_pr_number` are populated by the orchestrator after the context +> branch is created and the context PR is opened. +> +> **As of slice-1 (#2548 part 1)**, only the schema fields and the +> planner-prompt advertisement are wired. The orchestrator +> branch-creation and PR-opening hooks land in #2548 slices 3-4 — until +> those slices merge, the four `pr.context_*` fields are +> forward-compatibly inert: planners may emit `context_title` / +> `context_description` and the values flow into `PRMetadata`, but +> nothing acts on them yet. + ## HITL (Human-in-the-Loop) Mechanism For detailed HITL workflow documentation, see [HITL Decisions](../hitl-decisions.md). diff --git a/docs/development/STRUCTURE.md b/docs/development/STRUCTURE.md index c37efc47fa..69c4e560d0 100644 --- a/docs/development/STRUCTURE.md +++ b/docs/development/STRUCTURE.md @@ -376,11 +376,7 @@ shared/ ``` integration_tests/ ├── conftest.py # Shared fixtures for all integration tests -├── docker-compose.yml # Test environment setup -├── agent_findings.py # Security findings for agent security fuzz tests -├── test_agent_security_fuzz.py # Agent security fuzzing tests ├── test_credential_security.py # Credential isolation verification -├── test_e2e_workflow.py # End-to-end workflow tests ├── test_error_recovery.py # Error handling and recovery tests ├── test_fail_closed.py # Fail-closed security property tests ├── test_gateway_auth.py # Gateway authentication tests @@ -400,9 +396,7 @@ integration_tests/ │ └── test_escalation.py # Early-exit paths (fork, merged, empty diff) and final-push head-move escalation ├── local_pipeline/ # Orchestrator pipeline integration tests │ ├── conftest.py # Pipeline test fixtures -│ ├── docker-compose.yml # Orchestrator test environment │ ├── helpers.py # Shared API helper functions for tests -│ ├── mock-sandbox/ # Mock sandbox for testing │ ├── test_api_validation.py # API input validation tests │ ├── test_concurrent_pipelines.py # Concurrent pipeline execution tests │ ├── test_error_recovery.py # Error recovery scenario tests diff --git a/docs/guides/concurrent-execution.md b/docs/guides/concurrent-execution.md index 8ea8ce67bd..f9fd11d324 100644 --- a/docs/guides/concurrent-execution.md +++ b/docs/guides/concurrent-execution.md @@ -705,7 +705,11 @@ When the orchestrator auto-creates the PR (during the PR phase), it includes a o > _Per-phase BRC transcripts: [`refine`](./.egg-state/brc-history/42-refine.md), [`plan`](./.egg-state/brc-history/42-plan.md), [`implement`](./.egg-state/brc-history/42-implement.md)._ -Phases are ordered by canonical execution order (`refine` → `plan` → `implement` → `pr`); any non-canonical names sort alphabetically after. The line is omitted entirely when no transcript files exist on disk or the identifier is `None`. See [#1828](https://github.com/jwbron/egg/issues/1828) for why the old inline BRC Consensus Summary was removed. +In slice-aware mode (issue mode with `contract.slices`, #2548 hard switchover), the implement phase is partitioned per slice — the writer produces `{identifier}-implement-slice-.md` (one file per slice) plus `{identifier}-implement-unattributed.md` for cross-cutting messages without canonical slice scope (HEARTBEAT, OVERSEER_ALERT, AGENT_FAILED, …). The aggregate `{identifier}-implement.md` file is **not** produced in slice mode. The link line clusters the per-slice files at the canonical `implement` rank in natural-sort order, with the unattributed sibling rendered last: + +> _Per-phase BRC transcripts: [`refine`](./.egg-state/brc-history/42-refine.md), [`plan`](./.egg-state/brc-history/42-plan.md), [`implement-slice-1`](./.egg-state/brc-history/42-implement-slice-1.md), [`implement-slice-2`](./.egg-state/brc-history/42-implement-slice-2.md), [`implement-unattributed`](./.egg-state/brc-history/42-implement-unattributed.md)._ + +Babysit_pr and other non-slice implement runs continue to emit the aggregate `{identifier}-implement.md` file. Phases are ordered by canonical execution order (`refine` → `plan` → `implement` → `pr`); any non-canonical names sort alphabetically after. The line is omitted entirely when no transcript files exist on disk or the identifier is `None`. See [#1828](https://github.com/jwbron/egg/issues/1828) for why the old inline BRC Consensus Summary was removed and [#2548](https://github.com/jwbron/egg/issues/2548) for the per-slice partition. ### Consensus Check diff --git a/docs/guides/sdlc-pipeline.md b/docs/guides/sdlc-pipeline.md index b6c8252f85..3a0da38f3e 100644 --- a/docs/guides/sdlc-pipeline.md +++ b/docs/guides/sdlc-pipeline.md @@ -444,7 +444,7 @@ The local orchestrator handles concurrent contract updates through `orchestrator ```json { - "schemaVersion": "1.0", + "schemaVersion": "1.1", "issue": { "number": 123, "title": "Add feature X", @@ -483,6 +483,13 @@ The local orchestrator handles concurrent contract updates through `orchestrator } ``` +> **Schema 1.1 (#2548)**: The default `schemaVersion` is now `"1.1"`, which +> additively introduces four optional `pr.context_*` fields +> (`context_title`, `context_description`, `context_branch`, +> `context_pr_number`). Pre-1.1 contract JSON loads cleanly — a Pydantic +> `model_validator` silently promotes `"1.0"` to `"1.1"` on load and the +> bumped value is persisted on the next save. + ### Role-Based Field Ownership The `shared/egg_contracts/roles.py` module defines field ownership: diff --git a/docs/guides/testing.md b/docs/guides/testing.md index c50badc4a1..a06a542448 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -117,9 +117,9 @@ The algorithm is: intra-file filtering. 7. **Intersect with `PYTEST_ARGS`** (see §5 for the bypass rules). 8. **Run pytest.** The `make test` recipe pipes stdout into - `pytest $(SELECTED) -v -m "not functional" $(PYTEST_ARGS)`. If - the selector emits zero lines, the recipe skips the pytest - invocation and prints `no tests selected`. + `pytest $(SELECTED) -v $(PYTEST_ARGS)`. If the selector emits + zero lines, the recipe skips the pytest invocation and prints + `no tests selected`. A green narrow run **does not** update the LKG sidecar — only `make test-all` writes LKG, because only a full-suite green proves diff --git a/docs/reference/agent-roles.md b/docs/reference/agent-roles.md index 517079280b..3465dfdb8d 100644 --- a/docs/reference/agent-roles.md +++ b/docs/reference/agent-roles.md @@ -439,7 +439,9 @@ Tasks are assigned based on the files they modify: ### Validation -The YAML schema restricts the `role` field to the enum values `coder`, `tester`, and `documenter`. The plan parser also validates role values at parse time — invalid roles generate a parse warning and are treated as unassigned (`null`). +The YAML schema restricts the `role` field to the enum values `coder`, `tester`, and `documenter`. The plan parser validates role values at parse time — invalid roles generate a parse warning and are treated as unassigned (`null`). + +The orchestrator also validates **role↔file alignment** at `CONSENSUS_PROPOSE` time for `task_planner` proposals ([#2527](https://github.com/jwbron/egg/issues/2527)). A task whose `role:` assignment cannot push its `files:` — per the same `shared/egg_restrictions/patterns.py` blocklist the gateway uses at push time — causes the proposal to be rejected with HTTP 400 before it reaches reviewers. This catches structurally broken plans at plan time rather than after a producer cycle is wasted on a `403 restricted_path_modified`. The `validator` is available for manual use via `egg_contracts.plan_parser.validate_task_role_alignment(slices)`. ### Backward Compatibility diff --git a/docs/reference/agent-tools.md b/docs/reference/agent-tools.md index d5338e67af..4d978a5140 100644 --- a/docs/reference/agent-tools.md +++ b/docs/reference/agent-tools.md @@ -25,7 +25,7 @@ The MCP tool surface is **on by default** since [#1942](https://github.com/jwbro | Flag | Effect | |------|--------| -| `EGG_MCP_TOOLS` unset or any value not listed below | **Default.** Registers the 29 tools (one server per namespace) on `options.mcp_servers` and appends `SYSTEM_PROMPT_NUDGE` to `options.system_prompt`. | +| `EGG_MCP_TOOLS` unset or any value not listed below | **Default.** Registers the 31 tools (one server per namespace) on `options.mcp_servers` and appends `SYSTEM_PROMPT_NUDGE` to `options.system_prompt`. | | `EGG_MCP_TOOLS=false` (or `0` / `no` / `off`) | Opt-out. Code path is byte-identical to the pre-#1765 behaviour — no `mcp_servers` registration, no prompt changes, no import cost. | Iteration 1 (#1765) shipped the flag default-off while the wire-up burned in. @@ -39,9 +39,9 @@ Compose, or the `env` stanza on any submit-task payload. See (EGG_MCP_TOOLS flag)](../guides/sdlc-pipeline.md#agent-mcp-tools-egg_mcp_tools-flag) for the per-pipeline recipe. -## Tool inventory (29 verbs) +## Tool inventory (31 verbs) -All 29 tools are registered as `@tool`-decorated wrappers in +All 31 tools are registered as `@tool`-decorated wrappers in `sandbox/egg_agent_tools/tools/*.py`. The raw `@tool` name is the verb itself (e.g. `"propose"`, `"register_open_question"`). @@ -95,6 +95,8 @@ that requires the handler docstring to explain why no CLI exists. | `mcp__sdlc__check_hitl_answers` | Return resolved decisions and feedback (submitted or pending) for the current contract. Without a `phase` arg, returns HITL across all phases; pass `phase` to narrow to a single phase. | `handlers.sdlc.check_hitl_answers` | — *(no CLI; new capability)* | | `mcp__sdlc__show_contract` | Read the current contract as a dict. Optional `fields=[…]` projection returns only the named top-level keys; an unknown field raises `HandlerError` (no silent skip). State-machine effect: **read-only**. | `handlers.sdlc.show_contract` | `egg-contract show` | | `mcp__sdlc__verify_criterion` | Mark an acceptance criterion verified on the contract. **REVIEWER role only** — the gateway rejects non-REVIEWER writers; the handler does not re-check (decision-7). State-machine effect: marks the criterion verified; no-op if already verified. | `handlers.sdlc.verify_criterion` | `egg-contract verify-criterion` | +| `mcp__sdlc__check_file_restriction` | Pure-local read against `shared/egg_restrictions/patterns.py`: returns `can_write` + `alternative_role` for a path or list of paths. Producers call this before exploring a file outside their role boundary (#2529). Read-only; no gateway round-trip. | `handlers.restrictions.check_file_restriction` | — *(no CLI; pattern matching is pure CPU and the registry ships in the sandbox image — a CLI shim would just re-import the same module)* | +| `mcp__sdlc__report_impasse` | Persist a typed `Impasse` (category, reason, suggested_role, blocked_files, evidence, task_id) under `AgentOutput.impasse` (#2529). For `category=wrong_role`, `task_id` and `suggested_role` are **mandatory** — the handler raises `HandlerError` if either is missing, since the orchestrator's auto-delegation path needs both to rewire `task.role` unambiguously (no role-match fallback when a slice has multiple tasks per role). For other categories (`plan_bug`, `external_blocker`, `unknown`), both fields stay optional — those always escalate to HITL. The orchestrator reads the impasse post-phase and either auto-delegates to `suggested_role` (first attempt, `wrong_role` only) or escalates to HITL (second attempt or non-`wrong_role`). State-machine effect: **the agent must exit cleanly without committing after this returns**. | `handlers.restrictions.report_impasse` | — *(no CLI; structured runtime signal that lives inside agent-output JSON — a parallel CLI write path would just risk drift with the MCP one)* | ### `mcp__brc__*` — Broadcast-Review-Converge consensus @@ -169,7 +171,7 @@ tracked for a follow-up. The handlers import three pure helpers from | `mcp__checkpoint__show` | Resolve a checkpoint id → dict. Raises `HandlerError` for an unknown id. | `handlers.checkpoint.checkpoint_show` | `egg-checkpoint show` | | `mcp__checkpoint__search` | Substring search over checkpoint metadata; returns `{items, next_cursor}` with `limit`/`cursor` pagination (default `limit=100`). | `handlers.checkpoint.checkpoint_search` | `egg-checkpoint search` | -Total: **29 tools** across 6 namespaces (`sdlc`, `brc`, `phase`, +Total: **31 tools** across 6 namespaces (`sdlc`, `brc`, `phase`, `progress`, `task`, `checkpoint`) — 18 iter-1 + 12 iter-2 (#1917) = 30, then −2 in #2211 (`wait_for_event` + `wait_loop` removed; long-poll waits go through `egg-orch message wait` / `wait-loop` via Bash), then @@ -177,7 +179,7 @@ waits go through `egg-orch message wait` / `wait-loop` via Bash), then HITL (decisions + feedback + answers), phase context + completion, progress signals + overseer alerts + status queries, task completion + commits + notes + coverage-gaps, and checkpoint browsing — every -verb a pipeline agent issues on the hot path. The count (`29`) is +verb a pipeline agent issues on the hot path. The count (`31`) is asserted by `tests/sandbox/egg_agent_tools/test_server.py::TestToolRegistry::test_tool_count_registered` and the namespace set (`{sdlc, brc, phase, progress, task, @@ -288,7 +290,7 @@ namespace appears as `mcp____` in the nudge, and `mcp____` substring in the nudge corresponds to a registered namespace (extras in either direction fail CI). The companion `TestToolRegistry::test_tool_count_registered` and -`test_namespace_set_is_six` pin `len(TOOL_REGISTRY) == 29` and +`test_namespace_set_is_six` pin `len(TOOL_REGISTRY) == 31` and `set(TOOL_NAMESPACES.keys()) == {"sdlc", "brc", "phase", "progress", "task", "checkpoint"}` so a future iteration cannot drift the prose counts in this file silently. @@ -455,7 +457,7 @@ complete shell CLI surface. - **`EGG_MCP_TOOLS` flag removal (decision-9 of #1917):** Kept for iter-2 burn-in; removal is a third follow-up. - **Timeouts:** The SDK's default 60 s MCP-tool timeout is sufficient - for all 29 verbs (none are long-running). Pagination (decision-12 + for all 31 verbs (none are long-running). Pagination (decision-12 of #1917) keeps `read_peer_artifact` / `checkpoint_list` / `checkpoint_search` page sizes well under the limit. If a future tool needs to exceed 60 s, it must be restructured as a @@ -483,7 +485,7 @@ SDK release notes rather than silently breaking every sandbox. | `tests/sandbox/egg_agent_tools/test_handlers_*.py` | Unit tests for each handler (happy-path, missing-arg, 5xx gateway → `GatewayError`). | | `tests/sandbox/egg_agent_tools/handlers/test_*.py` | Per-handler unit tests for the iter-2 verbs (`show_contract`, `add_commit`, `update_notes`, `complete_phase`, `verify_criterion`, `read_peer_artifact`, `overseer_alert`, `query_status`, `checkpoint`, `mark_gap`). | | `tests/sandbox/egg_agent_tools/test_tools.py` | `@tool` wrappers (JSON-serialised success; `is_error=True` structured block on handler exception). | -| `tests/sandbox/egg_agent_tools/test_server.py` | `build_sandbox_mcp_server` registers all 29 tools; `SYSTEM_PROMPT_NUDGE` symmetric drift test; derived-count assertions (`len(TOOL_REGISTRY) == 29` and the 6-namespace set). | +| `tests/sandbox/egg_agent_tools/test_server.py` | `build_sandbox_mcp_server` registers all 31 tools; `SYSTEM_PROMPT_NUDGE` symmetric drift test; derived-count assertions (`len(TOOL_REGISTRY) == 31` and the 6-namespace set). | | `tests/sandbox/egg_agent_tools/test_schemas.py` | `derive_schema_from_argparse` correctness + override merge. | | `tests/sandbox/egg_agent_tools/test_sdk_surface.py` | SDK import smoke (fails loud on incompatible SDK upgrade). | | `tests/sandbox/egg_agent_tools/test_full_tool_registry.py` | Integration test: loads `TOOL_LIST` via `create_sdk_mcp_server`; asserts no registration errors and that completion/mutation verbs (`task_complete`, `phase__complete_phase`, `task__add_commit`, `sdlc__verify_criterion`) name the state-machine effect in their description. | diff --git a/docs/templates/plan.md b/docs/templates/plan.md index c7f31298ea..a1435f3b35 100644 --- a/docs/templates/plan.md +++ b/docs/templates/plan.md @@ -74,6 +74,14 @@ pr: manual_steps: | Pre-merge: [any required steps before merging, e.g. migrations, config changes] Post-merge: [any required steps after merging, e.g. deployments] + # Optional context-PR framing (#2548); omit to reuse pr.title / pr.description. + # context_title: |- + # Strategic plan for # — refine/plan analysis + BRC history + # context_description: |- + # Carries the refine analysis, the plan, the BRC consensus + # history that approved each, and the agent transcripts — + # so reviewers approaching the slice stack can see the strategic + # narrative on a PR that targets the configured base branch. phases: - id: 1 name: |- @@ -125,6 +133,23 @@ phases: > the task's files — see [Agent Roles Reference](../reference/agent-roles.md#role-aware-task-assignment) > for the file-to-role mapping. Tasks without a `role` default to the coder. +> **Context-PR framing (#2548)**: `pr.context_title` and `pr.context_description` +> are *optional* keys planners may emit to give the dedicated context PR a +> different framing from the slice PRs (e.g. "Strategic plan for #N" vs the +> slice's "Implement …"). When omitted the orchestrator falls back to +> `pr.title` / `pr.description`. Two sibling fields — `pr.context_branch` and +> `pr.context_pr_number` — exist on the contract but are populated by the +> orchestrator after the context branch is created and the context PR is +> opened; planners must NOT emit them. +> +> **As of slice-1 (#2548 part 1)**, only the schema fields and this +> planner-prompt guidance are wired. The orchestrator branch-creation +> and PR-opening hooks land in #2548 slices 3-4 — until those slices +> merge, any `context_title` / `context_description` a planner emits +> flows through the parser into `PRMetadata` but nothing acts on it +> yet, so emitting them now is forward-compatibly safe but does not +> change the rendered PR. + > **Slices vs. phases (#2137)**: The plan parser accepts either `slices:` > (canonical, post-#2137) or `phases:` (legacy alias) at the top of the > `# yaml-tasks` block. New plans should emit `slices:` so they ingest as diff --git a/integration_tests/agent_findings.py b/integration_tests/agent_findings.py deleted file mode 100644 index 737f57d7cb..0000000000 --- a/integration_tests/agent_findings.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Agent findings logger for integration tests. - -Records agent-led test findings to a JSONL log so that reproducible -edge cases can be codified as deterministic tests over time. - -Workflow: - 1. Agent fuzz tests call ``record_finding()`` with verdict data. - 2. Findings are appended to ``integration_tests/findings/.jsonl`` - (or ``$AGENT_FINDINGS_DIR`` if set). - 3. CI uploads the findings directory as a workflow artifact. - 4. A human reviews findings, writes deterministic tests for - reproducible ones, and marks them ``codified=True``. -""" - -import json -import os -import time -from pathlib import Path -from typing import Any - -_DEFAULT_DIR = Path(__file__).parent / "findings" - - -def _findings_dir() -> Path: - """Return the directory for findings output.""" - env = os.environ.get("AGENT_FINDINGS_DIR") - if env: - return Path(env) - return _DEFAULT_DIR - - -def record_finding( - test_name: str, - verdict: Any, - *, - category: str = "general", - codified: bool = False, -) -> Path: - """Append a finding to the JSONL log. - - Args: - test_name: Fully qualified test name. - verdict: An ``AgentVerdict`` or dict with verdict data. - category: Classification (e.g. "security", "network", "general"). - codified: True if a deterministic test already covers this finding. - - Returns: - Path to the findings file that was written to. - """ - out_dir = _findings_dir() - out_dir.mkdir(parents=True, exist_ok=True) - - date_str = time.strftime("%Y-%m-%d") - out_file = out_dir / f"{date_str}.jsonl" - - # Normalise verdict to dict - if hasattr(verdict, "__dataclass_fields__"): - from dataclasses import asdict - - verdict_data = asdict(verdict) - elif isinstance(verdict, dict): - verdict_data = verdict - else: - verdict_data = {"raw": str(verdict)} - - entry = { - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "test_name": test_name, - "category": category, - "verdict": verdict_data.get("verdict", "unknown"), - "evidence": verdict_data.get("evidence", ""), - "details": verdict_data.get("details", []), - "cost_usd": verdict_data.get("cost_usd"), - "codified": codified, - } - - with open(out_file, "a") as f: - f.write(json.dumps(entry) + "\n") - - return out_file diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py index b14411881f..65d2e48a44 100644 --- a/integration_tests/conftest.py +++ b/integration_tests/conftest.py @@ -2,16 +2,24 @@ Provides: - EggStack dataclass with gateway URL, IPs, launcher secret, and helpers -- egg_stack (session-scoped): starts/stops the gateway via docker compose +- egg_stack (session-scoped): starts/stops the gateway via Kubernetes (k3s) - gateway_session (function-scoped): creates/destroys a gateway session per test -- test_container: starts/stops an alpine container on a given network -- run_claude_structured(): runs Claude Code with JSON schema output parsing -- AgentVerdict / assert_agent_verdict(): structured verdict helpers - -All Docker-dependent fixtures skip gracefully when Docker is unavailable. +- isolated_container / external_container / test_container: alpine helper + fixtures for tests that still need ad-hoc docker containers attached to a + network (legacy — slated for replacement by k3s-native equivalents) + +Issue #2474 retired the docker-compose stack runtime; the only supported +test runtime is k3s. + +The ``isolated_container`` / ``external_container`` / ``test_container`` +fixtures shell out to ``docker run --network `` and only worked +under the old docker-compose stack. Under k3s ``egg_stack.isolated_network`` +is a Kubernetes namespace, not a docker network, so those fixtures +``pytest.skip`` with a clear message — ``test_credential_security.py:: +TestCredentialIsolation`` (the main consumer) is currently uncovered in +CI and needs a k3s-native replacement before it runs again. """ -import json import os import secrets import shutil @@ -24,19 +32,19 @@ from typing import Any import pytest -import requests from egg_config import GATEWAY_PORT, GATEWAY_PROXY_PORT -from egg_container import ( - ContainerNetworkConfig, - build_sandbox_docker_cmd, -) from tests.utils.gateway_client import ( GatewayClientMixin, - docker_available, wait_for_healthy, ) +# Public surface re-exported by other test modules. GATEWAY_PORT is +# re-exported from shared/egg_config/constants.py for tests that +# historically imported it from this module (e.g. +# integration_tests/test_network_security.py). +__all__ = ["EggStack", "ContainerInfo", "GATEWAY_PORT", "exec_in_container"] + # Project root (one level up from integration_tests/) PROJECT_ROOT = Path(__file__).parent.parent @@ -49,10 +57,6 @@ # Use constants from shared module for port configuration PROXY_PORT = GATEWAY_PROXY_PORT -# Counter for allocating unique container IPs within the test subnet. -# Starts at 100 to leave room for gateway (.2) and other infrastructure. -_next_container_ip_suffix = 100 - @dataclass class ContainerInfo: @@ -68,7 +72,7 @@ class EggStack(GatewayClientMixin): """Running integration test stack state. Inherits common API methods from GatewayClientMixin to reduce - duplication with tests/functional/conftest.py:MinimalGateway. + duplication in test helper code. """ gateway_url: str @@ -77,11 +81,18 @@ class EggStack(GatewayClientMixin): gateway_port: int proxy_port: int launcher_secret: str + # Under k3s this carries the ``k8s-`` sentinel — legacy + # docker-only fixtures key off the prefix to skip cleanly. Some tests + # (e.g. test_stack_lifecycle, test_worktree_integration) still consume + # it directly to build docker container names; those silently fail to + # find a container under k3s and are tracked for k3s-native rewrites. compose_project: str config_dir: str + # Both networks point at the same k8s namespace today; the duplicated + # field is retained so legacy fixtures keep their isolated/external + # split until the k3s-native replacements land. isolated_network: str external_network: str - certs_volume: str = "" # Docker volume name for gateway CA certs source_ip: str = "" # Auto-detected: IP the gateway sees for our requests _containers: list[str] = field(default_factory=list) @@ -262,109 +273,21 @@ def _k8s_egg_stack() -> Generator[EggStack]: shutil.rmtree(config_dir, ignore_errors=True) -def _docker_egg_stack() -> Generator[EggStack]: - """Create an EggStack backed by docker compose (legacy path).""" - if not docker_available(): - pytest.skip("Docker is not available") - - compose_file = PROJECT_ROOT / "integration_tests" / "docker-compose.yml" - if not compose_file.exists(): - pytest.skip("docker-compose.yml not found") - - project_name = f"egg-test-{os.getpid()}" - launcher_secret = secrets.token_urlsafe(32) - config_dir = tempfile.mkdtemp(prefix="egg-test-config-") - _write_test_config(config_dir, launcher_secret) - - env = { - **os.environ, - "COMPOSE_PROJECT_NAME": project_name, - "EGG_LAUNCHER_SECRET": launcher_secret, - "EGG_CONFIG_DIR": config_dir, - "HOST_UID": str(os.getuid()), - "HOST_GID": str(os.getgid()), - "GATEWAY_PORT": "0", - "PROXY_PORT": "0", - } - - compose_cmd = ["docker", "compose", "-f", str(compose_file), "-p", project_name] - - try: - subprocess.run( - [*compose_cmd, "up", "-d", "--build"], - env=env, - capture_output=True, - text=True, - timeout=300, - check=True, - ) - - result = subprocess.run( - [*compose_cmd, "port", "gateway", str(GATEWAY_PORT)], - env=env, - capture_output=True, - text=True, - timeout=10, - check=True, - ) - host_port = result.stdout.strip().split(":")[-1] - gateway_url = f"http://localhost:{host_port}" - - if not wait_for_healthy(gateway_url, timeout=120): - logs = subprocess.run( - [*compose_cmd, "logs", "gateway"], - env=env, - capture_output=True, - text=True, - timeout=10, - check=False, - ) - pytest.fail( - f"Gateway did not become healthy within 120s.\nLogs:\n{logs.stdout}\n{logs.stderr}" - ) - - stack = EggStack( - gateway_url=gateway_url, - gateway_isolated_ip=GATEWAY_ISOLATED_IP, - gateway_external_ip=GATEWAY_EXTERNAL_IP, - gateway_port=int(host_port), - proxy_port=PROXY_PORT, - launcher_secret=launcher_secret, - compose_project=project_name, - config_dir=config_dir, - isolated_network=f"{project_name}-isolated", - external_network=f"{project_name}-external", - certs_volume=f"{project_name}_certs", - ) - stack.detect_source_ip() - - yield stack - - finally: - subprocess.run( - [*compose_cmd, "down", "-v", "--remove-orphans"], - env=env, - capture_output=True, - timeout=60, - check=False, - ) - shutil.rmtree(config_dir, ignore_errors=True) - - @pytest.fixture(scope="session") def egg_stack() -> Generator[EggStack]: """Session-scoped fixture: start the gateway stack. - Selects Kubernetes or Docker backend based on the EGG_RUNTIME env var. - In k8s mode, expects the gateway to be pre-deployed in the cluster. - In Docker mode, starts the gateway via docker compose. + Backed exclusively by Kubernetes (k3s). Skips with a clear message + if ``kubectl`` is unavailable — see ``docs/guides/testing.md`` for + the k3s-on-host setup recipe. """ - runtime = os.environ.get("EGG_RUNTIME", "docker") + if not _kubectl_available(): + pytest.skip( + "kubectl is not available or not connected to a cluster — " + "integration tests require k3s (see docs/guides/testing.md)" + ) - if runtime == "kubernetes" and _kubectl_available(): - yield from _k8s_egg_stack() - else: - yield from _docker_egg_stack() + yield from _k8s_egg_stack() @pytest.fixture @@ -489,11 +412,24 @@ def exec_in_container( return result.returncode, result.stdout, result.stderr +_LEGACY_DOCKER_FIXTURE_SKIP = ( + "legacy docker-network container fixtures are not supported under k3s — " + "k3s-native replacement TBD (see integration_tests/conftest.py docstring)" +) + + +def _skip_if_k8s_backed(stack: EggStack) -> None: + """Skip the test if the stack is k8s-backed (legacy docker fixtures only).""" + if stack.compose_project.startswith("k8s-"): + pytest.skip(_LEGACY_DOCKER_FIXTURE_SKIP) + + @pytest.fixture def isolated_container( egg_stack: EggStack, ) -> Generator[ContainerInfo]: """Function-scoped fixture: alpine container on the isolated (private) network.""" + _skip_if_k8s_backed(egg_stack) container = _start_container(egg_stack.isolated_network, "isolated") if not container: pytest.skip("Could not start container on isolated network") @@ -506,6 +442,7 @@ def external_container( egg_stack: EggStack, ) -> Generator[ContainerInfo]: """Function-scoped fixture: alpine container on the external (public) network.""" + _skip_if_k8s_backed(egg_stack) container = _start_container(egg_stack.external_network, "external") if not container: pytest.skip("Could not start container on external network") @@ -522,6 +459,7 @@ def test_something(test_container): container = test_container(network="egg-test-isolated", dns="0.0.0.0") ... """ + _skip_if_k8s_backed(egg_stack) containers: list[str] = [] def _factory( @@ -542,361 +480,3 @@ def _factory( for cid in containers: _cleanup_container(cid) - - -# --------------------------------------------------------------------------- -# Structured output helpers for agent-led testing -# --------------------------------------------------------------------------- - -VERDICT_SCHEMA = { - "type": "object", - "properties": { - "verdict": { - "type": "string", - "enum": ["pass", "fail"], - "description": "Whether the test condition was met.", - }, - "evidence": { - "type": "string", - "description": "Concrete output or observation supporting the verdict.", - }, - "details": { - "type": "array", - "items": { - "type": "object", - "properties": { - "check": {"type": "string"}, - "passed": {"type": "boolean"}, - "note": {"type": "string"}, - }, - "required": ["check", "passed"], - }, - "description": "Individual sub-checks performed.", - }, - }, - "required": ["verdict", "evidence"], -} - -TEST_AGENT_SYSTEM_PROMPT = ( - "You are a TEST agent evaluating the egg sandbox. You did NOT build this code. " - "Report findings as structured verdicts. Be precise and factual." -) - - -@dataclass -class AgentVerdict: - """Parsed result from a structured agent run.""" - - verdict: str - evidence: str - details: list[dict[str, Any]] - raw_output: str - cost_usd: float | None - infrastructure_failure: bool = False # Set by run_claude_structured, not the agent - - @property - def passed(self) -> bool: - return self.verdict == "pass" - - -def _allocate_test_container_ip() -> str: - """Allocate a unique IP address for a test container. - - Production uses ``_allocate_container_ip()`` which inspects the docker - network to find available IPs. For tests, we use a simple counter to - avoid the subprocess overhead and race conditions in parallel tests. - - Returns: - An IP in the 172.40.0.100+ range (test isolated subnet). - """ - global _next_container_ip_suffix - ip = f"172.40.0.{_next_container_ip_suffix}" - _next_container_ip_suffix += 1 - # Wrap around if we somehow allocate >155 containers in one session - if _next_container_ip_suffix > 254: - _next_container_ip_suffix = 100 - return ip - - -def _capture_container_logs(container_name: str) -> str: - """Capture logs from a container (even if it crashed or is still running). - - Used for post-mortem diagnostics when tests timeout or fail unexpectedly. - """ - try: - result = subprocess.run( - ["docker", "logs", "--tail", "200", container_name], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - logs = [] - if result.stdout: - logs.append(f"=== STDOUT ===\n{result.stdout}") - if result.stderr: - logs.append(f"=== STDERR ===\n{result.stderr}") - return "\n".join(logs) if logs else "(no logs captured)" - except subprocess.TimeoutExpired: - return "(log capture timed out)" - except Exception as e: - return f"(log capture failed: {e})" - - -def _preflight_gateway_check(egg_stack: EggStack, timeout: int = 10) -> tuple[bool, str]: - """Perform a pre-flight health check on the gateway before spawning containers. - - This catches infrastructure issues early (before the 180s test timeout) - and provides clear diagnostics when the gateway is not ready. - - Returns: - (success, message) tuple - """ - try: - health = egg_stack.health_check(timeout=timeout) - status = health.get("status", "unknown") - if status == "healthy": - return True, "Gateway healthy" - return False, f"Gateway status: {status} (expected: healthy)" - except requests.exceptions.ConnectionError as e: - return False, f"Gateway unreachable: {e}" - except requests.exceptions.Timeout: - return False, f"Gateway health check timed out after {timeout}s" - except Exception as e: - return False, f"Gateway health check failed: {type(e).__name__}: {e}" - - -def run_claude_structured( - egg_stack: EggStack, - session_token: str, - prompt: str, - *, - model: str = "sonnet", - max_budget_usd: float = 0.50, - timeout: int = 180, - extra_system: str = "", -) -> AgentVerdict: - """Run Claude Code with structured JSON output in a sandbox container. - - Uses ``--output-format json`` and ``--json-schema`` to get a parsed - verdict back from the agent. The agent identity is established via - ``--append-system-prompt`` to separate it from the building agent. - - Network configuration coupling: - This function constructs ``ContainerNetworkConfig`` manually from - ``EggStack`` values rather than calling ``_get_container_network_config()`` - (which is internal to ``sandbox/egg_lib/runtime.py``). If that function - gains new fields or changes defaults, this test helper must be updated - to match. The shared ``build_sandbox_docker_cmd()`` ensures the *docker - arguments* stay in sync, but the *config dataclass population* is a - separate coupling point. - """ - # Pre-flight check: verify gateway is healthy before spawning container - # This catches infrastructure issues early with clear diagnostics - preflight_ok, preflight_msg = _preflight_gateway_check(egg_stack) - if not preflight_ok: - return AgentVerdict( - verdict="fail", - evidence=f"Pre-flight gateway check failed: {preflight_msg}", - details=[{"check": "gateway_preflight", "passed": False, "note": preflight_msg}], - raw_output="", - cost_usd=None, - infrastructure_failure=True, - ) - - system_prompt = TEST_AGENT_SYSTEM_PROMPT - if extra_system: - system_prompt = f"{system_prompt} {extra_system}" - - schema_json = json.dumps(VERDICT_SCHEMA) - - net_config = ContainerNetworkConfig( - network_name=egg_stack.isolated_network, - gateway_hostname="egg-gateway", - gateway_ip=egg_stack.gateway_isolated_ip, - gateway_port=GATEWAY_PORT, - repo_mode="private", - proxy_url=f"http://egg-gateway:{PROXY_PORT}", - ) - - # Allocate a static IP for this container — matches production behavior - # where sessions are bound to specific container IPs for request verification. - container_ip = _allocate_test_container_ip() - - # Generate a predictable container name so we can capture logs on timeout - container_name = f"test-claude-{os.getpid()}-{time.time_ns()}" - - cmd = build_sandbox_docker_cmd( - container_name=container_name, - image="egg-sandbox:latest", - network=net_config, - container_ip=container_ip, - session_token=session_token, - runtime_uid=1000, - runtime_gid=1000, - extra_env={ - "ANTHROPIC_OAUTH_TOKEN": os.environ["ANTHROPIC_OAUTH_TOKEN"], - # Match production: always set auth method so sandbox startup code - # branches the same way in tests as in production. - "ANTHROPIC_AUTH_METHOD": "oauth", - # Reduce gateway health check timeout for faster test feedback - # (default 60s is too long when debugging test failures) - "EGG_GATEWAY_TIMEOUT": "30", - # Enable debug logging for startup phases (logs to stderr) - # This helps diagnose container hangs by showing which phase stalled - "EGG_DEBUG": "1", - }, - ) - - # Note: We intentionally do NOT use --rm here so we can capture logs on timeout. - # Production uses the LIFECYCLE_FLAGS_INDEX pattern in build_sandbox_docker_cmd() - # to include --rm; we diverge here because tests need post-mortem log access. - # Cleanup happens in the finally block below. - - # Mount the gateway CA certificate volume so the sandbox can trust the proxy - # The volume is created by docker-compose and populated by the gateway entrypoint - if egg_stack.certs_volume: - cmd[-1:-1] = ["-v", f"{egg_stack.certs_volume}:/shared/certs:ro"] - - # Claude CLI command after image name - cmd.extend( - [ - "claude", - "--print", - "--output-format", - "json", - "--json-schema", - schema_json, - "--append-system-prompt", - system_prompt, - "--no-session-persistence", - "--max-budget-usd", - str(max_budget_usd), - "--model", - model, - "--dangerously-skip-permissions", - prompt, - ] - ) - - def _cleanup_container() -> None: - """Remove the test container (best-effort).""" - subprocess.run( - ["docker", "rm", "-f", container_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=15, - check=False, - ) - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - except subprocess.TimeoutExpired as e: - # Capture container logs before cleanup for post-mortem analysis - container_logs = _capture_container_logs(container_name) - _cleanup_container() - - raw = (e.stdout or "")[:2000] if e.stdout else "" - stderr = (e.stderr or "")[:500] if e.stderr else "" - return AgentVerdict( - verdict="fail", - evidence=( - f"Subprocess timed out after {timeout}s.\n" - f"stderr: {stderr}\n" - f"Container logs:\n{container_logs[:3000]}" - ), - details=[], - raw_output=raw, - cost_usd=None, - infrastructure_failure=True, - ) - - raw = result.stdout.strip() - - if result.returncode != 0: - # Capture container logs for debugging non-zero exit - container_logs = _capture_container_logs(container_name) - _cleanup_container() - return AgentVerdict( - verdict="fail", - evidence=( - f"Claude Code exited {result.returncode}: {result.stderr[:500]}\n" - f"Container logs:\n{container_logs[:3000]}" - ), - details=[], - raw_output=raw, - cost_usd=None, - infrastructure_failure=True, - ) - - # Success path - cleanup container - _cleanup_container() - - try: - envelope = json.loads(raw) - except json.JSONDecodeError: - return AgentVerdict( - verdict="fail", - evidence=f"Could not parse JSON output: {raw[:500]}", - details=[], - raw_output=raw, - cost_usd=None, - infrastructure_failure=True, - ) - - # The envelope from --output-format json wraps the schema result. - # Extract the verdict payload — it may be at top level or nested - # under a "result" key depending on Claude Code version. - payload = envelope.get("result", envelope) - - cost = None - if "cost_usd" in envelope: - cost = envelope["cost_usd"] - - return AgentVerdict( - verdict=payload.get("verdict", "fail"), - evidence=payload.get("evidence", ""), - details=payload.get("details", []), - raw_output=raw, - cost_usd=cost, - ) - - -def assert_agent_verdict( - verdict: AgentVerdict, - *, - min_pass_ratio: float = 1.0, - msg: str = "", -) -> None: - """Assert that an agent verdict meets expectations. - - Args: - verdict: The AgentVerdict to check. - min_pass_ratio: Fraction of detail checks that must pass (0.0-1.0). - Use < 1.0 for flaky tolerance. - msg: Optional context message for assertion errors. - """ - context = f" ({msg})" if msg else "" - - if verdict.details: - passed = sum(1 for d in verdict.details if d.get("passed")) - total = len(verdict.details) - ratio = passed / total if total else 0.0 - assert ratio >= min_pass_ratio, ( - f"Agent detail checks{context}: {passed}/{total} passed " - f"(need {min_pass_ratio:.0%}).\n" - f"Evidence: {verdict.evidence}\n" - f"Details: {json.dumps(verdict.details, indent=2)}" - ) - - assert verdict.passed, ( - f"Agent verdict: FAIL{context}.\n" - f"Evidence: {verdict.evidence}\n" - f"Raw output: {verdict.raw_output[:1000]}" - ) diff --git a/integration_tests/local_pipeline/conftest.py b/integration_tests/local_pipeline/conftest.py index 3bee8515ac..71381ca93c 100644 --- a/integration_tests/local_pipeline/conftest.py +++ b/integration_tests/local_pipeline/conftest.py @@ -2,13 +2,20 @@ Provides: - LocalPipelineStack dataclass with gateway/orchestrator URLs -- local_pipeline_stack (session-scoped): builds mock sandbox, starts compose, - waits for health, tears down -- orchestrator_url / gateway_url (session-scoped): extracted from compose ports -- wait_for_pipeline_terminal(): polls pipeline status until complete/failed/cancelled +- local_pipeline_stack (session-scoped): runs against a Kubernetes (k3s) + cluster. Expects gateway and orchestrator to already be deployed in + the egg-system namespace. +- orchestrator_url / gateway_url / launcher_secret (session-scoped): + shortcuts derived from local_pipeline_stack +- wait_for_pipeline_terminal(): polls pipeline status until + complete/failed/cancelled (re-exported from .helpers for backwards + compatibility with tests that imported it from conftest). + +Issue #2474 retired the docker-compose runtime; the only supported +backend is k3s. Tests skip with a clear message when ``kubectl`` is +unavailable. """ -import json import os import secrets import shutil @@ -19,20 +26,12 @@ from pathlib import Path import pytest -from egg_config import GATEWAY_PORT -from egg_config.constants import ORCHESTRATOR_PORT -from tests.utils.gateway_client import docker_available, wait_for_healthy +from tests.utils.gateway_client import wait_for_healthy # Project root (two levels up from integration_tests/local_pipeline/) PROJECT_ROOT = Path(__file__).parent.parent.parent -# Compose file for this test suite -COMPOSE_FILE = Path(__file__).parent / "docker-compose.yml" - -# Mock sandbox build context -MOCK_SANDBOX_DIR = Path(__file__).parent / "mock-sandbox" - def _write_test_config(config_dir: str, launcher_secret: str) -> None: """Generate minimal gateway config files for testing.""" @@ -96,25 +95,6 @@ class LocalPipelineStack: from .helpers import wait_for_pipeline_terminal # noqa: E402, F401 -def _cleanup_orphaned_containers() -> None: - """Remove any leftover egg-sandbox containers from previous test runs.""" - result = subprocess.run( - ["docker", "ps", "-a", "--filter", "name=egg-sandbox-egg-", "--format", "{{.Names}}"], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - for name in result.stdout.strip().splitlines(): - if name: - subprocess.run( - ["docker", "rm", "-f", name], - capture_output=True, - timeout=10, - check=False, - ) - - def _kubectl_available() -> bool: """Check if kubectl is available and can connect to a cluster.""" try: @@ -256,237 +236,19 @@ def _k8s_local_pipeline_stack() -> Generator[LocalPipelineStack]: @pytest.fixture(scope="session") def local_pipeline_stack() -> Generator[LocalPipelineStack]: - """Session-scoped fixture: build mock sandbox, start gateway+orchestrator. + """Session-scoped fixture: gateway+orchestrator running in Kubernetes (k3s). - Selects Kubernetes or Docker backend based on the EGG_RUNTIME env var. + Skips with a clear message if ``kubectl`` is not available — see + ``docs/guides/testing.md`` for the k3s-on-host setup recipe. """ - runtime = os.environ.get("EGG_RUNTIME", "docker") - - if runtime == "kubernetes" and _kubectl_available(): - yield from _k8s_local_pipeline_stack() - return - - if not docker_available(): - pytest.skip("Docker is not available") - - if not COMPOSE_FILE.exists(): - pytest.skip("local_pipeline docker-compose.yml not found") - - project_name = f"egg-lp-test-{os.getpid()}" - launcher_secret = secrets.token_urlsafe(32) - - config_dir = tempfile.mkdtemp(prefix="egg-lp-test-config-") - repos_dir = tempfile.mkdtemp(prefix="egg-lp-test-repos-") - _write_test_config(config_dir, launcher_secret) - - # Initialize a bare git repo so the orchestrator's state store works - subprocess.run( - ["git", "init", repos_dir], - capture_output=True, - check=True, - timeout=10, - ) - subprocess.run( - ["git", "-C", repos_dir, "config", "user.name", "test"], - capture_output=True, - check=True, - timeout=10, - ) - subprocess.run( - ["git", "-C", repos_dir, "config", "user.email", "test@test.com"], - capture_output=True, - check=True, - timeout=10, - ) - # Add a fake origin remote so gateway push endpoint can resolve repo names - subprocess.run( - [ - "git", - "-C", - repos_dir, - "remote", - "add", - "origin", - "https://github.com/test-owner/test-repo.git", - ], - capture_output=True, - check=True, - timeout=10, - ) - # Create initial commit so git operations work - Path(repos_dir, ".gitkeep").touch() - subprocess.run( - ["git", "-C", repos_dir, "add", "."], - capture_output=True, - check=True, - timeout=10, - ) - subprocess.run( - ["git", "-C", repos_dir, "commit", "-m", "init", "--no-verify"], - capture_output=True, - check=True, - timeout=10, - ) - - # Generate docker-compose.override.yml with per-repo volume mounts - repo_name = "test-repo" - override_file = Path(config_dir) / "docker-compose.override.yml" - override_file.write_text( - f"# Auto-generated for testing\n" - f"services:\n" - f" gateway:\n" - f" volumes:\n" - f" - {repos_dir}:/home/egg/repos/{repo_name}\n" - f" orchestrator:\n" - f" volumes:\n" - f" - {repos_dir}:/home/egg/repos/{repo_name}\n" - ) - - env = { - **os.environ, - "COMPOSE_PROJECT_NAME": project_name, - "EGG_LAUNCHER_SECRET": launcher_secret, - "EGG_CONFIG_DIR": config_dir, - "EGG_HOST_REPO_MAP": json.dumps({repo_name: repos_dir}), - "HOST_UID": str(os.getuid()), - "HOST_GID": str(os.getgid()), - "GATEWAY_PORT": "0", - "PROXY_PORT": "0", - "ORCHESTRATOR_PORT": "0", - } - - compose_cmd = [ - "docker", - "compose", - "-f", - str(COMPOSE_FILE), - "-f", - str(override_file), - "-p", - project_name, - ] - - try: - # Clean up any orphaned sandbox containers from previous test runs - # that might cause name conflicts - _cleanup_orphaned_containers() - - # Build mock-sandbox image first - subprocess.run( - [ - "docker", - "build", - "-t", - "mock-sandbox:latest", - str(MOCK_SANDBOX_DIR), - ], - capture_output=True, - text=True, - timeout=120, - check=True, - ) - - # Build and start the compose stack - result = subprocess.run( - [*compose_cmd, "up", "-d", "--build"], - env=env, - capture_output=True, - text=True, - timeout=300, - check=False, - ) - if result.returncode != 0: - pytest.fail( - f"docker compose up failed (exit {result.returncode}).\n" - f"STDOUT:\n{result.stdout[-3000:]}\n" - f"STDERR:\n{result.stderr[-3000:]}" - ) - - # Get mapped gateway port - port_result = subprocess.run( - [*compose_cmd, "port", "gateway", str(GATEWAY_PORT)], - env=env, - capture_output=True, - text=True, - timeout=10, - check=True, - ) - gateway_host_port = port_result.stdout.strip().split(":")[-1] - gateway_url = f"http://localhost:{gateway_host_port}" - - # Get mapped orchestrator port - port_result = subprocess.run( - [*compose_cmd, "port", "orchestrator", str(ORCHESTRATOR_PORT)], - env=env, - capture_output=True, - text=True, - timeout=10, - check=True, - ) - orchestrator_host_port = port_result.stdout.strip().split(":")[-1] - orchestrator_url = f"http://localhost:{orchestrator_host_port}" - - # Wait for gateway to become healthy - if not wait_for_healthy(gateway_url, timeout=120): - logs = subprocess.run( - [*compose_cmd, "logs", "gateway"], - env=env, - capture_output=True, - text=True, - timeout=10, - check=False, - ) - pytest.fail( - f"Gateway did not become healthy within 120s.\nLogs:\n{logs.stdout}\n{logs.stderr}" - ) - - # Wait for orchestrator to become healthy - if not wait_for_healthy(orchestrator_url, timeout=120): - logs = subprocess.run( - [*compose_cmd, "logs", "orchestrator"], - env=env, - capture_output=True, - text=True, - timeout=10, - check=False, - ) - pytest.fail( - f"Orchestrator did not become healthy within 120s.\n" - f"Logs:\n{logs.stdout}\n{logs.stderr}" - ) - - stack = LocalPipelineStack( - gateway_url=gateway_url, - orchestrator_url=orchestrator_url, - launcher_secret=launcher_secret, - compose_project=project_name, - config_dir=config_dir, - repos_dir=repos_dir, - ) - - yield stack - - finally: - # Dump logs for debugging if tests fail - subprocess.run( - [*compose_cmd, "logs", "--tail", "100"], - env=env, - capture_output=False, - timeout=30, - check=False, - ) - - # Tear down - subprocess.run( - [*compose_cmd, "down", "-v", "--remove-orphans"], - env=env, - capture_output=True, - timeout=60, - check=False, + if not _kubectl_available(): + pytest.skip( + "kubectl is not available or not connected to a cluster — " + "local pipeline integration tests require k3s " + "(see docs/guides/testing.md)" ) - shutil.rmtree(config_dir, ignore_errors=True) - shutil.rmtree(repos_dir, ignore_errors=True) + yield from _k8s_local_pipeline_stack() @pytest.fixture(scope="session") diff --git a/integration_tests/local_pipeline/mock-sandbox/Dockerfile b/integration_tests/local_pipeline/mock-sandbox/Dockerfile deleted file mode 100644 index 9e5df6fac9..0000000000 --- a/integration_tests/local_pipeline/mock-sandbox/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM alpine:latest -RUN apk add --no-cache curl -COPY phase-runner.sh /phase-runner.sh -RUN chmod +x /phase-runner.sh -ENTRYPOINT ["/phase-runner.sh"] diff --git a/integration_tests/local_pipeline/mock-sandbox/phase-runner.sh b/integration_tests/local_pipeline/mock-sandbox/phase-runner.sh deleted file mode 100644 index 14199ba13b..0000000000 --- a/integration_tests/local_pipeline/mock-sandbox/phase-runner.sh +++ /dev/null @@ -1,317 +0,0 @@ -#!/bin/sh -# Mock sandbox phase runner for integration tests. -# -# Validates that the orchestrator passes the correct environment and -# volumes to spawned sandbox containers. Supports failure injection -# via prompt keywords and explicit exit-code override. -# -# Exit codes: -# 0 — success (default) -# 1 — FORCE_FAIL prompt keyword or MOCK_EXIT_CODE=1 -# 2 — missing required pipeline env vars -# 3 — missing required sandbox env vars (GATEWAY_URL, etc.) -# 4 — repo volume not mounted -# -# Prompt keywords: -# FORCE_FAIL — exit immediately with code 1 -# CHECK_FAIL — checker always fails -# CHECK_FAIL_THEN_PASS — checker fails first, passes on retry -# REVIEW_NEEDS_REVISION — all reviewers return needs_revision -# SLOW_PHASE — sleep for SLOW_PHASE_DURATION seconds (default 30) -# FAIL_ON_PHASE= — exit code 1 only when EGG_PIPELINE_PHASE matches -# REVIEWER_MIXED_VERDICT — first reviewer approves, second needs_revision -# HEARTBEAT_ONLY — send heartbeats but never exit (for timeout tests) -# PARTIAL_FAILURE — write partial draft then exit code 1 - -echo "=== Mock Sandbox ===" -echo "EGG_PIPELINE_PHASE=$EGG_PIPELINE_PHASE" -echo "EGG_PIPELINE_ID=$EGG_PIPELINE_ID" -echo "EGG_PIPELINE_MODE=$EGG_PIPELINE_MODE" -echo "EGG_PIPELINE_PROMPT=$EGG_PIPELINE_PROMPT" -echo "EGG_AGENT_ROLE=$EGG_AGENT_ROLE" -echo "EGG_REVIEWER_TYPE=$EGG_REVIEWER_TYPE" -echo "EGG_REPO_PATH=$EGG_REPO_PATH" -echo "GATEWAY_URL=$GATEWAY_URL" -echo "RUNTIME_UID=$RUNTIME_UID" -echo "RUNTIME_GID=$RUNTIME_GID" -echo "====================" - -# --- Check 1: required pipeline identity vars (exit 2) --- -missing="" -[ -z "$EGG_PIPELINE_PHASE" ] && missing="$missing EGG_PIPELINE_PHASE" -[ -z "$EGG_PIPELINE_ID" ] && missing="$missing EGG_PIPELINE_ID" -[ -z "$EGG_PIPELINE_MODE" ] && missing="$missing EGG_PIPELINE_MODE" - -if [ -n "$missing" ]; then - echo "ERROR: Missing required pipeline env vars:$missing" - exit 2 -fi - -# --- Check 2: required sandbox infra vars (exit 3) --- -missing_infra="" -[ -z "$GATEWAY_URL" ] && missing_infra="$missing_infra GATEWAY_URL" - -if [ -n "$missing_infra" ]; then - echo "ERROR: Missing required sandbox env vars:$missing_infra" - exit 3 -fi - -# --- Check 3: repo volume mounted (exit 4) --- -if [ ! -d "$EGG_REPO_PATH" ] && [ ! -d "/home/egg/repos" ]; then - echo "ERROR: Repo volume not mounted at $EGG_REPO_PATH or /home/egg/repos" - exit 4 -fi -echo "Repo volume OK: $(ls -d ${EGG_REPO_PATH:-/home/egg/repos} 2>/dev/null)" - -# --- Check 4: worktree validity (exit 5) --- -# Verify the mounted repo is a valid git worktree (has .git file with gitdir pointer) -# This catches issues like empty worktrees or root-owned worktrees that Docker may create -REPO_PATH="${EGG_REPO_PATH:-/home/egg/repos}" -GIT_PATH="$REPO_PATH/.git" - -if [ -f "$GIT_PATH" ]; then - # .git is a file - check it contains gitdir pointer (valid worktree) - if grep -q "gitdir:" "$GIT_PATH" 2>/dev/null; then - echo "Worktree OK: .git file contains gitdir pointer" - else - echo "ERROR: .git file exists but does not contain gitdir pointer" - exit 5 - fi -elif [ -d "$GIT_PATH" ]; then - # .git is a directory - could be a regular repo or empty dir from Docker - if [ -f "$GIT_PATH/HEAD" ]; then - echo "Git repo OK: .git directory with HEAD (not a worktree, but valid)" - else - echo "ERROR: .git directory is empty or invalid (no HEAD file)" - exit 5 - fi -else - # No .git at all - this is expected if the repo mount is working normally - # The gateway mounts the worktree at the repo path - echo "NOTE: No .git found at $GIT_PATH (expected when gateway mounts worktree at repo path)" -fi - -# Report worktree status for debugging -echo "Worktree mount status:" -echo " - Path: $REPO_PATH" -echo " - Owner: $(stat -c '%u:%g' "$REPO_PATH" 2>/dev/null || echo 'unknown')" -echo " - Files: $(ls -A "$REPO_PATH" 2>/dev/null | head -5 | tr '\n' ' ')" - -# --- Checker role handling --- -# When spawned as a checker, write check results and exit. -if [ "$EGG_AGENT_ROLE" = "checker" ]; then - echo "Checker role detected — writing check results" - CHECKS_DIR="${EGG_REPO_PATH:-.}/.egg-state/checks" - mkdir -p "$CHECKS_DIR" - - # Track autofix attempt count via a state file - ATTEMPT_FILE="$CHECKS_DIR/.autofix-attempt-count" - if [ -f "$ATTEMPT_FILE" ]; then - ATTEMPT_COUNT=$(cat "$ATTEMPT_FILE") - ATTEMPT_COUNT=$((ATTEMPT_COUNT + 1)) - else - ATTEMPT_COUNT=1 - fi - echo "$ATTEMPT_COUNT" >"$ATTEMPT_FILE" - - # Determine check result: MOCK_CHECK_RESULT env var or prompt keywords - # "fail" — always fail - # "fail-then-pass" — fail on first attempt, pass on subsequent - # default — all pass - # Prompt keywords (checked via EGG_PIPELINE_PROMPT): - # CHECK_FAIL_THEN_PASS → fail-then-pass - # CHECK_FAIL → fail (must check after CHECK_FAIL_THEN_PASS) - if [ -z "$MOCK_CHECK_RESULT" ]; then - case "$EGG_PIPELINE_PROMPT" in - *CHECK_FAIL_THEN_PASS*) MOCK_CHECK_RESULT="fail-then-pass" ;; - *CHECK_FAIL*) MOCK_CHECK_RESULT="fail" ;; - esac - fi - - if [ "$MOCK_CHECK_RESULT" = "fail" ]; then - ALL_PASSED="false" - elif [ "$MOCK_CHECK_RESULT" = "fail-then-pass" ]; then - if [ "$ATTEMPT_COUNT" -le 1 ]; then - ALL_PASSED="false" - else - ALL_PASSED="true" - fi - else - ALL_PASSED="true" - fi - - # Derive pipeline identifier for namespaced results filename - if [ -n "$EGG_ISSUE_NUMBER" ]; then - _IDENT="$EGG_ISSUE_NUMBER" - elif [ -n "$EGG_PIPELINE_ID" ]; then - _IDENT="$EGG_PIPELINE_ID" - else - _IDENT="unknown" - fi - RESULTS_FILE="$CHECKS_DIR/${_IDENT}-implement-results.json" - if [ "$ALL_PASSED" = "true" ]; then - cat >"$RESULTS_FILE" <"$RESULTS_FILE" < prompt keyword > default (approved) - # REVIEWER_MIXED_VERDICT: first reviewer (unified) approves, subsequent reject - if [ -n "$MOCK_REVIEW_VERDICT" ]; then - VERDICT="$MOCK_REVIEW_VERDICT" - elif echo "$EGG_PIPELINE_PROMPT" | grep -q "REVIEWER_MIXED_VERDICT"; then - # First reviewer type (unified) approves, others need revision - if [ "$REVIEWER_TYPE" = "unified" ]; then - VERDICT="approved" - echo "REVIEWER_MIXED_VERDICT: unified reviewer approves" - else - VERDICT="needs_revision" - echo "REVIEWER_MIXED_VERDICT: ${REVIEWER_TYPE} reviewer requests revision" - fi - elif echo "$EGG_PIPELINE_PROMPT" | grep -q "REVIEW_NEEDS_REVISION"; then - VERDICT="needs_revision" - else - VERDICT="approved" - fi - - # Typed verdict file path: {phase}-{reviewer_type}-review.json - VERDICT_FILE="$REVIEWS_DIR/${PHASE}-${REVIEWER_TYPE}-review.json" - - TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "2025-01-01T00:00:00Z") - - if [ "$VERDICT" = "needs_revision" ]; then - FEEDBACK="Mock ${REVIEWER_TYPE} reviewer feedback: please revise the ${PHASE} draft." - else - FEEDBACK="" - fi - - cat >"$VERDICT_FILE" <"$DRAFTS_DIR/analysis.md" <"$DRAFTS_DIR/plan.md" <: fail only when current phase matches -# Use case statement matching to avoid shell injection via sed -FAIL_PHASE="" -case "$EGG_PIPELINE_PROMPT" in - *FAIL_ON_PHASE=refine*) FAIL_PHASE="refine" ;; - *FAIL_ON_PHASE=plan*) FAIL_PHASE="plan" ;; - *FAIL_ON_PHASE=implement*) FAIL_PHASE="implement" ;; - *FAIL_ON_PHASE=review*) FAIL_PHASE="review" ;; - *FAIL_ON_PHASE=pr*) FAIL_PHASE="pr" ;; -esac - -if [ -n "$FAIL_PHASE" ]; then - if [ "$EGG_PIPELINE_PHASE" = "$FAIL_PHASE" ]; then - echo "FAIL_ON_PHASE=$FAIL_PHASE matched current phase — exiting with code 1" - exit 1 - fi - echo "FAIL_ON_PHASE=$FAIL_PHASE does not match current phase ($EGG_PIPELINE_PHASE) — continuing" -fi - -# PARTIAL_FAILURE: write partial draft then fail -case "$EGG_PIPELINE_PROMPT" in - *PARTIAL_FAILURE*) - DRAFTS_DIR="${EGG_REPO_PATH:-.}/.egg-state/drafts" - mkdir -p "$DRAFTS_DIR" - cat >"$DRAFTS_DIR/partial-draft.md" </dev/null || echo "2025-01-01T00:00:00Z") - cat >"$SIGNALS_DIR/heartbeat-${HEARTBEAT_COUNT}.json" < bool: + """Return True iff the slice integration branch's tip on origin is + already reachable from ``parent_branch``'s tip on origin. + + This is the #2549 "slice already merged" signal: after the slice's + PR is merged into the parent, the integration branch's old tip is + an ancestor of the parent's new tip. The inverse direction of the + #2512 restart-recovery check — and the case that previously caused + ``create_slice_integration_branch`` to fall through to a non-fast- + forward push and fail the slice (and cascade-fail the phase). + + Returns False on any of: + + * Either branch is missing on origin (nothing to compare against). + * The integration branch tip equals the parent tip (``==`` is + neither "merged" nor "diverged"; just a no-op state — let the + regular create path handle it as a fast-forward no-op). + * The ancestry check itself fails (gateway down, missing object + after a flaky fetch). In that case we return False so the + caller falls through to the existing create path rather than + silently skipping the slice. + + The transport mirrors :meth:`create_slice_integration_branch`: + a single synthetic launcher-authenticated session shared across + ls-remote, fetch, and the merge-base call. + """ + if not integration_branch or not parent_branch: + return False + if integration_branch == parent_branch: + return False + + temp_container_id = ( + f"{pipeline_id}-slice-merged-check-{integration_branch.replace('/', '-')}" + ) + session_token: str | None = None + try: + session = self.register_session( + container_id=temp_container_id, + container_ip=self.self_ip, + mode=mode, + pipeline_id=pipeline_id, + agent_role=agent_role, + branch=integration_branch, + synthetic=True, + ) + session_token = session.session_token + + parent_sha = self.get_remote_branch_sha( + pipeline_id, + repo_path, + f"refs/heads/{parent_branch}", + mode=mode, + bearer_token=session_token, + ) + existing_sha = self.get_remote_branch_sha( + pipeline_id, + repo_path, + f"refs/heads/{integration_branch}", + mode=mode, + bearer_token=session_token, + ) + if not parent_sha or not existing_sha: + return False + if parent_sha == existing_sha: + return False + + # Both refs must be locally reachable for ``merge-base + # --is-ancestor`` to evaluate without errors. Best-effort: + # if either fetch fails the merge-base call will return + # False (missing object → returncode != 0) and we degrade + # to "not merged", which matches the safe default. + self.fetch_branch( + pipeline_id, + repo_path, + args=[f"+refs/heads/{parent_branch}:refs/remotes/origin/{parent_branch}"], + mode=mode, + bearer_token=session_token, + ) + self.fetch_branch( + pipeline_id, + repo_path, + args=[f"+refs/heads/{integration_branch}:refs/remotes/origin/{integration_branch}"], + mode=mode, + bearer_token=session_token, + ) + + return self._sha_is_ancestor( + pipeline_id, + repo_path, + existing_sha, + parent_sha, + bearer_token=session_token, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "is_slice_branch_merged_into_parent: gateway request failed", + pipeline_id=pipeline_id, + integration_branch=integration_branch, + parent_branch=parent_branch, + error=str(exc), + ) + return False + finally: + if session_token: + try: + self.delete_session(session_token) + except Exception: + pass + def create_slice_integration_branch( self, pipeline_id: str, diff --git a/orchestrator/handoffs.py b/orchestrator/handoffs.py index 96736159a4..9110bb043b 100644 --- a/orchestrator/handoffs.py +++ b/orchestrator/handoffs.py @@ -31,6 +31,7 @@ def get_logger(name: str, **kwargs) -> logging.Logger: # type: ignore[misc] from egg_contracts.agent_roles import ( get_role_definition, ) +from egg_contracts.impasse import Impasse from egg_contracts.orchestrator import ( load_agent_output, ) @@ -101,6 +102,7 @@ def __init__( handoff_data: dict[str, Any] | None = None, logs: str | None = None, metrics: dict[str, Any] | None = None, + impasse: Impasse | None = None, ): """Initialize agent output. @@ -111,6 +113,10 @@ def __init__( handoff_data: Data for dependent agents logs: Execution logs metrics: Performance metrics + impasse: Typed runtime escape-hatch signal (#2529). When + set, the producer found its task structurally + impossible and exited without committing; the + orchestrator routes the impasse post-phase. """ self.role = role self.commit = commit @@ -118,6 +124,7 @@ def __init__( self.handoff_data = handoff_data or {} self.logs = logs self.metrics = metrics or {} + self.impasse = impasse self.timestamp = datetime.now(UTC) def to_dict(self) -> dict[str, Any]: @@ -129,12 +136,15 @@ def to_dict(self) -> dict[str, Any]: "handoff_data": self.handoff_data, "logs": self.logs, "metrics": self.metrics, + "impasse": self.impasse.to_dict() if self.impasse else None, "timestamp": self.timestamp.isoformat(), } @classmethod def from_dict(cls, d: dict[str, Any]) -> AgentOutput: """Create from dictionary representation.""" + impasse_raw = d.get("impasse") + impasse = Impasse.from_dict(impasse_raw) if isinstance(impasse_raw, dict) else None output = cls( role=AgentRole(d["role"]), commit=d.get("commit"), @@ -142,6 +152,7 @@ def from_dict(cls, d: dict[str, Any]) -> AgentOutput: handoff_data=d.get("handoff_data", {}), logs=d.get("logs"), metrics=d.get("metrics", {}), + impasse=impasse, ) if d.get("timestamp"): output.timestamp = datetime.fromisoformat(d["timestamp"]) diff --git a/orchestrator/impasse_routing.py b/orchestrator/impasse_routing.py new file mode 100644 index 0000000000..a86ece2e30 --- /dev/null +++ b/orchestrator/impasse_routing.py @@ -0,0 +1,554 @@ +"""Orchestrator-side impasse detection and routing (#2529). + +Reads :class:`AgentOutput.impasse` from each producer's per-pipeline +agent-output file after a slice's BRC cycle exits, and decides whether +to: + +- **Delegate** — flip the contract task's ``role`` to the agent's + ``suggested_role`` and bump ``delegation_attempts``. The slice's + next BRC cycle picks up the new role from the contract. +- **Escalate** — create a HITL decision describing the impasse so the + human can decide between cancelling, re-planning, or manually + resolving the underlying blocker. + +Auto-delegation only fires for ``WRONG_ROLE`` impasses with a single +eligible alternative producer role and a fresh task +(``delegation_attempts == 0``). Everything else escalates: a second +impasse on the same task, a non-WRONG_ROLE category, an unknown +``suggested_role``, and self-delegation attempts are all routed to +HITL. + +The helper is import-only — wiring into the slice loop is done by +``orchestrator/routes/pipelines.py``. Keeping the routing logic here +makes it unit-testable in isolation from the 16k-line pipelines +module. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import TYPE_CHECKING + +_shared_path = Path(__file__).parent.parent / "shared" +if _shared_path.exists() and str(_shared_path) not in sys.path: + sys.path.insert(0, str(_shared_path)) + +from egg_contracts.agent_roles import AgentRole as ContractAgentRole +from egg_contracts.impasse import Impasse, ImpasseCategory +from egg_contracts.loader import load_contract, save_contract +from egg_contracts.models import ( + Contract, + Decision, + DecisionOption, + DecisionType, + Slice, + Task, +) +from egg_contracts.orchestrator import load_agent_output +from egg_contracts.roles import Role +from egg_contracts.validator import apply_mutation + +try: + from egg_logging import get_logger +except ImportError: # pragma: no cover - host-side fallback + import logging + + def get_logger(name: str, **kwargs): # type: ignore[misc] + return logging.getLogger(name) + + +if TYPE_CHECKING: + pass + + +logger = get_logger("orchestrator.impasse_routing") + + +# Producer roles eligible for delegation. Cross-phase roles +# (overseer/autofixer/conflict_resolver/inspector) are not valid +# delegation targets — auto-delegation rewires a producer task within +# the implement phase, not across phases. +_DELEGATION_ELIGIBLE_ROLES = {"coder", "tester", "documenter"} + +# Bumped by orchestrator each delegation; second hit (>= 1) escalates. +DELEGATION_LIMIT = 1 + + +class ImpasseAction(StrEnum): + """What the orchestrator decided to do with an impasse.""" + + DELEGATE = "delegate" + """Mutated ``task.role`` to ``suggested_role`` and bumped + ``delegation_attempts``. The slice loop should re-run the BRC + cycle so the new role spawns and proposes.""" + + ESCALATE = "escalate" + """Created (or skipped — see ``hitl_decision_id``) a HITL decision. + The slice should not auto-retry; the human gates the next move.""" + + +@dataclass +class RoutingDecision: + """Outcome of routing a single impasse.""" + + action: ImpasseAction + impasse: Impasse + role: str + """The role that *reported* the impasse (the impassed producer).""" + task_id: str | None + new_role: str | None + """For ``DELEGATE``: the role we flipped to. ``None`` for + ``ESCALATE``.""" + reason: str + """Operator-readable summary of why this action was chosen.""" + hitl_decision_id: str | None = None + """For ``ESCALATE``: the ID of the decision created on the + contract. ``None`` if creation was skipped or failed.""" + + +def collect_impasses( + repo_path: Path, + pipeline_id: str | int, + roles: list[ContractAgentRole], +) -> list[tuple[ContractAgentRole, Impasse]]: + """Read each role's agent-output file and collect any impasses. + + Returns the list in spawn order (= ``roles`` argument order) so the + caller can route them deterministically rather than relying on + filesystem mtime. + """ + impasses: list[tuple[ContractAgentRole, Impasse]] = [] + for role in roles: + try: + raw = load_agent_output(repo_path, role, identifier=pipeline_id) + except Exception as exc: # pragma: no cover - defensive + logger.warning( + "Failed to read agent output during impasse scan", + role=role.value, + pipeline_id=str(pipeline_id), + error=str(exc), + ) + continue + impasse_raw = raw.get("impasse") if isinstance(raw, dict) else None + if not isinstance(impasse_raw, dict): + continue + try: + impasses.append((role, Impasse.from_dict(impasse_raw))) + except Exception as exc: + logger.warning( + "Discarding malformed impasse payload", + role=role.value, + pipeline_id=str(pipeline_id), + error=str(exc), + ) + continue + return impasses + + +def _find_task( + contract: Contract, + slice_id: str | None, + impasse: Impasse, + role: str, +) -> tuple[Slice, Task] | None: + """Resolve which slice + task the impasse applies to. + + Resolution order: + + 1. ``impasse.task_id`` — exact match, scoped to ``slice_id`` when + provided. + 2. The single task in the active slice whose ``role`` matches the + impassed role. When the slice has multiple matches (or none), + returns ``None`` and the caller escalates. + + Pipeline-level (non-sliced) phases pass ``slice_id=None`` and + search across all slices. + """ + candidate_slices: list[Slice] + if slice_id is None: + candidate_slices = list(contract.slices) + else: + candidate_slices = [s for s in contract.slices if s.id == slice_id] + + if impasse.task_id: + for slice_obj in candidate_slices: + for task in slice_obj.tasks: + if task.id == impasse.task_id: + return slice_obj, task + return None + + for slice_obj in candidate_slices: + matches = [t for t in slice_obj.tasks if (t.role or "coder") == role] + if len(matches) == 1: + return slice_obj, matches[0] + return None + + +def _is_eligible_delegation( + impasse: Impasse, + impassed_role: str, + task: Task, + *, + force_escalate: bool = False, +) -> tuple[bool, str]: + """Decide whether this impasse qualifies for auto-delegation. + + Returns ``(eligible, reason)``. ``reason`` is a short human-readable + string used in the structured log + the HITL decision body. + + ``force_escalate`` is set by the slice-loop wrapper on its terminal + iteration: a delegation that lands there can never re-run the BRC + cycle, so the safer behaviour is to escalate to HITL instead of + silently mutating the contract and exiting (review feedback #2 on + PR #2553). + """ + if force_escalate: + return False, ( + "delegation skipped on terminal slice iteration; no further " + "BRC cycle can execute the new role assignment" + ) + if impasse.category != ImpasseCategory.WRONG_ROLE: + return False, ( + f"category={impasse.category.value} is not auto-delegateable " + "(only wrong_role triggers role-flip)" + ) + if not impasse.suggested_role: + return False, "no suggested_role on the impasse" + if impasse.suggested_role == impassed_role: + return False, "suggested_role equals the impassed role (self-delegation)" + if impasse.suggested_role not in _DELEGATION_ELIGIBLE_ROLES: + return False, ( + f"suggested_role={impasse.suggested_role!r} is not in the " + "producer trio (coder/tester/documenter)" + ) + if task.delegation_attempts >= DELEGATION_LIMIT: + return False, ( + f"task.delegation_attempts={task.delegation_attempts} already " + f"at limit {DELEGATION_LIMIT}; second impasse on same task" + ) + return True, "wrong_role with single eligible alternative role" + + +def _build_hitl_decision( + contract: Contract, + slice_obj: Slice | None, + task: Task | None, + impasse: Impasse, + impassed_role: str, + reason_for_escalation: str, +) -> tuple[str, Decision]: + """Construct the decision payload for an impasse HITL escalation. + + Returns ``(field_path, decision)`` ready to feed into + :func:`apply_mutation`. + """ + next_idx = len(contract.decisions or []) + decision_id = f"decision-{next_idx + 1}" + + task_id = task.id if task else (impasse.task_id or "") + slice_id = slice_obj.id if slice_obj else "" + + question_lines = [ + f"Producer ``{impassed_role}`` reported an impasse on " + f"``{task_id}`` ({slice_id}, category=``{impasse.category.value}``).", + "", + f"**Agent reason**: {impasse.reason}", + ] + if impasse.blocked_files: + joined = ", ".join(f"``{p}``" for p in impasse.blocked_files) + question_lines.append(f"**Blocked files**: {joined}") + if impasse.suggested_role: + question_lines.append(f"**Agent's suggested role**: ``{impasse.suggested_role}``") + question_lines.append(f"**Why auto-delegation didn't fire**: {reason_for_escalation}") + + options: list[DecisionOption] = [] + if impasse.suggested_role and impasse.suggested_role in _DELEGATION_ELIGIBLE_ROLES: + options.append( + DecisionOption( + id="opt-1", + label=( + f"Delegate to ``{impasse.suggested_role}`` (acknowledges " + "second impasse / overrides safety gate)" + ), + ) + ) + options.append( + DecisionOption( + id=f"opt-{len(options) + 1}", + label="Cancel the slice and re-plan", + ) + ) + options.append( + DecisionOption( + id=f"opt-{len(options) + 1}", + label="Resolve the underlying blocker manually, then resume", + ) + ) + options.append( + DecisionOption( + id=f"opt-{len(options) + 1}", + label="Other (explain in reply)", + ) + ) + + decision = Decision( + id=decision_id, + question="\n".join(question_lines), + type=DecisionType.HITL, + phase=contract.current_phase, + options=options, + ) + + field_path = f"decisions.{next_idx}" + return field_path, decision + + +def route_impasses( + repo_path: Path, + pipeline_id: str | int, + contract_identifier: str | int, + impasses: list[tuple[ContractAgentRole, Impasse]], + slice_id: str | None, + actor: str = "orchestrator-impasse-router", + *, + force_escalate: bool = False, +) -> list[RoutingDecision]: + """Apply the routing policy to every impasse, mutating the contract. + + Loads the contract, walks each ``(role, impasse)`` pair, and for + each one either: + + - flips ``task.role`` + bumps ``task.delegation_attempts`` and + returns a ``DELEGATE`` decision, or + - appends a HITL decision and returns an ``ESCALATE`` decision. + + All mutations go through :func:`apply_mutation` (so the audit log + captures them) under the ``SYSTEM`` role — only SYSTEM owns + ``phases.*.tasks.*.role`` and ``phases.*.tasks.*.delegation_attempts`` + per ``shared/egg_contracts/roles.py``. + + Saves the contract once at the end if any mutation was applied. + + ``force_escalate`` (callers: terminal slice-loop iteration) forces + every impasse to take the escalate path even when it would + otherwise qualify for auto-delegation. The slice loop sets this on + its last iteration because a delegation made there can never + re-run a BRC cycle, so the role flip would silently dangle. + """ + if not impasses: + return [] + + contract = load_contract(contract_identifier, repo_path) + decisions: list[RoutingDecision] = [] + mutated = False + + for role, impasse in impasses: + impassed_role = role.value + located = _find_task(contract, slice_id, impasse, impassed_role) + if located is None: + decision = _record_escalate( + contract, + None, + None, + impasse, + impassed_role, + actor, + "could not resolve task_id from the contract", + ) + decisions.append(decision) + mutated = True + continue + + slice_obj, task = located + eligible, why = _is_eligible_delegation( + impasse, impassed_role, task, force_escalate=force_escalate + ) + + if eligible: + decision = _record_delegate( + contract, + slice_obj, + task, + impasse, + impassed_role, + actor, + why, + ) + else: + decision = _record_escalate( + contract, + slice_obj, + task, + impasse, + impassed_role, + actor, + why, + ) + decisions.append(decision) + mutated = True + + if mutated: + try: + save_contract(contract, repo_path) + except Exception as exc: # pragma: no cover - defensive + logger.error( + "Failed to persist contract after impasse routing", + pipeline_id=str(pipeline_id), + error=str(exc), + ) + + return decisions + + +def _record_delegate( + contract: Contract, + slice_obj: Slice, + task: Task, + impasse: Impasse, + impassed_role: str, + actor: str, + reason: str, +) -> RoutingDecision: + slice_idx = next( + (i for i, s in enumerate(contract.slices) if s.id == slice_obj.id), + None, + ) + task_idx = next((i for i, t in enumerate(slice_obj.tasks) if t.id == task.id), None) + if slice_idx is None or task_idx is None: # pragma: no cover - defensive + return _record_escalate( + contract, + slice_obj, + task, + impasse, + impassed_role, + actor, + "could not locate task indices for delegation mutation", + ) + + role_path = f"phases.{slice_idx}.tasks.{task_idx}.role" + # The impasse schema caps ``reason`` at 2000 chars; the audit log + # can hold the full payload, and post-mortem debugging benefits + # from the unredacted agent reasoning. Don't truncate. + role_result = apply_mutation( + contract, + role=Role.SYSTEM, + actor=actor, + field_path=role_path, + new_value=impasse.suggested_role, + reason=( + f"Impasse-driven delegation: {impassed_role} → " + f"{impasse.suggested_role}. Agent reason: {impasse.reason}" + ), + ) + if not role_result.success: + return _record_escalate( + contract, + slice_obj, + task, + impasse, + impassed_role, + actor, + f"role mutation failed: {role_result.message}", + ) + + counter_path = f"phases.{slice_idx}.tasks.{task_idx}.delegation_attempts" + counter_result = apply_mutation( + contract, + role=Role.SYSTEM, + actor=actor, + field_path=counter_path, + new_value=task.delegation_attempts + 1, + reason="Impasse-driven delegation counter bump", + ) + if not counter_result.success: # pragma: no cover - schema-guarded + logger.warning( + "Failed to bump delegation_attempts after role flip", + slice_id=slice_obj.id, + task_id=task.id, + error=counter_result.message, + ) + + logger.info( + "Impasse delegated", + slice_id=slice_obj.id, + task_id=task.id, + from_role=impassed_role, + to_role=impasse.suggested_role, + reason=reason, + agent_reason=impasse.reason[:200], + ) + + return RoutingDecision( + action=ImpasseAction.DELEGATE, + impasse=impasse, + role=impassed_role, + task_id=task.id, + new_role=impasse.suggested_role, + reason=reason, + ) + + +def _record_escalate( + contract: Contract, + slice_obj: Slice | None, + task: Task | None, + impasse: Impasse, + impassed_role: str, + actor: str, + reason: str, +) -> RoutingDecision: + field_path, decision = _build_hitl_decision( + contract, slice_obj, task, impasse, impassed_role, reason + ) + # decisions.* is owned by IMPLEMENTER per FIELD_OWNERSHIP — mirror + # the existing ``register_open_question`` MCP tool's role choice. + # The audit log records the orchestrator-side actor so it stays + # distinguishable from agent-emitted decisions. + result = apply_mutation( + contract, + role=Role.IMPLEMENTER, + actor=actor, + field_path=field_path, + new_value=decision, + reason=f"Impasse-driven HITL escalation ({impasse.category.value})", + ) + decision_id = decision.id if result.success else None + if not result.success: + logger.error( + "Failed to create HITL decision for impasse", + slice_id=slice_obj.id if slice_obj else None, + task_id=task.id if task else impasse.task_id, + error=result.message, + ) + else: + logger.info( + "Impasse escalated to HITL", + slice_id=slice_obj.id if slice_obj else None, + task_id=task.id if task else impasse.task_id, + impassed_role=impassed_role, + category=impasse.category.value, + decision_id=decision_id, + reason=reason, + ) + + return RoutingDecision( + action=ImpasseAction.ESCALATE, + impasse=impasse, + role=impassed_role, + task_id=task.id if task else impasse.task_id, + new_role=None, + reason=reason, + hitl_decision_id=decision_id, + ) + + +__all__ = [ + "DELEGATION_LIMIT", + "ImpasseAction", + "RoutingDecision", + "collect_impasses", + "route_impasses", +] diff --git a/orchestrator/routes/messages.py b/orchestrator/routes/messages.py index 56e9099a76..b1abf28c98 100644 --- a/orchestrator/routes/messages.py +++ b/orchestrator/routes/messages.py @@ -666,11 +666,17 @@ def post_heartbeat(pipeline_id: str) -> tuple[Response, int]: # Emit as a normal HEARTBEAT message on the bus so downstream # consumers (HealthMonitor, overseer, UI) see it. - metadata = {"state": state} + metadata: dict[str, Any] = {"state": state} if waiting_on: metadata["waiting_on"] = waiting_on if body.get("since"): metadata["since"] = body["since"] + # Tag with slice_id so the implement-phase BRC writer can partition + # this HEARTBEAT into the correct per-slice transcript (#2548). + # Pipeline-level (non-slice) heartbeats leave the metadata off + # entirely. + if slice_id: + metadata["slice_id"] = slice_id msg = Message( pipeline_id=pipeline_id, diff --git a/orchestrator/routes/pipelines.py b/orchestrator/routes/pipelines.py index d5be4613a4..ecde53abc2 100644 --- a/orchestrator/routes/pipelines.py +++ b/orchestrator/routes/pipelines.py @@ -169,11 +169,13 @@ def get_repo_checks(repo: str) -> list[dict[str, str]]: # type: ignore[misc] get_state_store, ) +from egg_contracts.orchestrator import load_agent_output, save_agent_output from egg_git.default_branch import get_default_branch from lifecycle_auth import require_lifecycle_secret if TYPE_CHECKING: from egg_container import MountSpec + from egg_contracts.agent_roles import AgentRole as ContractAgentRole try: from ..container_spawner import ContainerSpawner @@ -4577,7 +4579,24 @@ def _get_plan_review_criteria() -> str: "### 7. Completeness\n" "- Does the plan cover all aspects of the original request?\n" "- Are documentation updates included where needed?\n" - "- Are there any obvious gaps or missing tasks?\n" + "- Are there any obvious gaps or missing tasks?\n\n" + "### 8. Task Role ↔ Files Alignment (deterministic, see #2527)\n" + "- Task role↔files alignment is enforced **orchestrator-side** at " + "`CONSENSUS_PROPOSE`: a planner proposal whose task `role:` " + "assignments cannot push their `files:` (per " + "`shared/egg_restrictions/patterns.py`, the same blocklist the " + "gateway uses) is rejected with HTTP 400 before the proposal " + "reaches you. By the time you act on a `CONSENSUS_PROPOSE`, " + "structural role↔files alignment is therefore already validated — " + "no manual check is required for this dimension.\n" + "- If you want belt-and-suspenders verification, you can run the " + "validator yourself against the proposed plan: " + '`python3 -c "from egg_contracts.plan_parser import parse_plan_file, ' + "validate_task_role_alignment as v; r = parse_plan_file(''); " + "print('\\n'.join(v(r.to_contract_slices())))\"`. " + "Errors here would predict a push-time `403 " + "restricted_path_modified` — NACK the planner and quote the " + "structured errors verbatim if any surface.\n" ) @@ -5705,9 +5724,90 @@ def _build_role_restrictions_section() -> str: ) lines.append("") + # Runtime escape hatch — the actionable producer-side guidance (the + # "call these two tools, do not invent a workaround, exit cleanly" + # text) lives in ``_build_impasse_escape_hatch_section`` and is + # injected into producer prompts (coder/tester/documenter); see + # issue #2529. Here we tell the planner only that the post-failure + # delegation path exists, so it knows the orchestrator can rewire a + # mis-assigned task without re-planning. The planner does not emit + # impasses itself. + lines.append("### Runtime delegation (post-failure)") + lines.append("") + lines.append( + "If a producer discovers mid-execution that its assigned task " + "is structurally impossible, it emits a typed Impasse via " + "``mcp__sdlc__report_impasse`` and the orchestrator may " + "auto-delegate the task to a different producer role (see " + "issue #2529). You don't need to plan for this — it's a " + "runtime safety net for plan bugs, role-restriction " + "mismatches, and external blockers." + ) + lines.append("") + return "\n".join(lines) +def _build_impasse_escape_hatch_section() -> str: + """Build the producer-facing runtime escape hatch section (#2529). + + Injected into the coder/tester/documenter prompts so producers know + to call ``mcp__sdlc__check_file_restriction`` / + ``mcp__sdlc__report_impasse`` instead of inventing workarounds when + they hit a structurally impossible task. The planner never emits + impasses, so this section is omitted from its prompt — see + ``_build_role_restrictions_section`` for the planner-facing + summary. + """ + return "\n".join( + [ + "## Impossible task? Use the runtime escape hatch — DO NOT invent workarounds", + "", + ( + "If you discover mid-execution that the task you've been " + "assigned is structurally impossible (file restrictions " + "block your role, the plan is buggy, an external " + "dependency is missing), STOP. Do not invent a " + "workaround like staging the files in another directory " + "or asking another agent to do it via a freeform handoff " + "document — past pipelines (#2474, #2529) wasted ~10+ " + "min and triggered downstream NACKs that way." + ), + "", + "Instead, use the two MCP tools:", + "", + ( + '1. `mcp__sdlc__check_file_restriction({path: "..."})` — ' + "cheap pure-local read against `shared/egg_restrictions/" + "patterns.py`. Confirms whether your role can write the " + "path and returns `alternative_role` (the producer role " + "that *can* write it, when exactly one covers it). Call " + "this BEFORE exploring a file you suspect is outside " + "your boundary." + ), + "", + ( + "2. `mcp__sdlc__report_impasse({category, reason, " + "task_id, suggested_role, blocked_files})` — emits a " + "typed Impasse signal and exits cleanly. **`task_id` is " + "required for ``wrong_role`` impasses** (look it up in " + "your spawn prompt or via `egg-contract show`); without " + "it the orchestrator cannot route precisely and " + "escalates to HITL. The orchestrator detects the " + "impasse post-phase and either delegates to " + "``suggested_role`` (first attempt) or escalates to " + "HITL (second attempt or no eligible role). Categories: " + "``wrong_role`` (file restrictions; auto-delegateable), " + "``plan_bug`` / ``external_blocker`` / ``unknown`` " + "(always HITL). Once you've called this tool, do NOT " + "commit code or call any other producer tool — just " + "exit." + ), + "", + ] + ) + + def _render_contract_tasks( repo_path: str, pipeline_id: str, @@ -8094,6 +8194,26 @@ def _finalize_pr_phase_failed( } ) +# Subset of BRC_HISTORY_TYPES that the orchestrator's CONSENSUS_* signal +# handlers tag with ``metadata['slice_id']`` for slice-aware implement +# pipelines (#2548). The implement-phase BRC writer treats a missing +# ``slice_id`` on these as a contract violation (drop with WARNING), +# while the remaining BRC_HISTORY_TYPES (HEARTBEAT, STATUS, HANDOFF, +# AGENT_FAILED, NUDGE, OVERSEER_ALERT) come from emitters that do not +# uniformly carry slice scope — those are routed to the unattributed +# sibling file rather than dropped, so the audit trail stays complete. +CONSENSUS_BRC_TYPES = frozenset( + { + "CONSENSUS_PROPOSE", + "CONSENSUS_ACK", + "CONSENSUS_NACK", + "CONSENSUS_WITHDRAW", + "CONSENSUS_CONFIRMED", + "CONSENSUS_RE_REVIEW", + "CONSENSUS_OBLIGATION_RESOLVED", + } +) + def _get_message_store(): """Import and return the message store factory function, or None if unavailable.""" @@ -8107,91 +8227,53 @@ def _get_message_store(): return get_message_store -def _write_brc_history( - worktree_path: Path, +def _render_brc_history_markdown( + brc_messages: list[Any], pipeline_id: str, phase: str, - identifier: int | str, -) -> None: - """Write BRC consensus message history for a phase to .egg-state. - - Retrieves BRC-related messages for the given phase from the message store - and writes them as a chronological markdown log to - ``.egg-state/brc-history/{identifier}-{phase}.md``. - No-ops gracefully when the message store is unavailable or contains no - BRC messages for the pipeline and phase. - - Args: - worktree_path: Path to the worktree repo directory - pipeline_id: The pipeline ID to retrieve messages for - phase: The pipeline phase name (e.g. "implement", "plan") - identifier: The pipeline identifier for file naming + *, + slice_id: str | None = None, +) -> str: + """Render *brc_messages* as a chronological markdown log. + + The output shape mirrors the legacy aggregate file: a heading line, + a generated-timestamp footer, and one ``### [ts] role (TYPE): subject`` + section per message with a fenced YAML metadata block. + + ``Generated:`` is derived from the *latest* message timestamp (not + wall-clock time) so regenerating the file from the same message set + produces byte-identical output. This keeps the PR-phase safety-net + rewrite (:func:`_rewrite_brc_history_for_pr`) idempotent: when no new + BRC messages arrived between phase completion and PR creation, the + rewritten file matches the previous commit and the follow-up commit is + skipped by :func:`_commit_statefiles_to_worktree`. See #1714. """ - logger.info( - "_write_brc_history: entering", - pipeline_id=pipeline_id, - phase=phase, - identifier=str(identifier), - ) - - store_fn = _get_message_store() - if store_fn is None: - logger.info( - "_write_brc_history: early return — message store unavailable", - pipeline_id=pipeline_id, - phase=phase, - ) - return - - try: - store = store_fn() - messages = store.get_messages(pipeline_id, limit=10000) - except Exception as e: - logger.warning( - "_write_brc_history: early return — failed to retrieve messages", - pipeline_id=pipeline_id, - phase=phase, - error=str(e), - ) - return - - if not messages: - logger.info( - "_write_brc_history: early return — no messages in store", - pipeline_id=pipeline_id, - phase=phase, - ) - return - - brc_messages = [m for m in messages if m.message_type in BRC_HISTORY_TYPES and m.phase == phase] - if not brc_messages: - logger.info( - "_write_brc_history: early return — no BRC messages for phase", - pipeline_id=pipeline_id, - phase=phase, - total_messages=len(messages), - ) - return - - # Format as markdown. `Generated:` is derived from the latest - # message timestamp (not wall-clock time) so regenerating the - # file from the same message set produces byte-identical output. - # This keeps the PR-phase safety-net rewrite - # (_rewrite_brc_history_for_pr) idempotent: when no new BRC - # messages arrived between phase completion and PR creation, - # the rewritten file matches the previous commit and the - # follow-up commit is skipped by _commit_statefiles_to_worktree. - # See #1714. message_timestamps = [m.timestamp for m in brc_messages if m.timestamp is not None] if message_timestamps: generated_str = max(message_timestamps).strftime("%Y-%m-%dT%H:%M:%SZ") else: generated_str = "unknown" + # The "unattributed" bucket is not a slice — it holds cross-cutting + # non-CONSENSUS messages that lack canonical slice scope (HEARTBEAT, + # OVERSEER_ALERT, AGENT_FAILED, …) routed to a sibling file so the + # audit trail stays complete. Rendering it as "Slice: unattributed" + # would mislead a reviewer who lands on the file via a link line — + # special-case the heading and metadata block instead. + is_unattributed = slice_id == "unattributed" lines: list[str] = [] - lines.append(f"# BRC Consensus History — {phase} phase") + if is_unattributed: + lines.append(f"# BRC Consensus History — {phase} phase, cross-cutting (unattributed)") + elif slice_id: + lines.append(f"# BRC Consensus History — {phase} phase, {slice_id}") + else: + lines.append(f"# BRC Consensus History — {phase} phase") lines.append("") lines.append(f"Generated: {generated_str}") lines.append(f"Pipeline: {pipeline_id}") + if is_unattributed: + lines.append("Section: cross-cutting (unattributed)") + elif slice_id: + lines.append(f"Slice: {slice_id}") lines.append("") for msg in brc_messages: @@ -8224,24 +8306,56 @@ def _write_brc_history( ) lines.append("````") lines.append("") + return "\n".join(lines) + + +def _write_brc_history_file( + worktree_path: Path, + pipeline_id: str, + phase: str, + identifier: int | str, + brc_messages: list[Any], + *, + slice_id: str | None = None, +) -> None: + """Render and persist the markdown + JSON companion files for one bucket. + + ``slice_id``, when provided, switches the on-disk filename from the + aggregate ``{identifier}-{phase}.{md,json}`` shape used by + refine/plan/pr to the per-slice ``{identifier}-{phase}-{slice_id}.{md,json}`` + shape used by implement (#2548 — hard switchover, no aggregate + implement file is produced). + """ + if slice_id: + stem = f"{identifier}-{phase}-{slice_id}" + else: + stem = f"{identifier}-{phase}" history_dir = worktree_path / ".egg-state" / "brc-history" history_dir.mkdir(parents=True, exist_ok=True) - history_file = history_dir / f"{identifier}-{phase}.md" + history_file = history_dir / f"{stem}.md" # Write the markdown history file try: - history_file.write_text("\n".join(lines)) + history_file.write_text( + _render_brc_history_markdown( + brc_messages, + pipeline_id, + phase, + slice_id=slice_id, + ) + ) except Exception as md_err: logger.warning( "Failed to write BRC history markdown file", pipeline_id=pipeline_id, phase=phase, + slice_id=slice_id, error=str(md_err), ) # Write a JSON companion artifact containing the full message dicts - json_file = history_dir / f"{identifier}-{phase}.json" + json_file = history_dir / f"{stem}.json" try: json_data = [msg.to_dict() for msg in brc_messages] json_file.write_text(json.dumps(json_data, indent=2, default=str)) @@ -8250,6 +8364,7 @@ def _write_brc_history( "Failed to write BRC history JSON companion file", pipeline_id=pipeline_id, phase=phase, + slice_id=slice_id, error=str(json_err), ) @@ -8257,11 +8372,247 @@ def _write_brc_history( "Wrote BRC history file", pipeline_id=pipeline_id, phase=phase, + slice_id=slice_id, path=str(history_file), message_count=len(brc_messages), ) +def _write_brc_history( + worktree_path: Path, + pipeline_id: str, + phase: str, + identifier: int | str, +) -> None: + """Write BRC consensus message history for a phase to .egg-state. + + Retrieves BRC-related messages for the given phase from the message store + and writes them as a chronological markdown log to + ``.egg-state/brc-history/{identifier}-{phase}.md``. + + For the ``implement`` phase the writer auto-detects slice-aware vs + aggregate mode (#2548): + + * If at least one BRC message carries a canonical + ``metadata['slice_id']`` (validated against + ``SLICE_ID_PATTERN``), the writer partitions messages per-slice + and writes one file per slice as + ``{identifier}-implement-{slice_id}.{md,json}``. + Per-message attribution rules: + + - ``CONSENSUS_*`` messages without a canonical slice_id are + dropped with a single aggregate WARNING — the orchestrator's + CONSENSUS_* signal handlers tag every implement-phase write + under D4, so a missing slice_id is a contract violation. + - Other ``BRC_HISTORY_TYPES`` (HEARTBEAT, STATUS, HANDOFF, + AGENT_FAILED, NUDGE, OVERSEER_ALERT) come from emitters that + do not uniformly carry slice scope. When they lack a + canonical slice_id they are routed to a sibling + ``{identifier}-implement-unattributed.{md,json}`` file rather + than dropped, so the audit trail stays complete and reviewers + of any per-slice transcript can cross-reference. + + * If **no** BRC message carries a slice_id (babysit_pr and other + non-slice pipelines), the writer falls back to the aggregate + ``{identifier}-implement.{md,json}`` filename. This preserves + the documented babysit_pr artifact named in + ``skills/babysit-pr/SKILL.md``. + + No-ops gracefully when the message store is unavailable or contains no + BRC messages for the pipeline and phase. + + Args: + worktree_path: Path to the worktree repo directory + pipeline_id: The pipeline ID to retrieve messages for + phase: The pipeline phase name (e.g. "implement", "plan") + identifier: The pipeline identifier for file naming + """ + logger.info( + "_write_brc_history: entering", + pipeline_id=pipeline_id, + phase=phase, + identifier=str(identifier), + ) + + store_fn = _get_message_store() + if store_fn is None: + logger.info( + "_write_brc_history: early return — message store unavailable", + pipeline_id=pipeline_id, + phase=phase, + ) + return + + try: + store = store_fn() + messages = store.get_messages(pipeline_id, limit=10000) + except Exception as e: + logger.warning( + "_write_brc_history: early return — failed to retrieve messages", + pipeline_id=pipeline_id, + phase=phase, + error=str(e), + ) + return + + if not messages: + logger.info( + "_write_brc_history: early return — no messages in store", + pipeline_id=pipeline_id, + phase=phase, + ) + return + + brc_messages = [m for m in messages if m.message_type in BRC_HISTORY_TYPES and m.phase == phase] + if not brc_messages: + logger.info( + "_write_brc_history: early return — no BRC messages for phase", + pipeline_id=pipeline_id, + phase=phase, + total_messages=len(messages), + ) + return + + if phase == "implement": + # Implement-phase BRC messages are partitioned per-slice (#2548) + # for slice-aware pipelines (issue mode with `contract.slices`): + # the orchestrator's CONSENSUS_* signal handlers tag every + # implement-phase consensus message with `metadata['slice_id']`, + # and this writer buckets them into one transcript file per + # slice. Babysit_pr and other non-slice pipelines have no slice + # scope on any message, so they fall back to the aggregate + # `{identifier}-implement.{md,json}` filename (preserving the + # documented babysit_pr artifact named in + # `skills/babysit-pr/SKILL.md`). + # + # ``metadata['slice_id']`` is interpolated into the on-disk + # filename below, so this is a gateway-facing seam in the same + # sense as ``signals.py`` / the restart route / + # ``concurrent_executor`` branch builders: every value MUST be + # validated against the canonical ``SLICE_ID_PATTERN`` before + # use, otherwise an attacker-controlled metadata blob (any role + # can post arbitrary metadata via ``messages.py``) could smuggle + # path separators into the filename and write outside + # ``.egg-state/brc-history/``. See ``slice_id_validation.py`` + # for the invariant. ``SLICE_ID_PATTERN`` is already imported at + # module top (the same try/except sandbox-vs-orchestrator dual + # import that imports ``extract_slice_id``); no local re-import + # is needed. + + buckets: dict[str, list[Any]] = {} + # ``unattributed_consensus`` holds CONSENSUS_* messages that lack + # a canonical slice_id — those are a D4 contract violation and + # are dropped with a single aggregate WARNING. ``unattributed_other`` + # holds non-CONSENSUS BRC types (HEARTBEAT, STATUS, HANDOFF, + # AGENT_FAILED, NUDGE, OVERSEER_ALERT) whose emitters do not + # uniformly carry slice scope; those are written to the + # ``unattributed`` sibling file so the audit trail stays complete. + unattributed_consensus: list[Any] = [] + unattributed_other: list[Any] = [] + for msg in brc_messages: + # ``Message.metadata`` is a Pydantic dict[str, Any] field with a + # default_factory=dict (message_store.Message), so it is always a + # dict at this point — no need to guard with getattr/isinstance. + raw_slice_id = msg.metadata.get("slice_id") + if isinstance(raw_slice_id, str) and SLICE_ID_PATTERN.fullmatch(raw_slice_id): + buckets.setdefault(raw_slice_id, []).append(msg) + continue + if str(getattr(msg, "message_type", "")) in CONSENSUS_BRC_TYPES: + unattributed_consensus.append(msg) + else: + unattributed_other.append(msg) + + if not buckets: + # No slice-attributed messages anywhere — this is a non-slice + # pipeline (babysit_pr or any other implement-phase run that + # never spawned slice scopes). Fall back to the aggregate + # `{identifier}-implement.{md,json}` filename so we never + # silently drop the entire BRC stream when no per-slice + # bucketing is possible. See #2548 reviewer_code_holistic + # finding #3. + logger.info( + "_write_brc_history: no slice-attributed implement-phase " + "messages — writing aggregate file (non-slice pipeline)", + pipeline_id=pipeline_id, + phase=phase, + total_brc_messages=len(brc_messages), + ) + _write_brc_history_file( + worktree_path, + pipeline_id, + phase, + identifier, + brc_messages, + ) + return + + # Slice-aware pipeline: at least one canonical slice_id was + # observed. CONSENSUS_* messages that lack a canonical slice_id + # are a D4 hard-switchover contract violation — drop them with + # a loud aggregate WARNING (count + sample types) so an operator + # notices the asymmetry rather than silently shipping a thinned- + # out transcript. + if unattributed_consensus: + sample_types = sorted( + {str(getattr(m, "message_type", "")) for m in unattributed_consensus[:8]} + ) + logger.warning( + "_write_brc_history: dropped implement-phase CONSENSUS_* messages " + "without canonical metadata.slice_id (hard switchover, #2548)", + pipeline_id=pipeline_id, + phase=phase, + dropped_count=len(unattributed_consensus), + sample_message_types=sample_types, + attributed_count=sum(len(v) for v in buckets.values()), + ) + + # Non-CONSENSUS BRC types without a canonical slice_id come from + # emitters that do not uniformly attach slice scope (HealthMonitor + # nudges, overseer respawn alerts, AGENT_FAILED broadcasts, + # CLI-routed HANDOFF/NUDGE messages, etc.). Route them to a + # sibling ``{identifier}-implement-unattributed.{md,json}`` file + # so the audit trail stays complete — reviewers reading any + # per-slice transcript can cross-reference. See #2548 + # reviewer_code blocking finding. + if unattributed_other: + _write_brc_history_file( + worktree_path, + pipeline_id, + phase, + identifier, + unattributed_other, + slice_id="unattributed", + ) + + # Natural sort by the integer suffix so a 12-slice pipeline iterates + # `slice-1, slice-2, ..., slice-12` rather than the lexicographic + # `slice-1, slice-10, slice-11, slice-12, slice-2`. Every key is + # already SLICE_ID_PATTERN-validated (`^slice-[0-9]+$`) above, so the + # int() parse is total. + for slice_id, slice_msgs in sorted( + buckets.items(), key=lambda kv: int(kv[0].rsplit("-", 1)[1]) + ): + _write_brc_history_file( + worktree_path, + pipeline_id, + phase, + identifier, + slice_msgs, + slice_id=slice_id, + ) + return + + # Refine, plan, and pr phases continue to write the aggregate + # `{identifier}-{phase}.{md,json}` file — only implement is per-slice. + _write_brc_history_file( + worktree_path, + pipeline_id, + phase, + identifier, + brc_messages, + ) + + def _rewrite_brc_history_for_pr( worktree_path: Path, pipeline_id: str, @@ -8540,7 +8891,34 @@ def _build_brc_history_link_line( canonical = [p.value for p in PipelinePhase] rank = {name: i for i, name in enumerate(canonical)} - phases.sort(key=lambda name: (rank.get(name, len(canonical)), name)) + + # Per-slice implement files (#2548) carry the stem + # ``implement-slice-{N}``; cluster them at the canonical ``implement`` + # rank so the rendered link order is + # ``refine → plan → implement[-slice-N] → implement-unattributed → + # pr`` instead of pushing the per-slice files past pr to the end of + # the list. Within the implement cluster, sort by the integer slice + # index so a 12-slice pipeline renders ``slice-1, slice-2, …, + # slice-12`` rather than the lexicographic ``slice-1, slice-10, + # slice-11, slice-12, slice-2``. The ``implement-unattributed`` + # sibling (cross-cutting non-CONSENSUS BRC types without slice scope, + # see ``_write_brc_history``) sorts after every per-slice file so a + # reviewer reads each slice transcript first, then the cross-cutting + # context. + def _sort_key(name: str) -> tuple[int, int, str]: + if name == "implement": + return (rank["implement"], -1, "") + if name == "implement-unattributed": + return (rank["implement"], 1 << 30, name) + if name.startswith("implement-slice-"): + try: + idx = int(name.rsplit("-", 1)[1]) + except ValueError: + idx = 1 << 30 # malformed → sort last within cluster + return (rank["implement"], idx, name) + return (rank.get(name, len(canonical)), 0, name) + + phases.sort(key=_sort_key) links = ", ".join( f"[`{phase}`](./.egg-state/brc-history/{identifier}-{phase}.md)" for phase in phases @@ -8990,6 +9368,42 @@ def _auto_create_pr( " components as a result.", ] +# Shared context-PR framing guidance injected into planner prompts (#2548). +# The planner may optionally emit ``pr.context_title`` / ``pr.context_description`` +# to give the dedicated context PR a different framing from the slice PRs; +# falls back to ``pr.title`` / ``pr.description`` when omitted. The +# orchestrator-populated fields ``pr.context_branch`` and +# ``pr.context_pr_number`` are intentionally excluded — those are runtime +# values written by the orchestrator after the context branch is created +# and the context PR is opened, and the planner must NOT emit them. +_PR_CONTEXT_GUIDANCE = [ + "**Optional context-PR framing (#2548)**: the orchestrator opens a " + "dedicated *context PR* at the root of the slice stack carrying the " + "refine/plan analysis docs and BRC consensus history. You MAY emit " + "`pr.context_title` and `pr.context_description` to frame this " + 'context PR differently from the slice PRs (e.g. "Strategic plan ' + 'for #N" vs the slice\'s "Implement …"). Both keys are optional — ' + "omit them and the orchestrator falls back to `pr.title` / " + "`pr.description`. Do NOT emit `pr.context_branch` or " + "`pr.context_pr_number`: those are populated by the orchestrator " + "after the context branch is created and the PR is opened.", +] + +# Example YAML lines documenting the optional context-PR keys. Indented to +# match the surrounding ``pr:`` block (`` context_title:`` lines up with +# `` description:``). Both lines are commented-out hints because they are +# optional — emitting them is encouraged when the framing should differ. +_PR_CONTEXT_YAML_EXAMPLE_LINES = [ + " # Optional context-PR framing (#2548); omit to reuse pr.title / pr.description.", + " # context_title: |-", + " # Strategic plan for # — refine/plan analysis + BRC history", + " # context_description: |-", + " # Carries the refine analysis, the plan, the BRC consensus", + " # history that approved each, and the agent transcripts —", + " # so reviewers approaching the slice stack can see the strategic", + " # narrative on a PR that targets the configured base branch.", +] + # YAML safety guidance for planner prompts. Plain (unquoted) scalars break # when they contain ``: `` sequences — e.g. "Add `sequence: int = 0` field" # parses as a nested mapping and raises ScannerError. Block scalars (``|-``) @@ -9266,6 +9680,8 @@ def _build_phase_prompt( "", *_PR_DESCRIPTION_GUIDANCE, "", + *_PR_CONTEXT_GUIDANCE, + "", "End your document with a fenced YAML block like this:", "", "````", @@ -9281,6 +9697,7 @@ def _build_phase_prompt( " manual_steps: |", " Pre-merge: any required steps before merging", " Post-merge: any required steps after merging", + *_PR_CONTEXT_YAML_EXAMPLE_LINES, "phases:", " - id: 1", " name: |-", @@ -10605,6 +11022,13 @@ def _build_agent_prompt( boundary_section = _build_file_boundary_section(role_value) if boundary_section: base_prompt += "\n" + boundary_section + # Producer escape hatch (#2529) — coder is one of the impassing + # producer roles, so it must see the actionable + # check_file_restriction / report_impasse guidance instead of + # inventing workarounds. Refiner runs in the refine phase and + # never owns implement-phase tasks, so it doesn't need this. + if role_value == "coder": + base_prompt += "\n" + _build_impasse_escape_hatch_section() # In concurrent mode, inject BRC consensus preamble so the coder/refiner # knows to propose, respond to reviews, confirm, and stay alive. if concurrent: @@ -11071,6 +11495,8 @@ def _build_agent_prompt( "", *_PR_DESCRIPTION_GUIDANCE, "", + *_PR_CONTEXT_GUIDANCE, + "", "End your document with a fenced YAML block like this:", "", "````", @@ -11086,6 +11512,7 @@ def _build_agent_prompt( " manual_steps: |", " Pre-merge: any required steps before merging", " Post-merge: any required steps after merging", + *_PR_CONTEXT_YAML_EXAMPLE_LINES, "phases:", " - id: 1", " name: |-", @@ -11299,6 +11726,15 @@ def _build_agent_prompt( if boundary_section: lines.append(boundary_section) + # Producer escape hatch (#2529) — tester/documenter are the other + # two impassing producer roles (coder is handled in the early-return + # branch above). They need the actionable + # check_file_restriction / report_impasse guidance so they don't + # invent workarounds when their assigned task is structurally + # impossible. + if role_value in ("tester", "documenter"): + lines.append(_build_impasse_escape_hatch_section()) + lines.append("## Phase Completion\n") if concurrent: lines.extend( @@ -12319,15 +12755,13 @@ def _contract_loader() -> Any: except Exception: # noqa: BLE001 return None - reconciler_thread, reconciler_stop = _start_stacked_pr_reconciler( - pipeline_id, - _contract_loader, - spawner.gateway, - pipeline, - worktree_repo_path=worktree_repo_path, - repo=getattr(pipeline, "repo", None), - ) - + # #2549 reviewer note: defer starting the stacked-PR reconciler + # until after the bootstrap reconciliation pass so an unhandled + # exception during bootstrap (e.g. a hard ImportError of + # ``SliceStatus`` or a programming error in the pass) cannot leak + # the daemon thread. The reconciler does not depend on bootstrap + # state, so its start is safe to move after the pass; the existing + # ``finally`` at the bottom of the run loop owns its teardown. aggregate_logs: list[str] = [] overall_exit = 0 poll_interval = 5.0 @@ -12337,6 +12771,170 @@ def _contract_loader() -> Any: except ImportError: import global_slice_admit # type: ignore[no-redef] + try: + from orchestrator.peer_consensus import ( + remove_peer_consensus_tracker, + ) + except ImportError: + from peer_consensus import ( # type: ignore[no-redef] + remove_peer_consensus_tracker, + ) + + try: + from state_store import get_pipeline_state_lock + except ImportError: + from orchestrator.state_store import ( # type: ignore[no-redef] + get_pipeline_state_lock, + ) + + from egg_contracts.models import SliceStatus + + def _persist_slice_status_complete(slice_id: str) -> None: + """Mark ``slice_id`` as ``SliceStatus.COMPLETE`` on the contract. + + #2549 — durable record of slice completion. The + ``Slice.status`` field has had a ``COMPLETE`` value since + the original schema, but until #2549 nothing wrote it; the + #2470 ``restart_agent`` reader at line 2653 was effectively + dead code. This helper closes that gap so: + + * Subsequent ``start_pipeline`` calls see merged slices as + COMPLETE on the contract and skip them in the bootstrap + reconciliation pass below — no GitHub round-trip needed. + * The #2470 ``restart_agent`` parent-complete fallback + finally has a real signal to read. + + Best-effort: if the lock or save fails, the in-memory + scheduler state still reflects completion and the slice + won't run this pass; the next start_pipeline will + re-detect via the merged-detection helper. + """ + try: + with get_pipeline_state_lock(pipeline_id): + contract_local = load_contract(pipeline_id, worktree_repo_path) + for s in contract_local.slices: + if s.id == slice_id: + s.status = SliceStatus.COMPLETE + break + save_contract(contract_local, worktree_repo_path) + except Exception as save_err: # noqa: BLE001 + logger.warning( + "Failed to persist slice.status=COMPLETE", + pipeline_id=pipeline_id, + slice_id=slice_id, + error=str(save_err), + ) + + # #2549 — bootstrap reconciliation pass. Before the run loop picks up + # any slices, fold in two sources of "this slice is already done" + # state that the scheduler (a pure rebuild from ``contract.slices``) + # cannot see on its own: + # + # (A) Slices that the contract already records as + # ``SliceStatus.COMPLETE``. Once #2549 starts writing this + # field on success, future restarts can trust it directly + # without a GitHub round-trip. + # + # (B) Slices whose integration branch on origin is reachable from + # their parent's tip — i.e. their PR has been merged. This + # handles the literal #2549 repro (operator merges slice-1's + # PR, runs ``start_pipeline`` to resume) AND any slice whose + # completion was committed before #2549's writer landed. On a + # hit, also persist (A) so subsequent restarts hit the cheap + # path. + # + # Without this pass, the scheduler would yield every slice as READY + # on its first ``iter_ready`` tick and ``create_slice_integration_ + # branch`` would attempt to push parent_sha onto an existing slice + # ref whose tip is now an ancestor of parent — a non-fast-forward + # rejection that previously failed the slice and cascaded the + # phase. Both layers (A+B) are best-effort: a failure in this pass + # silently falls through to the existing run loop, preserving the + # pre-#2549 behaviour as the floor. + bootstrap_complete: list[str] = [] + bootstrap_merged: list[str] = [] + + # Layer (A): cheap, no I/O. Trust contract-recorded COMPLETE status. + layer_b_candidates = [] + for s in slices: + if s.status == SliceStatus.COMPLETE: + scheduler.record_complete(s.id) + bootstrap_complete.append(s.id) + continue + layer_b_candidates.append(s) + + # Layer (B): origin-side detection for slices not yet recorded as + # COMPLETE on the contract. Each helper call uses its own synthetic + # gateway session, so we parallelise across slices to keep startup + # latency bounded as forests grow. Cap workers so a large forest + # doesn't burst against the gateway. + if pipeline.repo and layer_b_candidates: + + def _bootstrap_check_one(slice_obj: Any) -> tuple[str, bool]: + # Prefer the parent branch the slice was actually forked + # off of (recorded by ``_run_one_slice_inner``). Falls back + # to the dependency-derived parent for slices that never + # made it through ``_run_one_slice_inner`` (e.g. fresh + # contract on first run). Both should agree today, but a + # future re-plan that mutates ``dependencies`` post-creation + # would diverge — preferring the recorded value future- + # proofs the check. + if slice_obj.parent_branch_at_creation: + parent_branch_for_check = slice_obj.parent_branch_at_creation + elif slice_obj.dependencies: + parent_branch_for_check = f"{issue_branch}/{slice_obj.dependencies[0]}" + else: + parent_branch_for_check = pipeline_branch + integration_branch_for_check = f"{issue_branch}/{slice_obj.id}" + try: + merged = spawner.gateway.is_slice_branch_merged_into_parent( + pipeline_id, + str(worktree_repo_path), + integration_branch=integration_branch_for_check, + parent_branch=parent_branch_for_check, + agent_role="coder", + mode=gateway_mode, # type: ignore[arg-type] + ) + except Exception as detect_err: # noqa: BLE001 + logger.warning( + "Bootstrap merged-detection raised; treating slice as not-merged", + pipeline_id=pipeline_id, + slice_id=slice_obj.id, + error=str(detect_err), + ) + return slice_obj.id, False + return slice_obj.id, bool(merged) + + max_workers = min(len(layer_b_candidates), 8) + with concurrent.futures.ThreadPoolExecutor( + max_workers=max_workers, + thread_name_prefix=f"slice-bootstrap-{pipeline_id}", + ) as bootstrap_pool: + results = list(bootstrap_pool.map(_bootstrap_check_one, layer_b_candidates)) + + for slice_id, already_merged in results: + if already_merged: + scheduler.record_complete(slice_id) + _persist_slice_status_complete(slice_id) + bootstrap_merged.append(slice_id) + + if bootstrap_complete or bootstrap_merged: + logger.info( + "Slice bootstrap reconciliation marked slices complete", + pipeline_id=pipeline_id, + already_complete_on_contract=bootstrap_complete, + detected_merged_on_origin=bootstrap_merged, + ) + + reconciler_thread, reconciler_stop = _start_stacked_pr_reconciler( + pipeline_id, + _contract_loader, + spawner.gateway, + pipeline, + worktree_repo_path=worktree_repo_path, + repo=getattr(pipeline, "repo", None), + ) + try: while not scheduler.all_done(): # 1. Snapshot ready slices for this tick. @@ -12376,21 +12974,6 @@ def _contract_loader() -> Any: # on the scheduler from inside ``_run_one_slice`` so the # cascade machinery sees the same wall-clock as the run # loop. - try: - from orchestrator.peer_consensus import ( - remove_peer_consensus_tracker, - ) - except ImportError: - from peer_consensus import ( # type: ignore[no-redef] - remove_peer_consensus_tracker, - ) - - try: - from state_store import get_pipeline_state_lock - except ImportError: - from orchestrator.state_store import ( # type: ignore[no-redef] - get_pipeline_state_lock, - ) def _run_one_slice(slice_id: str, parent_slice_id: str | None) -> tuple[int, str]: # Release the global-admission slot when the slice @@ -12430,6 +13013,54 @@ def _run_one_slice_inner(slice_id: str, parent_slice_id: str | None) -> tuple[in error=str(save_err), ) + # #2549 race protection: a slice's PR can be merged + # between the bootstrap reconciliation pass and this + # spawn (e.g. operator merges slice-1 while slice-2 is + # still queued). When that happens, the integration + # branch's old tip is reachable from the parent's new + # tip, and the create-branch push below would be + # rejected as non-fast-forward (cascading the slice and + # its descendants to FAILED). Detect that case here and + # skip directly to COMPLETE — same effect as the + # bootstrap reconciliation pass, just on a slice that + # transitioned during the run. + if pipeline.repo: + try: + already_merged = spawner.gateway.is_slice_branch_merged_into_parent( + pipeline_id, + str(worktree_repo_path), + integration_branch=integration_branch, + parent_branch=parent_branch, + agent_role="coder", + mode=gateway_mode, # type: ignore[arg-type] + ) + except Exception as detect_err: # noqa: BLE001 + logger.warning( + "Slice merged-detection raised; treating as not-merged", + pipeline_id=pipeline_id, + slice_id=slice_id, + error=str(detect_err), + ) + already_merged = False + if already_merged: + logger.info( + "Slice already merged into parent on origin — skipping spawn (#2549)", + pipeline_id=pipeline_id, + slice_id=slice_id, + integration_branch=integration_branch, + parent_branch=parent_branch, + ) + scheduler.record_complete(slice_id) + _persist_slice_status_complete(slice_id) + try: + remove_peer_consensus_tracker(pipeline_id, slice_id) + except Exception: # noqa: BLE001 + pass + return 0, ( + f"slice {slice_id}: already merged into " + f"{parent_branch} on origin — skipped" + ) + # #2137 TASK-4-2: create the slice integration branch # on origin BEFORE spawning containers. Push # ``parent_branch:refs/heads/integration_branch`` @@ -12485,7 +13116,7 @@ def _run_one_slice_inner(slice_id: str, parent_slice_id: str | None) -> tuple[in integration_branch=integration_branch, ) - exit_code_inner, logs_inner = _run_concurrent_phase( + exit_code_inner, logs_inner = _run_concurrent_phase_with_impasse_retry( pipeline_id=pipeline_id, pipeline=pipeline, phase="implement", @@ -12663,6 +13294,7 @@ def _run_one_slice_inner(slice_id: str, parent_slice_id: str | None) -> tuple[in ) scheduler.record_complete(slice_id) + _persist_slice_status_complete(slice_id) try: remove_peer_consensus_tracker(pipeline_id, slice_id) except Exception: # noqa: BLE001 @@ -12787,6 +13419,249 @@ def _run_one_slice_inner(slice_id: str, parent_slice_id: str | None) -> tuple[in return overall_exit, aggregated +def _clear_stale_impasses_for_producers( + repo_path: Path, + pipeline_id: str, + producer_roles: "list[ContractAgentRole]", # noqa: UP037 + *, + cleanup_reason: str, +) -> None: + """Drop the ``impasse`` field from each producer's per-pipeline + agent-output file before the next BRC cycle. + + ``save_agent_output`` writes with ``mode="w"`` so a producer that + respawns and reaches its handoff write will overwrite the stale + impasse on its own. But if a producer crashes before writing in the + next iteration (or if the implement roster ever becomes + contract-task-driven, in which case a producer with no remaining + tasks won't spawn at all), the iter-N impasse file would persist + into iter-N+1's ``collect_impasses`` scan and re-trigger routing on + a stale signal — which the ``delegation_attempts`` counter would + then translate into a spurious "second impasse on same task" HITL + escalation. + + Pre-clearing the field keeps ``collect_impasses`` honest about what + came out of the *current* iteration only. Other top-level fields on + the agent output (``handoff_data``, ``role``, anything else) are + preserved. + """ + for role_enum in producer_roles: + try: + existing = load_agent_output(repo_path, role_enum, identifier=pipeline_id) + except Exception as exc: # noqa: BLE001 + logger.debug( + "Could not pre-load agent output to clear stale impasse", + pipeline_id=pipeline_id, + role=role_enum.value, + error=str(exc), + ) + continue + if not isinstance(existing, dict) or "impasse" not in existing: + continue + cleaned = {k: v for k, v in existing.items() if k != "impasse"} + try: + save_agent_output( + repo_path, + role_enum, + cleaned, + identifier=pipeline_id, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Failed to clear stale impasse from agent output", + pipeline_id=pipeline_id, + role=role_enum.value, + error=str(exc), + ) + continue + logger.info( + "Cleared stale impasse from agent output", + pipeline_id=pipeline_id, + role=role_enum.value, + cleanup_reason=cleanup_reason, + ) + + +def _run_concurrent_phase_with_impasse_retry( + pipeline_id: str, + pipeline: Pipeline, + phase: str, + spawner, + repo_volumes: dict[str, str], + gateway_mode: str, + repos: list[str], + sandbox_env: dict[str, str], + store, + certs_volume: str | None, + worktree_repo_path: Path, + review_feedback: str | None = None, + slice_id: str | None = None, +) -> tuple[int, str]: + """Run a concurrent phase, auto-delegating impasses once before HITL. + + Wraps :func:`_run_concurrent_phase` with the runtime escape-hatch + introduced in #2529: + + 1. Run the BRC cycle as usual. + 2. After it exits, scan each producer's ``AgentOutput`` for a typed + :class:`egg_contracts.Impasse`. + 3. For ``WRONG_ROLE`` impasses with a single eligible alternative + producer role and ``task.delegation_attempts == 0``, mutate + ``task.role`` to the suggested role and re-run the BRC cycle + once. The new spawn picks up the role flip when + ``_build_agent_prompt`` re-reads the contract. + 4. For everything else (second impasse, non-WRONG_ROLE category, + no eligible alternative role, unresolvable task_id) the helper + creates a HITL decision on the contract and the slice exits + so the operator can choose between cancel / re-plan / manual + resolution. ``feedback_no_auto_hitl.md``: the orchestrator + creates the decision; surfacing to the user is the operator + layer's job. + + Pipeline-level (non-sliced) callers can pass ``slice_id=None``; + the routing helper falls back to a contract-wide search for the + impassed task. + """ + try: + from impasse_routing import ( + ImpasseAction, + collect_impasses, + route_impasses, + ) + except ImportError: + from orchestrator.impasse_routing import ( # type: ignore[no-redef] + ImpasseAction, + collect_impasses, + route_impasses, + ) + try: + from egg_contracts.agent_roles import AgentRole as ContractAgentRoleEnum + except ImportError: # pragma: no cover - import seam parity + from shared.egg_contracts.agent_roles import ( # type: ignore[no-redef] + AgentRole as ContractAgentRoleEnum, + ) + # Two attempts max: original + at most one delegated retry. The + # ``delegation_attempts`` counter on the contract task enforces the + # same bound when the slice is restarted out-of-band by an + # operator, so a long-lived pipeline can never escape this gate. + MAX_IMPASSE_ATTEMPTS = 2 + + # Producer roles only — impasses are a producer concept; reviewers + # don't author tasks. Mirrors the producer trio in + # ``shared/egg_restrictions/patterns.py``. + producer_roles = [ + ContractAgentRoleEnum.CODER, + ContractAgentRoleEnum.TESTER, + ContractAgentRoleEnum.DOCUMENTER, + ] + + last_exit = 0 + last_logs = "" + for attempt in range(MAX_IMPASSE_ATTEMPTS): + is_terminal = attempt + 1 == MAX_IMPASSE_ATTEMPTS + + last_exit, last_logs = _run_concurrent_phase( + pipeline_id=pipeline_id, + pipeline=pipeline, + phase=phase, + spawner=spawner, + repo_volumes=repo_volumes, + gateway_mode=gateway_mode, + repos=repos, + sandbox_env=sandbox_env, + store=store, + certs_volume=certs_volume, + worktree_repo_path=worktree_repo_path, + review_feedback=review_feedback, + slice_id=slice_id, + ) + + try: + impasses = collect_impasses( + Path(worktree_repo_path), + pipeline_id, + producer_roles, + ) + except Exception as scan_err: # noqa: BLE001 + logger.warning( + "Impasse scan raised; continuing without delegation", + pipeline_id=pipeline_id, + slice_id=slice_id, + error=str(scan_err), + ) + return last_exit, last_logs + + if not impasses: + return last_exit, last_logs + + try: + # On the terminal iteration we have no remaining BRC cycle + # to respawn with a new role, so a delegation made here + # would silently dangle (review feedback #2 on PR #2553). + # Force every impasse onto the escalate path instead. + decisions = route_impasses( + repo_path=Path(worktree_repo_path), + pipeline_id=pipeline_id, + contract_identifier=pipeline_id, + impasses=impasses, + slice_id=slice_id, + force_escalate=is_terminal, + ) + except Exception as route_err: # noqa: BLE001 + logger.error( + "Impasse routing raised; surfacing slice failure", + pipeline_id=pipeline_id, + slice_id=slice_id, + error=str(route_err), + ) + return last_exit, last_logs + + all_delegated = decisions and all(d.action == ImpasseAction.DELEGATE for d in decisions) + if not all_delegated: + # Any escalation, or an empty decision list, means the + # operator gates the next move. Don't auto-retry. + for d in decisions: + logger.info( + "Impasse decision", + pipeline_id=pipeline_id, + slice_id=slice_id, + action=d.action.value, + role=d.role, + task_id=d.task_id, + new_role=d.new_role, + reason=d.reason, + hitl_decision_id=d.hitl_decision_id, + ) + return last_exit, last_logs + + # All impasses delegated cleanly — the contract has been + # mutated, log the swap and let the loop respawn with the new + # roles. Last attempt falls through and returns whatever the + # second BRC cycle produced. + for d in decisions: + logger.info( + "Impasse delegated; retrying slice with new role", + pipeline_id=pipeline_id, + slice_id=slice_id, + attempt=attempt + 1, + from_role=d.role, + to_role=d.new_role, + task_id=d.task_id, + ) + + # Drop the now-routed impasse signals before the next BRC + # cycle, so a producer that crashes pre-handoff in iter-N+1 + # cannot resurrect this iteration's impasse via a stale file. + _clear_stale_impasses_for_producers( + Path(worktree_repo_path), + pipeline_id, + producer_roles, + cleanup_reason="post-delegation cleanup", + ) + + return last_exit, last_logs + + def _run_concurrent_phase( pipeline_id: str, pipeline: Pipeline, @@ -14876,11 +15751,38 @@ def _populate_contract_from_plan( if result.pr_title: from egg_contracts.models import PRMetadata + # #2548 — preserve orchestrator-populated runtime fields on + # ``PRMetadata`` across re-populates. The planner-emitted + # ``context_title`` / ``context_description`` still flow in + # fresh from the parsed plan; the fields below are populated + # by orchestrator code paths (gateway primitives, the + # conditional-ACK gate at ``complete_phase``) and would + # otherwise be silently dropped when this safety-net + # populator re-runs (e.g. on a ``start_phase=implement`` + # re-entry where ``deferred_actions`` was already populated + # during implement-phase close). + # + # ``deferred_actions`` is the merge-blocking *Pre-merge + # Obligations* handoff written by ``decisions.py`` after a + # conditional-ACK gate resolves; losing it here erases the + # reviewer's only durable handoff for git-mv / migration / + # cross-repo flips. See test + # ``test_populate_contract_from_plan_preserves_deferred_actions``. + preserved_branch = contract.pr.context_branch if contract.pr is not None else None + preserved_pr_number = contract.pr.context_pr_number if contract.pr is not None else None + preserved_deferred_actions = ( + list(contract.pr.deferred_actions) if contract.pr is not None else [] + ) contract.pr = PRMetadata( title=result.pr_title, description=result.pr_description or "", test_plan=result.pr_test_plan or "", manual_steps=result.pr_manual_steps or "", + context_title=result.pr_context_title, + context_description=result.pr_context_description, + context_branch=preserved_branch, + context_pr_number=preserved_pr_number, + deferred_actions=preserved_deferred_actions, ) changed = True @@ -16744,6 +17646,16 @@ def _health_monitor_poll(monitor, stop_event: threading.Event, interval: float = worktree_repo_path=worktree_repo_path, ) else: + # Pre-#2137 monolithic-implement fallback. The + # impasse-retry wrapper deliberately wraps only + # the slice-loop call site (#2529): impasse + # delegation rewires a *task* between producer + # roles, which only makes sense per-slice. + # Pipelines that don't use the slice loop are + # legacy / single-PR-shape, so an impasse here + # surfaces as a normal slice failure and the + # operator handles it via the existing + # phase-failure HITL path. exit_code, container_logs = _run_concurrent_phase( pipeline_id=pipeline_id, pipeline=pipeline, diff --git a/orchestrator/routes/signals.py b/orchestrator/routes/signals.py index 13ef92213f..011ceb4193 100644 --- a/orchestrator/routes/signals.py +++ b/orchestrator/routes/signals.py @@ -917,6 +917,137 @@ def _validate_tester_check_coverage( ) +def _validate_planner_role_alignment( + pipeline_id: str, + payload: dict[str, Any], + repo_path: Path, + *, + pipeline_state: Any | None = None, + worktree_path: Path | None = None, +) -> None: + """Validate task role↔files alignment for a planner proposal (#2527). + + Reads the plan draft at the proposed commit via ``git show`` against + the orchestrator's pipeline worktree and runs + ``validate_task_role_alignment``. Raises ``ValueError`` if any task + is assigned to a role that cannot push its files — the caller's + ``handle_consensus_propose_signal`` ``except`` block then returns 400 + to the planner before the proposal is recorded on the tracker, so no + reviewer cycle is wasted on a structurally-broken plan. + + The check mirrors the gateway's push-time blocked-pattern logic + (``gateway/phase_filter.py::FileRestriction.is_file_blocked``), so a + rejection here predicts a push-time ``403 restricted_path_modified`` + in the implement phase. Caught here it costs the planner one + re-propose; caught at push time it costs a full producer cycle. + + Graceful degradation: when the plan file doesn't exist at the + proposed commit, ``git show`` fails, parsing fails, or the validator + raises, this function returns silently. The push-time gateway check + remains the backstop, and the existing forest-validation pass at + plan-ingestion time still runs. + + ``pipeline_state`` and ``worktree_path`` should be passed in by + ``handle_consensus_propose_signal`` so the state-store + worktree + lookups it has already performed for ``_verify_commit_on_branch`` + aren't duplicated. The function falls back to loading them itself + when called directly (e.g. from unit tests), preserving backward + compatibility with patches on ``get_state_store`` / ``resolve_worktree_path``. + """ + commit_sha = (payload.get("commit_sha") or "").strip() + if not commit_sha: + return + + if pipeline_state is None: + try: + pipeline_state = get_state_store(repo_path).load_pipeline(pipeline_id) + except StateStoreError: + return + + if not pipeline_state.branch: + return + + # Build the plan draft path. Imported lazily to avoid pulling the + # 16k-line ``routes.pipelines`` module into ``signals`` import time. + try: + from routes.pipelines import _get_draft_path + except ImportError: + try: + from .pipelines import _get_draft_path # type: ignore[no-redef] + except ImportError: + return + plan_rel = _get_draft_path( + "plan", + issue_number=pipeline_state.issue_number, + pipeline_id=pipeline_id, + mode=getattr(pipeline_state, "mode", None), + ) + if not plan_rel: + return + + if worktree_path is None: + worktree_path = resolve_worktree_path(pipeline_id, repo_path) + + # Read plan content as committed at the proposed SHA so a stale + # local checkout cannot mask a real misassignment. The preceding + # ``_verify_commit_on_branch`` call has already done a ``git fetch``, + # making the commit reachable in the worktree. + try: + result = subprocess.run( + ["git", "-C", str(worktree_path), "show", f"{commit_sha}:{plan_rel}"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if result.returncode != 0: + return # Plan not present at this commit — nothing to validate. + plan_text = result.stdout + except Exception as exc: + logger.warning( + "plan role-alignment validation: git show failed (non-blocking)", + pipeline_id=pipeline_id, + commit_sha=commit_sha, + error=str(exc), + ) + return + + try: + from egg_contracts.plan_parser import ( + parse_plan, + validate_task_role_alignment, + ) + except ImportError: + return + + try: + parsed = parse_plan(plan_text) + if not parsed.success: + return + slices = parsed.to_contract_slices() + errors = validate_task_role_alignment(slices) + except Exception as exc: + logger.warning( + "plan role-alignment validation: validator raised (non-blocking)", + pipeline_id=pipeline_id, + commit_sha=commit_sha, + error=str(exc), + ) + return + + if not errors: + return + + bullet_list = "\n".join(f" - {e}" for e in errors) + raise ValueError( + "Plan proposal rejected: task role↔files alignment violations.\n" + "The following tasks are assigned to roles whose blocklist " + "forbids their files (would 403 at push time per " + "shared/egg_restrictions/patterns.py). Update the affected " + "tasks' 'role' field and re-propose:\n" + bullet_list + ) + + def _resolve_pipeline_phase(pipeline_id: str, repo_path: Path) -> str: """Resolve the current phase for a pipeline, with graceful fallback. @@ -938,6 +1069,7 @@ def _emit_ready_to_confirm_nudges( phase: str, newly_ready: list[dict[str, Any]], tracker: Any = None, + slice_id: str | None = None, ) -> None: """Emit a STATUS to each producer that newly became ready to confirm. @@ -951,12 +1083,18 @@ def _emit_ready_to_confirm_nudges( supplied, the per-version memo entry is rolled back so the producer can be re-nudged on the next state change. Other producers in the batch are still attempted. + + ``slice_id`` is forwarded into the STATUS metadata so the + implement-phase BRC writer (#2548) routes the nudge into the + producer's per-slice transcript. Pipeline-level (non-slice) callers + leave it as ``None``. """ if not newly_ready: return from message_store import Message, MessageType, get_message_store store = get_message_store() + _slice_meta: dict[str, Any] = {"slice_id": slice_id} if slice_id is not None else {} for entry in newly_ready: producer = entry["role"] version = entry["version"] @@ -975,7 +1113,7 @@ def _emit_ready_to_confirm_nudges( f"`egg-orch consensus confirmed` to confirm." ), phase=phase, - metadata={"ready_to_confirm": True, "version": version}, + metadata={"ready_to_confirm": True, "version": version, **_slice_meta}, ) ) except Exception as exc: @@ -1062,28 +1200,35 @@ def handle_consensus_propose_signal( # Verify commit SHA exists on the expected branch before accepting # the proposal (#1473). Reuses _verify_commit_on_branch() from the # completion handler — graceful degradation on network errors (None). + # + # ``pipeline_state`` and ``worktree_path`` are loaded once here and + # threaded into ``_validate_planner_role_alignment`` below so the + # validator's dependency on this block is explicit and the + # state-store + worktree lookups aren't duplicated. commit_sha = payload.get("commit_sha", "") + pipeline_state = None + worktree_path = None if commit_sha: try: store_mod = get_state_store(repo_path) - pipeline = store_mod.load_pipeline(pipeline_id) - if pipeline.branch: + pipeline_state = store_mod.load_pipeline(pipeline_id) + if pipeline_state.branch: worktree_path = resolve_worktree_path(pipeline_id, repo_path) branch_verified = _verify_commit_on_branch( commit_sha, - pipeline.branch, + pipeline_state.branch, worktree_path, pipeline_id, ) if branch_verified is False: return make_error_response( f"Proposal rejected: commit {commit_sha} not found on " - f"expected branch {pipeline.branch}. Push your work before " - f"proposing consensus.", + f"expected branch {pipeline_state.branch}. Push your " + f"work before proposing consensus.", status_code=409, details={ "commit_sha": commit_sha, - "expected_branch": pipeline.branch, + "expected_branch": pipeline_state.branch, "pipeline_id": pipeline_id, }, ) @@ -1101,6 +1246,17 @@ def handle_consensus_propose_signal( # rejected proposals. if agent_role == "tester": _validate_tester_check_coverage(pipeline_id, payload, repo_path) + # Validate task_planner proposals don't misassign tasks to roles + # whose blocklist forbids their files (#2527). Same placement + # rule: BEFORE handle_propose so the tracker isn't mutated. + elif agent_role == "task_planner": + _validate_planner_role_alignment( + pipeline_id, + payload, + repo_path, + pipeline_state=pipeline_state, + worktree_path=worktree_path, + ) # Check if this is a re-proposal changed_artifacts = data.get("changed_artifacts") @@ -1128,7 +1284,15 @@ def handle_consensus_propose_signal( details=result, ) - # Write consensus message to message bus + # Write consensus message to message bus. + # Tag every CONSENSUS_* message with slice_id metadata when the + # producer is slice-scoped so the implement-phase BRC writer + # (#2548) can partition messages into per-slice transcript + # files. Pipeline-level (non-slice) callers leave the metadata + # off entirely — matches the legacy non-slice shape and signals + # the writer to fall back to its aggregate filename. + _slice_meta: dict[str, Any] = {"slice_id": slice_id} if slice_id is not None else {} + from message_store import Message, MessageType, get_message_store store = get_message_store() @@ -1146,6 +1310,7 @@ def handle_consensus_propose_signal( "payload": payload, "version": result.get("version"), "commit_sha": commit_sha, + **_slice_meta, }, ) ) @@ -1171,13 +1336,16 @@ def handle_consensus_propose_signal( metadata={ "producer_role": agent_role, "version": result.get("version"), + **_slice_meta, }, ) ) # A new proposal can unblock the global zero-proposal guard for # producers that were previously fully ACKed but unable to confirm. - _emit_ready_to_confirm_nudges(pipeline_id, phase, result.get("newly_ready", []), tracker) + _emit_ready_to_confirm_nudges( + pipeline_id, phase, result.get("newly_ready", []), tracker, slice_id=slice_id + ) return make_success_response( f"Proposal recorded for {agent_role}", @@ -1270,6 +1438,10 @@ def handle_consensus_ack_signal( store = get_message_store() phase = _resolve_pipeline_phase(pipeline_id, repo_path) + # Tag with slice_id metadata for the implement-phase BRC writer's + # per-slice partitioning (#2548). Pipeline-level callers leave it + # off, matching the legacy non-slice shape. + _slice_meta: dict[str, Any] = {"slice_id": slice_id} if slice_id is not None else {} store.add_message( Message( pipeline_id=pipeline_id, @@ -1279,7 +1451,11 @@ def handle_consensus_ack_signal( subject=f"ACK from {reviewer_role} for {producer_role}", body=payload.get("reason", ""), phase=phase, - metadata={"payload": payload, "version": result.get("version")}, + metadata={ + "payload": payload, + "version": result.get("version"), + **_slice_meta, + }, ) ) @@ -1288,7 +1464,9 @@ def handle_consensus_ack_signal( # critical-reviewer ACK predicate. Replaces the prior ``fully_acked`` # gate which fired before global guards (e.g. zero-proposal) cleared # and could mislead an advisory-only producer like documenter (#2078). - _emit_ready_to_confirm_nudges(pipeline_id, phase, result.get("newly_ready", []), tracker) + _emit_ready_to_confirm_nudges( + pipeline_id, phase, result.get("newly_ready", []), tracker, slice_id=slice_id + ) return make_success_response( f"ACK recorded: {reviewer_role} -> {producer_role}", @@ -1359,6 +1537,9 @@ def handle_consensus_nack_signal( from message_store import Message, MessageType, get_message_store store = get_message_store() + # Tag with slice_id metadata for the implement-phase BRC writer's + # per-slice partitioning (#2548). + _slice_meta: dict[str, Any] = {"slice_id": slice_id} if slice_id is not None else {} store.add_message( Message( pipeline_id=pipeline_id, @@ -1372,6 +1553,7 @@ def handle_consensus_nack_signal( "payload": payload, "reason": result.get("reason"), "revision_count": result.get("revision_count"), + **_slice_meta, }, ) ) @@ -1423,6 +1605,9 @@ def handle_consensus_withdraw_signal( from message_store import Message, MessageType, get_message_store store = get_message_store() + # Tag with slice_id metadata for the implement-phase BRC writer's + # per-slice partitioning (#2548). + _slice_meta: dict[str, Any] = {"slice_id": slice_id} if slice_id is not None else {} store.add_message( Message( pipeline_id=pipeline_id, @@ -1432,6 +1617,7 @@ def handle_consensus_withdraw_signal( subject=f"Withdrawal by {agent_role}", body=reason, phase=_resolve_pipeline_phase(pipeline_id, repo_path), + metadata=_slice_meta, ) ) @@ -1859,6 +2045,11 @@ def handle_consensus_excuse_producer_signal( store = get_message_store() phase = _resolve_pipeline_phase(pipeline_id, repo_path) + # Tag with slice_id so the implement-phase BRC writer can + # partition this STATUS into the correct per-slice transcript + # (#2548). Pipeline-level (non-slice) callers leave the metadata + # off entirely. + _slice_meta: dict[str, Any] = {"slice_id": slice_id} if slice_id is not None else {} # Notify all agents that the producer has been excused store.add_message( @@ -1879,6 +2070,7 @@ def handle_consensus_excuse_producer_signal( "producer_role": producer_role, "reason": reason, "affected_reviewers": result.get("affected_reviewers", []), + **_slice_meta, }, ) ) @@ -1977,6 +2169,12 @@ def handle_consensus_resolve_obligation_signal( store = get_message_store() phase = _resolve_pipeline_phase(pipeline_id, repo_path) + # Tag with slice_id metadata for the implement-phase BRC writer's + # per-slice partitioning (#2548). CONSENSUS_OBLIGATION_RESOLVED is + # in BRC_HISTORY_TYPES and can fire during the implement phase + # with slice scope (typical case: tester satisfies a coder's + # conditional ACK on a per-slice review). + _slice_meta: dict[str, Any] = {"slice_id": slice_id} if slice_id is not None else {} store.add_message( Message( pipeline_id=pipeline_id, @@ -1996,6 +2194,7 @@ def handle_consensus_resolve_obligation_signal( "note": note, "version": result.get("version"), "condition": result.get("condition", ""), + **_slice_meta, }, ) ) @@ -2069,6 +2268,10 @@ def handle_consensus_producer_push_signal( store = get_message_store() phase = _resolve_pipeline_phase(pipeline_id, repo_path) + # Tag with slice_id metadata for the implement-phase BRC + # writer's per-slice partitioning (#2548). Same shape as the + # manual re-propose path in handle_consensus_propose_signal. + _slice_meta: dict[str, Any] = {"slice_id": slice_id} if slice_id is not None else {} store.add_message( Message( pipeline_id=pipeline_id, @@ -2087,6 +2290,7 @@ def handle_consensus_producer_push_signal( "commit_sha": commit_sha, "version": result.get("version"), "changed_files": changed_files, + **_slice_meta, }, ) ) @@ -2117,6 +2321,7 @@ def handle_consensus_producer_push_signal( "producer_role": agent_role, "version": result.get("version"), "commit_sha": commit_sha, + **_slice_meta, }, ) ) @@ -2129,7 +2334,7 @@ def handle_consensus_producer_push_signal( # ACKs were just invalidated), but skipping the call would # silently regress if a future guard depends on peer versions. _emit_ready_to_confirm_nudges( - pipeline_id, phase, result.get("newly_ready", []), tracker + pipeline_id, phase, result.get("newly_ready", []), tracker, slice_id=slice_id ) return make_success_response( diff --git a/orchestrator/tests/test_brc_history.py b/orchestrator/tests/test_brc_history.py index f43d1a5c38..b63e8b8256 100644 --- a/orchestrator/tests/test_brc_history.py +++ b/orchestrator/tests/test_brc_history.py @@ -63,6 +63,15 @@ def _make_contract_json( return contract +# Default slice_id stamped onto implement-phase BRC messages by the test +# helpers below. Issue #2548 hard-switchover: ``_write_brc_history`` drops +# implement-phase BRC messages without a ``metadata['slice_id']`` with a +# warning, so every fixture in this module must seed one. Tests that want +# to exercise the missing-slice_id WARNING path explicitly pass +# ``slice_id=None`` (or omit ``slice_id`` from the override metadata). +_DEFAULT_IMPLEMENT_SLICE_ID = "slice-1" + + def _make_brc_message( pipeline_id="issue-42", from_role="coder", @@ -72,8 +81,25 @@ def _make_brc_message( phase="implement", timestamp=None, metadata=None, + slice_id="__default__", ): - """Create a BRC Message for testing.""" + """Create a BRC Message for testing. + + ``slice_id`` is merged into ``metadata`` for implement-phase messages so + tests can rely on the post-#2548 hard-switchover writer producing per-slice + files. Pass ``slice_id=None`` to omit it (used by the missing-slice_id + WARNING regression test). When ``metadata`` already contains a + ``slice_id`` key it wins (caller intent). + """ + md = dict(metadata or {}) + if slice_id == "__default__": + # Default policy: implement phase auto-stamps slice-1 unless metadata + # already supplies one; non-implement phases never auto-stamp. + if phase == "implement" and "slice_id" not in md: + md["slice_id"] = _DEFAULT_IMPLEMENT_SLICE_ID + elif slice_id is not None: + md.setdefault("slice_id", slice_id) + # slice_id=None and metadata lacks "slice_id" -> intentionally unattributed return Message( pipeline_id=pipeline_id, from_role=from_role, @@ -83,11 +109,11 @@ def _make_brc_message( body=body, phase=phase, timestamp=timestamp or datetime(2026, 4, 8, 12, 0, 0, tzinfo=UTC), - metadata=metadata or {}, + metadata=md, ) -def _make_brc_messages(pipeline_id="issue-42", phase="implement"): +def _make_brc_messages(pipeline_id="issue-42", phase="implement", slice_id="__default__"): """Create a typical set of BRC messages for a phase.""" return [ _make_brc_message( @@ -98,6 +124,7 @@ def _make_brc_messages(pipeline_id="issue-42", phase="implement"): body="Implemented auth fix", phase=phase, timestamp=datetime(2026, 4, 8, 12, 0, 0, tzinfo=UTC), + slice_id=slice_id, ), _make_brc_message( pipeline_id=pipeline_id, @@ -107,6 +134,7 @@ def _make_brc_messages(pipeline_id="issue-42", phase="implement"): body="Code looks good", phase=phase, timestamp=datetime(2026, 4, 8, 12, 5, 0, tzinfo=UTC), + slice_id=slice_id, ), _make_brc_message( pipeline_id=pipeline_id, @@ -116,6 +144,7 @@ def _make_brc_messages(pipeline_id="issue-42", phase="implement"): body="Tests pass", phase=phase, timestamp=datetime(2026, 4, 8, 12, 10, 0, tzinfo=UTC), + slice_id=slice_id, ), _make_brc_message( pipeline_id=pipeline_id, @@ -125,11 +154,12 @@ def _make_brc_messages(pipeline_id="issue-42", phase="implement"): body="", phase=phase, timestamp=datetime(2026, 4, 8, 12, 15, 0, tzinfo=UTC), + slice_id=slice_id, ), ] -def _make_mixed_messages(pipeline_id="issue-42", phase="implement"): +def _make_mixed_messages(pipeline_id="issue-42", phase="implement", slice_id="__default__"): """Create messages with both BRC and non-BRC types.""" return [ _make_brc_message( @@ -139,6 +169,7 @@ def _make_mixed_messages(pipeline_id="issue-42", phase="implement"): subject="Working on task", body="Starting implementation", phase=phase, + slice_id=slice_id, ), _make_brc_message( pipeline_id=pipeline_id, @@ -147,6 +178,7 @@ def _make_mixed_messages(pipeline_id="issue-42", phase="implement"): subject="Proposal from coder", body="Done with implementation", phase=phase, + slice_id=slice_id, ), _make_brc_message( pipeline_id=pipeline_id, @@ -155,10 +187,16 @@ def _make_mixed_messages(pipeline_id="issue-42", phase="implement"): subject="Test status", body="Running tests", phase=phase, + slice_id=slice_id, ), ] +def _implement_path(tmp_path, identifier="42", suffix=".md", slice_id=_DEFAULT_IMPLEMENT_SLICE_ID): + """Resolve the canonical per-slice implement-phase BRC history path (#2548).""" + return tmp_path / ".egg-state" / "brc-history" / f"{identifier}-implement-{slice_id}{suffix}" + + def _setup_contract(tmp_path, issue_number=42): """Set up a contract JSON file in the temp directory.""" contract_dir = tmp_path / ".egg-state" / "contracts" @@ -181,11 +219,14 @@ def test_creates_file_with_brc_messages(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - expected_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + expected_path = _implement_path(tmp_path) assert expected_path.exists(), f"Expected BRC history file at {expected_path}" content = expected_path.read_text() assert len(content) > 0 + # #2548 hard switchover: aggregate file MUST NOT be produced. + aggregate = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + assert not aggregate.exists(), "Aggregate implement file leaked through hard switchover" def test_file_contains_chronological_messages(self, tmp_path): """BRC history file contains messages in chronological order.""" @@ -198,7 +239,7 @@ def test_file_contains_chronological_messages(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() # Verify all BRC message types appear assert "CONSENSUS_PROPOSE" in content @@ -266,7 +307,7 @@ def test_filters_only_brc_history_messages(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - expected_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + expected_path = _implement_path(tmp_path) assert expected_path.exists() content = expected_path.read_text() @@ -318,7 +359,7 @@ def test_file_contains_phase_header(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() # File should contain a header with phase info assert "implement" in content.lower() @@ -352,7 +393,7 @@ def test_includes_nack_messages(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "CONSENSUS_NACK" in content assert "reviewer_code" in content @@ -395,7 +436,7 @@ def test_includes_re_review_and_withdraw(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "CONSENSUS_RE_REVIEW" in content assert "CONSENSUS_WITHDRAW" in content @@ -438,7 +479,7 @@ def test_filters_messages_by_phase(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() # Should contain implement-phase messages assert "Implement proposal" in content assert "ACK for implement" in content @@ -499,7 +540,11 @@ def test_multiple_phases_create_separate_files(self, tmp_path): history_dir = tmp_path / ".egg-state" / "brc-history" assert (history_dir / "42-refine.md").exists() assert (history_dir / "42-plan.md").exists() - assert (history_dir / "42-implement.md").exists() + # #2548: implement phase emits a per-slice file, not the aggregate. + assert (history_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md").exists() + assert not (history_dir / "42-implement.md").exists(), ( + "Aggregate implement.md leaked through hard switchover" + ) def test_messages_with_empty_body(self, tmp_path): """Messages with empty body are included but don't break formatting.""" @@ -521,7 +566,7 @@ def test_messages_with_empty_body(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - expected_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + expected_path = _implement_path(tmp_path) assert expected_path.exists() content = expected_path.read_text() assert "CONSENSUS_CONFIRMED" in content @@ -554,7 +599,7 @@ def test_ack_metadata_round_trips_into_yaml_block(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "````yaml" in content assert "artifact_references" in content assert "orchestrator/routes/pipelines.py" in content @@ -584,7 +629,7 @@ def test_nack_metadata_reason_and_revision_count_round_trip(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "````yaml" in content assert "revision_count" in content assert "payload" in content @@ -611,7 +656,7 @@ def test_propose_commit_sha_in_metadata(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "commit_sha" in content assert "abc123def456" in content @@ -638,7 +683,7 @@ def test_to_role_shown_for_directed_messages(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "→ reviewer_code" in content def test_to_role_omitted_for_broadcast(self, tmp_path): @@ -662,7 +707,7 @@ def test_to_role_omitted_for_broadcast(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "→" not in content def test_triple_backtick_body_does_not_corrupt_yaml_block(self, tmp_path): @@ -693,7 +738,7 @@ def test_triple_backtick_body_does_not_corrupt_yaml_block(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() # The body should appear verbatim assert "```python" in content assert "print('hi')" in content @@ -739,7 +784,7 @@ def test_handoff_included_in_history(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "HANDOFF" in content assert "Code ready for testing" in content @@ -764,7 +809,7 @@ def test_overseer_alert_included_in_history(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "OVERSEER_ALERT" in content @@ -782,7 +827,7 @@ def test_json_file_written_alongside_md(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - json_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.json" + json_path = _implement_path(tmp_path, suffix=".json") assert json_path.exists(), "JSON companion file should exist" def test_json_round_trips_to_message_dicts(self, tmp_path): @@ -796,7 +841,7 @@ def test_json_round_trips_to_message_dicts(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - json_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.json" + json_path = _implement_path(tmp_path, suffix=".json") data = json.loads(json_path.read_text()) assert isinstance(data, list) assert len(data) == len(messages) @@ -878,7 +923,7 @@ def test_json_includes_non_consensus_types(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - json_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.json" + json_path = _implement_path(tmp_path, suffix=".json") data = json.loads(json_path.read_text()) types_in_json = {entry["message_type"] for entry in data} @@ -914,7 +959,7 @@ def test_json_includes_metadata_fields(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - json_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.json" + json_path = _implement_path(tmp_path, suffix=".json") data = json.loads(json_path.read_text()) assert len(data) == 1 metadata = data[0]["metadata"] @@ -946,7 +991,7 @@ def test_json_write_failure_does_not_block_md(self, tmp_path): ): _write_brc_history(tmp_path, "issue-42", "implement", 42) - md_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + md_path = _implement_path(tmp_path) assert md_path.exists(), "Markdown file should still be written despite JSON failure" @@ -1040,7 +1085,7 @@ def test_yaml_block_is_parseable(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() # Extract YAML blocks from fenced code blocks in_yaml = False @@ -1094,7 +1139,7 @@ def test_yaml_block_with_nested_metadata(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() in_yaml = False yaml_lines: list[str] = [] @@ -1142,14 +1187,14 @@ def test_md_write_failure_does_not_block_json(self, tmp_path): history_dir.mkdir(parents=True, exist_ok=True) # Make the .md file a directory so write_text fails - md_path = history_dir / "42-implement.md" + md_path = history_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" md_path.mkdir() with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) # JSON should still be written despite MD failure - json_path = history_dir / "42-implement.json" + json_path = history_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.json" assert json_path.exists(), "JSON file should be written despite markdown write failure" data = json.loads(json_path.read_text()) assert len(data) == 1 @@ -1169,7 +1214,7 @@ def test_no_json_file_when_no_brc_messages(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - json_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.json" + json_path = _implement_path(tmp_path, suffix=".json") assert not json_path.exists(), "No JSON file should be created for empty message store" def test_multiple_phases_create_separate_json_files(self, tmp_path): @@ -1190,11 +1235,19 @@ def test_multiple_phases_create_separate_json_files(self, tmp_path): history_dir = tmp_path / ".egg-state" / "brc-history" assert (history_dir / "42-refine.json").exists() assert (history_dir / "42-plan.json").exists() - assert (history_dir / "42-implement.json").exists() + # #2548: implement is per-slice; aggregate JSON must NOT exist. + assert (history_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.json").exists() + assert not (history_dir / "42-implement.json").exists(), ( + "Aggregate implement.json leaked through hard switchover" + ) # Each JSON file should only contain messages for that phase - for phase in ["refine", "plan", "implement"]: - data = json.loads((history_dir / f"42-{phase}.json").read_text()) + for phase, fname in [ + ("refine", "42-refine.json"), + ("plan", "42-plan.json"), + ("implement", f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.json"), + ]: + data = json.loads((history_dir / fname).read_text()) for entry in data: assert entry["phase"] == phase, ( f"JSON for {phase} contains message from {entry['phase']}" @@ -1222,7 +1275,7 @@ def test_json_preserves_to_role_for_directed_messages(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - json_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.json" + json_path = _implement_path(tmp_path, suffix=".json") data = json.loads(json_path.read_text()) assert data[0]["to_role"] == "reviewer_code" @@ -1250,7 +1303,7 @@ def test_status_included_in_history(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "STATUS" in content assert "Ready to confirm" in content @@ -1274,7 +1327,7 @@ def test_nudge_included_in_history(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "NUDGE" in content def test_status_replaces_removed_question_type(self, tmp_path): @@ -1305,7 +1358,7 @@ def test_status_replaces_removed_question_type(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "STATUS" in content assert "Should I test the SSO path?" in content @@ -1329,7 +1382,7 @@ def test_agent_failed_included_in_history(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "AGENT_FAILED" in content assert "Container exited with code 1" in content @@ -1353,7 +1406,7 @@ def test_handoff_included_in_history(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + content = _implement_path(tmp_path).read_text() assert "HANDOFF" in content @@ -1368,7 +1421,7 @@ def _touch(self, tmp_path, filename: str) -> None: def test_returns_empty_when_identifier_is_none(self, tmp_path): from routes.pipelines import _build_brc_history_link_line - self._touch(tmp_path, "42-implement.md") + self._touch(tmp_path, f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md") assert _build_brc_history_link_line(tmp_path, None) == "" def test_returns_empty_when_history_dir_missing(self, tmp_path): @@ -1379,52 +1432,67 @@ def test_returns_empty_when_history_dir_missing(self, tmp_path): def test_returns_empty_when_no_matching_files(self, tmp_path): from routes.pipelines import _build_brc_history_link_line - # File for a different pipeline/identifier - self._touch(tmp_path, "99-implement.md") + # File for a different pipeline/identifier (#2548: per-slice form). + self._touch(tmp_path, f"99-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md") assert _build_brc_history_link_line(tmp_path, 42) == "" def test_links_files_in_canonical_phase_order(self, tmp_path): - """Phases link in refine → plan → implement → pr order even if files were created otherwise.""" + """Phases link in refine → plan → implement order even if files were created otherwise. + + After #2548, implement-phase BRC files are per-slice (e.g. + ``42-implement-slice-1.md``); the link-line builder treats the suffix + after the identifier as the phase label, so the slice file appears as + ``implement-slice-1`` rather than ``implement``. The canonical phases + ``refine``/``plan``/``pr`` still sort before any non-canonical name — + which now includes the slice suffix. + """ from routes.pipelines import _build_brc_history_link_line - # Create deliberately out of order - self._touch(tmp_path, "42-implement.md") + # Create deliberately out of order. Implement phase uses the per-slice + # form (#2548 hard switchover) — there is no aggregate ``42-implement.md``. + impl_file = f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + self._touch(tmp_path, impl_file) self._touch(tmp_path, "42-plan.md") self._touch(tmp_path, "42-refine.md") result = _build_brc_history_link_line(tmp_path, 42) assert result.startswith("_Per-phase BRC transcripts:") assert result.endswith("._") - # Canonical order: refine before plan before implement + # Canonical order: refine before plan before implement(-slice-N). assert result.index("refine") < result.index("plan") < result.index("implement") - # Link format + # Link format — refine/plan unchanged, implement now includes slice suffix. assert "[`plan`](./.egg-state/brc-history/42-plan.md)" in result assert "[`refine`](./.egg-state/brc-history/42-refine.md)" in result - assert "[`implement`](./.egg-state/brc-history/42-implement.md)" in result + assert ( + f"[`implement-{_DEFAULT_IMPLEMENT_SLICE_ID}`](./.egg-state/brc-history/{impl_file})" + ) in result def test_ignores_json_companions(self, tmp_path): from routes.pipelines import _build_brc_history_link_line - self._touch(tmp_path, "42-implement.md") - self._touch(tmp_path, "42-implement.json") + self._touch(tmp_path, f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md") + self._touch(tmp_path, f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.json") result = _build_brc_history_link_line(tmp_path, 42) # .json not surfaced as its own phase assert ".json" not in result - assert "[`implement`]" in result + assert f"[`implement-{_DEFAULT_IMPLEMENT_SLICE_ID}`]" in result def test_string_identifier_works(self, tmp_path): """Babysit-pr identifiers like 'pr-123-abc1234' glob the corresponding files.""" from routes.pipelines import _build_brc_history_link_line - self._touch(tmp_path, "pr-123-abc1234-implement.md") + impl_file = f"pr-123-abc1234-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + self._touch(tmp_path, impl_file) self._touch(tmp_path, "pr-123-abc1234-plan.md") # Unrelated file for a different identifier must not leak in self._touch(tmp_path, "42-refine.md") result = _build_brc_history_link_line(tmp_path, "pr-123-abc1234") assert "[`plan`](./.egg-state/brc-history/pr-123-abc1234-plan.md)" in result - assert "[`implement`](./.egg-state/brc-history/pr-123-abc1234-implement.md)" in result + assert ( + f"[`implement-{_DEFAULT_IMPLEMENT_SLICE_ID}`](./.egg-state/brc-history/{impl_file})" + ) in result assert "42-refine" not in result def test_unknown_phase_names_sorted_after_canonical(self, tmp_path): @@ -1449,13 +1517,17 @@ def test_body_includes_link_line_when_history_files_exist(self, tmp_path): history_dir = tmp_path / ".egg-state" / "brc-history" history_dir.mkdir(parents=True) (history_dir / "42-plan.md").write_text("stub") - (history_dir / "42-implement.md").write_text("stub") + # #2548: implement is per-slice — the aggregate file is gone. + impl_file = f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + (history_dir / impl_file).write_text("stub") title, body, _ = _build_pr_body(pipeline, tmp_path) assert "_Per-phase BRC transcripts:" in body assert "[`plan`](./.egg-state/brc-history/42-plan.md)" in body - assert "[`implement`](./.egg-state/brc-history/42-implement.md)" in body + assert ( + f"[`implement-{_DEFAULT_IMPLEMENT_SLICE_ID}`](./.egg-state/brc-history/{impl_file})" + ) in body # The dropped inline summary must not reappear assert "## BRC Consensus Summary" not in body # Existing sections still present @@ -1480,8 +1552,952 @@ def test_link_line_appears_before_authored_by(self, tmp_path): _setup_contract(tmp_path) history_dir = tmp_path / ".egg-state" / "brc-history" history_dir.mkdir(parents=True) - (history_dir / "42-implement.md").write_text("stub") + # #2548: per-slice implement file replaces the aggregate. + (history_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md").write_text("stub") title, body, _ = _build_pr_body(pipeline, tmp_path) assert body.index("Per-phase BRC transcripts") < body.index("Authored-by: egg") + + +# --------------------------------------------------------------------------- +# Per-slice implement-phase BRC history (#2548 slice-2) +# --------------------------------------------------------------------------- + + +class TestPerSliceImplementBrcHistory: + """Implement-phase ``_write_brc_history`` partitions BRC messages by + ``metadata['slice_id']`` and writes one file per slice (#2548 slice-2, + hard switchover under D4 — no aggregate ``-implement.{md,json}`` is + produced). + + These tests pin the per-slice partitioning contract end-to-end: + multi-slice fan-out, file-naming shape, message routing into the right + bucket, and the no-aggregate-file invariant that the planner explicitly + called out as the slice's most observable acceptance criterion. + """ + + def _make_implement_msgs(self, slice_id, *, body_prefix="work"): + """Build a 4-message PROPOSE/ACK/ACK/CONFIRMED BRC quartet for *slice_id*.""" + return _make_brc_messages(pipeline_id="issue-42", phase="implement", slice_id=slice_id) + + def test_writes_one_file_per_slice_no_aggregate(self, tmp_path): + """Two slices' worth of implement BRC messages produce two per-slice + files and no aggregate ``42-implement.{md,json}``.""" + from routes.pipelines import _write_brc_history + + messages = [] + # Distinct timestamps so the two buckets render in stable order. + for i, sid in enumerate(["slice-1", "slice-2"]): + for j, m in enumerate(self._make_implement_msgs(sid)): + # Disambiguate timestamps so renderer ordering is stable. + m.timestamp = datetime(2026, 4, 8, 12, i * 30 + j, 0, tzinfo=UTC) + messages.append(m) + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + slice1_md = history_dir / "42-implement-slice-1.md" + slice2_md = history_dir / "42-implement-slice-2.md" + slice1_json = history_dir / "42-implement-slice-1.json" + slice2_json = history_dir / "42-implement-slice-2.json" + + # Per-slice files exist, both .md and .json. + assert slice1_md.exists(), "slice-1 markdown file missing" + assert slice2_md.exists(), "slice-2 markdown file missing" + assert slice1_json.exists(), "slice-1 JSON companion missing" + assert slice2_json.exists(), "slice-2 JSON companion missing" + + # Aggregate file MUST NOT exist (hard switchover, D4). + assert not (history_dir / "42-implement.md").exists(), ( + "Aggregate 42-implement.md leaked through hard switchover" + ) + assert not (history_dir / "42-implement.json").exists(), ( + "Aggregate 42-implement.json leaked through hard switchover" + ) + + # And there should be exactly the expected per-slice files plus the + # JSON companions — no other implement-phase artifacts. + produced = sorted(p.name for p in history_dir.glob("42-implement*")) + assert produced == [ + "42-implement-slice-1.json", + "42-implement-slice-1.md", + "42-implement-slice-2.json", + "42-implement-slice-2.md", + ], f"Unexpected files: {produced}" + + def test_each_slice_file_contains_only_its_own_messages(self, tmp_path): + """The slice-1 file must NOT contain any slice-2 messages and vice + versa — partitioning must isolate the buckets.""" + from routes.pipelines import _write_brc_history + + messages = [] + for sid, marker in [("slice-1", "alpha-marker"), ("slice-2", "beta-marker")]: + for m in self._make_implement_msgs(sid): + if m.message_type == MessageType.CONSENSUS_PROPOSE: + m.body = f"{marker} body" + messages.append(m) + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + slice1_md = (history_dir / "42-implement-slice-1.md").read_text() + slice2_md = (history_dir / "42-implement-slice-2.md").read_text() + + # Each marker lands ONLY in its own slice's file. + assert "alpha-marker" in slice1_md + assert "alpha-marker" not in slice2_md, "alpha-marker leaked into slice-2" + assert "beta-marker" in slice2_md + assert "beta-marker" not in slice1_md, "beta-marker leaked into slice-1" + + # And the JSON companions match the same partitioning. + slice1_json = json.loads((history_dir / "42-implement-slice-1.json").read_text()) + slice2_json = json.loads((history_dir / "42-implement-slice-2.json").read_text()) + assert all(entry["metadata"].get("slice_id") == "slice-1" for entry in slice1_json) + assert all(entry["metadata"].get("slice_id") == "slice-2" for entry in slice2_json) + + def test_single_slice_still_uses_per_slice_filename(self, tmp_path): + """Even a single-slice pipeline writes ``-implement-slice-1.{md,json}`` + — there is no fallback to the aggregate filename when only one slice + exists. (Hard switchover — no special-case for N=1.)""" + from routes.pipelines import _write_brc_history + + messages = self._make_implement_msgs("slice-1") + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + assert (history_dir / "42-implement-slice-1.md").exists() + assert (history_dir / "42-implement-slice-1.json").exists() + assert not (history_dir / "42-implement.md").exists() + assert not (history_dir / "42-implement.json").exists() + + def test_per_slice_file_carries_slice_label_in_header(self, tmp_path): + """Each per-slice file's ``# BRC Consensus History`` header includes + the slice_id so a reviewer scanning the markdown knows which slice + the consensus belongs to.""" + from routes.pipelines import _write_brc_history + + messages = self._make_implement_msgs("slice-7") + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + content = (tmp_path / ".egg-state" / "brc-history" / "42-implement-slice-7.md").read_text() + assert "# BRC Consensus History" in content + assert "slice-7" in content, "Slice label missing from per-slice file header" + assert "implement" in content.lower() + + def test_messages_without_slice_id_dropped_with_warning(self, tmp_path): + """Implement-phase BRC messages that lack ``metadata['slice_id']`` + are silently dropped from the on-disk history (hard switchover) and + a single aggregate WARNING is emitted naming the dropped count.""" + from routes.pipelines import _write_brc_history + + # Mix attributed and unattributed messages. + attributed = self._make_implement_msgs("slice-1") + unattributed = [ + _make_brc_message( + pipeline_id="issue-42", + from_role="coder", + message_type=MessageType.CONSENSUS_PROPOSE, + subject="Stray PROPOSE", + body="missing slice_id", + phase="implement", + slice_id=None, # <-- intentionally omit metadata.slice_id + ), + _make_brc_message( + pipeline_id="issue-42", + from_role="reviewer_code", + message_type=MessageType.CONSENSUS_NACK, + subject="Stray NACK", + body="missing slice_id", + phase="implement", + slice_id=None, + ), + ] + for m in unattributed: + assert "slice_id" not in m.metadata, "fixture leak — slice_id was stamped" + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = attributed + unattributed + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines.logger") as mock_logger, + ): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + # The slice-1 file exists and contains ONLY the attributed PROPOSE + # body; the unattributed Stray PROPOSE/NACK must NOT be present. + slice1_md = tmp_path / ".egg-state" / "brc-history" / "42-implement-slice-1.md" + assert slice1_md.exists() + content = slice1_md.read_text() + assert "missing slice_id" not in content, "Unattributed message leaked into slice-1 file" + assert "Stray PROPOSE" not in content + assert "Stray NACK" not in content + + # An aggregate file must STILL not exist, even though there were + # unattributed messages — the writer never falls back. + assert not (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").exists() + assert not (tmp_path / ".egg-state" / "brc-history" / "42-implement.json").exists() + + # A single warning was emitted with the dropped count. + warning_calls = [ + c + for c in mock_logger.warning.call_args_list + if "slice_id" in str(c) or "without metadata" in str(c) + ] + assert len(warning_calls) >= 1, ( + f"Expected a warning about dropped messages, got: {mock_logger.warning.call_args_list}" + ) + # The warning surfaces the dropped count so an operator knows scale. + kwargs = warning_calls[0][1] + assert kwargs.get("dropped_count") == 2 + + def test_all_messages_unattributed_writes_aggregate_babysit_fallback(self, tmp_path): + """When EVERY implement-phase BRC message lacks ``slice_id``, the + writer falls back to the aggregate ``{identifier}-implement.{md,json}`` + filename so non-slice pipelines (babysit_pr) keep producing the + artifact documented in ``skills/babysit-pr/SKILL.md``. + + Surfaced as v2-NACK reviewer_code_holistic finding #2 (#2548): v2 + dropped the entire BRC stream for babysit_pr; v3 falls back to + aggregate when no message carries a canonical slice_id. + """ + from routes.pipelines import _write_brc_history + + unattributed = [ + _make_brc_message( + pipeline_id="issue-42", + from_role="coder", + message_type=MessageType.CONSENSUS_PROPOSE, + subject=f"Stray {i}", + body="no slice_id", + phase="implement", + slice_id=None, + ) + for i in range(3) + ] + for m in unattributed: + assert "slice_id" not in m.metadata + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = unattributed + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + # Babysit fallback: aggregate IS produced when no slice_id anywhere. + assert (history_dir / "42-implement.md").exists(), ( + "Babysit fallback: aggregate file MUST be written when no message " + "carries slice_id (preserves documented babysit_pr artifact)" + ) + assert (history_dir / "42-implement.json").exists() + # And NO per-slice files were produced. + per_slice = list(history_dir.glob("42-implement-*.md")) + assert per_slice == [], f"Babysit fallback must NOT write per-slice files, got: {per_slice}" + + def test_babysit_aggregate_fallback_contains_all_messages(self, tmp_path): + """The babysit aggregate fallback contains every (BRC-eligible) + implement-phase message — no message is silently dropped just + because no message in the bucket happened to carry slice_id.""" + from routes.pipelines import _write_brc_history + + unattributed = [ + _make_brc_message( + pipeline_id="issue-42", + from_role="coder", + message_type=MessageType.CONSENSUS_PROPOSE, + subject="Babysit-PROPOSE", + body="alpha", + phase="implement", + slice_id=None, + ), + _make_brc_message( + pipeline_id="issue-42", + from_role="reviewer_code", + message_type=MessageType.CONSENSUS_ACK, + subject="Babysit-ACK", + body="beta", + phase="implement", + slice_id=None, + ), + ] + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = unattributed + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + content = (tmp_path / ".egg-state" / "brc-history" / "42-implement.md").read_text() + assert "alpha" in content, "Babysit aggregate dropped the PROPOSE body" + assert "beta" in content, "Babysit aggregate dropped the ACK body" + assert "Babysit-PROPOSE" in content + assert "Babysit-ACK" in content + + def test_refine_phase_keeps_aggregate_filename(self, tmp_path): + """Regression: refine phase still writes the aggregate + ``42-refine.{md,json}`` and does NOT write a per-slice file even when + messages happen to carry ``metadata['slice_id']``.""" + from routes.pipelines import _write_brc_history + + # Refine messages with a slice_id leftover (defensive — should be + # ignored for non-implement phases). + messages = _make_brc_messages(pipeline_id="issue-42", phase="refine", slice_id="slice-1") + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "refine", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + assert (history_dir / "42-refine.md").exists(), "Refine aggregate .md missing" + assert (history_dir / "42-refine.json").exists(), "Refine aggregate .json missing" + # No per-slice refine file should exist. + assert not (history_dir / "42-refine-slice-1.md").exists(), ( + "Refine phase must not partition by slice" + ) + assert not (history_dir / "42-refine-slice-1.json").exists(), ( + "Refine phase must not partition by slice" + ) + + def test_plan_phase_keeps_aggregate_filename(self, tmp_path): + """Regression: plan phase still writes the aggregate + ``42-plan.{md,json}`` (only implement is per-slice — D4).""" + from routes.pipelines import _write_brc_history + + messages = _make_brc_messages(pipeline_id="issue-42", phase="plan", slice_id="slice-1") + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "plan", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + assert (history_dir / "42-plan.md").exists() + assert (history_dir / "42-plan.json").exists() + assert not (history_dir / "42-plan-slice-1.md").exists() + assert not (history_dir / "42-plan-slice-1.json").exists() + + def test_pr_phase_keeps_aggregate_filename(self, tmp_path): + """Regression: pr phase still writes the aggregate + ``42-pr.{md,json}``. The contract carved out implement only.""" + from routes.pipelines import _write_brc_history + + messages = _make_brc_messages(pipeline_id="issue-42", phase="pr", slice_id="slice-1") + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "pr", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + assert (history_dir / "42-pr.md").exists() + assert (history_dir / "42-pr.json").exists() + assert not (history_dir / "42-pr-slice-1.md").exists() + assert not (history_dir / "42-pr-slice-1.json").exists() + + def test_partial_attribution_only_attributed_messages_get_files(self, tmp_path): + """Mix of slice-1, slice-2, and unattributed messages: per-slice + files exist for slice-1 and slice-2, no aggregate, unattributed are + dropped with a single warning.""" + from routes.pipelines import _write_brc_history + + slice1_msgs = self._make_implement_msgs("slice-1") + slice2_msgs = self._make_implement_msgs("slice-2") + # One unattributed message. + stray = _make_brc_message( + pipeline_id="issue-42", + phase="implement", + from_role="coder", + message_type=MessageType.CONSENSUS_PROPOSE, + subject="Stray", + body="no slice_id", + slice_id=None, + ) + assert "slice_id" not in stray.metadata + all_messages = slice1_msgs + slice2_msgs + [stray] + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = all_messages + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines.logger") as mock_logger, + ): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + produced = sorted(p.name for p in history_dir.glob("42-implement*")) + assert produced == [ + "42-implement-slice-1.json", + "42-implement-slice-1.md", + "42-implement-slice-2.json", + "42-implement-slice-2.md", + ], f"Unexpected files: {produced}" + + # The dropped count must equal exactly 1 — the writer must not + # double-count or miscount. + warning_calls = [ + c + for c in mock_logger.warning.call_args_list + if "slice_id" in str(c) or "without metadata" in str(c) + ] + assert any(c[1].get("dropped_count") == 1 for c in warning_calls), ( + f"Expected dropped_count=1, got warnings: {warning_calls}" + ) + + def test_non_consensus_unattributed_routed_to_unattributed_sibling_file(self, tmp_path): + """Non-CONSENSUS BRC types (HEARTBEAT, OVERSEER_ALERT, AGENT_FAILED, + STATUS, NUDGE, HANDOFF) without ``metadata['slice_id']`` are routed + to ``{identifier}-implement-unattributed.{md,json}`` rather than + dropped. Their emitters do not uniformly carry slice scope (overseer + respawn, HealthMonitor escalation, CLI message-send), and dropping + them would silently strip cross-cutting context from per-slice + transcripts. See #2548 reviewer_code blocking finding.""" + from routes.pipelines import _write_brc_history + + # One canonical CONSENSUS_PROPOSE so partition mode engages. + attributed = self._make_implement_msgs("slice-1") + # An OVERSEER_ALERT and a HEARTBEAT, both without slice_id — these + # should land in the unattributed sibling, not be dropped. + unattributed_other = [ + _make_brc_message( + pipeline_id="issue-42", + from_role="orchestrator", + message_type=MessageType.OVERSEER_ALERT, + subject="brc_confirmation_timeout — call mcp__brc__confirm", + body="orchestrator nudge with no explicit slice scope", + phase="implement", + slice_id=None, + ), + _make_brc_message( + pipeline_id="issue-42", + from_role="coder", + message_type=MessageType.HEARTBEAT, + subject="heartbeat: WORKING", + body="", + phase="implement", + slice_id=None, + ), + ] + for m in unattributed_other: + assert "slice_id" not in m.metadata, "fixture leak — slice_id stamped" + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = attributed + unattributed_other + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines.logger") as mock_logger, + ): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + # Per-slice file exists for the canonical message. + assert (history_dir / "42-implement-slice-1.md").exists() + assert (history_dir / "42-implement-slice-1.json").exists() + # Unattributed sibling file exists and contains the OVERSEER_ALERT + # and HEARTBEAT. + unattributed_md = history_dir / "42-implement-unattributed.md" + unattributed_json = history_dir / "42-implement-unattributed.json" + assert unattributed_md.exists(), ( + "Non-CONSENSUS BRC messages without slice_id must land in the " + "unattributed sibling, not be dropped" + ) + assert unattributed_json.exists() + content = unattributed_md.read_text() + assert "brc_confirmation_timeout" in content + assert "OVERSEER_ALERT" in content + assert "HEARTBEAT" in content + # No aggregate file (partition mode is engaged). + assert not (history_dir / "42-implement.md").exists() + # No CONSENSUS_* drop warning (none were dropped). + warning_calls = [ + c + for c in mock_logger.warning.call_args_list + if "without canonical metadata.slice_id" in str(c) + ] + assert warning_calls == [], ( + f"Did not expect drop warnings for non-CONSENSUS unattributed; got: {warning_calls}" + ) + + def test_consensus_dropped_non_consensus_routed_when_mixed(self, tmp_path): + """When unattributed messages include BOTH CONSENSUS_* and + non-CONSENSUS_* types, the writer must split the bucket: CONSENSUS_* + are dropped with a warning (D4 contract violation), non-CONSENSUS_* + are routed to the unattributed sibling so the audit trail stays + complete.""" + from routes.pipelines import _write_brc_history + + attributed = self._make_implement_msgs("slice-1") + stray_consensus = _make_brc_message( + pipeline_id="issue-42", + from_role="coder", + message_type=MessageType.CONSENSUS_PROPOSE, + subject="Stray PROPOSE", + body="contract violation — must be dropped", + phase="implement", + slice_id=None, + ) + stray_alert = _make_brc_message( + pipeline_id="issue-42", + from_role="orchestrator", + message_type=MessageType.OVERSEER_ALERT, + subject="overseer_restart", + body="cross-cutting alert — must be routed to unattributed", + phase="implement", + slice_id=None, + ) + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = attributed + [stray_consensus, stray_alert] + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines.logger") as mock_logger, + ): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + # CONSENSUS_* drop produced a warning with count=1 (only the stray + # PROPOSE, not the OVERSEER_ALERT). + warning_calls = [ + c + for c in mock_logger.warning.call_args_list + if "CONSENSUS_*" in str(c) or "without canonical metadata.slice_id" in str(c) + ] + assert any(c[1].get("dropped_count") == 1 for c in warning_calls), ( + f"Expected CONSENSUS_* drop warning with count=1; got {warning_calls}" + ) + # The OVERSEER_ALERT landed in the unattributed sibling. + unattributed_md = (history_dir / "42-implement-unattributed.md").read_text() + assert "overseer_restart" in unattributed_md + assert "OVERSEER_ALERT" in unattributed_md + # The stray CONSENSUS_PROPOSE did NOT land anywhere. + for produced in history_dir.glob("42-implement*.md"): + content = produced.read_text() + assert "Stray PROPOSE" not in content, ( + f"CONSENSUS_* drop must not leak into {produced.name}" + ) + + def test_implement_messages_with_empty_slice_id_treated_as_unattributed(self, tmp_path): + """Empty-string slice_id fails ``SLICE_ID_PATTERN`` validation and is + treated as unattributed. + + Critically, the writer must NEVER produce a file named + ``42-implement-.md`` (i.e. interpolating the empty string into the + per-slice stem) — that would be both ugly on disk and a path-shape + injection vector. + + When mixed with at least one canonical-attributed message, the + empty-slice_id messages are dropped with a warning. When all + messages have empty slice_id, the babysit aggregate fallback + engages (separate test). + """ + from routes.pipelines import _write_brc_history + + # Mix an empty-slice_id message with a canonical one so partition + # mode engages (otherwise we'd get the aggregate fallback path). + canonical = _make_brc_message( + pipeline_id="issue-42", + phase="implement", + from_role="coder", + message_type=MessageType.CONSENSUS_PROPOSE, + subject="Canonical", + body="ok", + slice_id="slice-1", + ) + empty = _make_brc_message( + pipeline_id="issue-42", + phase="implement", + from_role="reviewer_code", + message_type=MessageType.CONSENSUS_NACK, + subject="Empty slice_id", + body="should be dropped", + metadata={"slice_id": ""}, + slice_id=None, + ) + # Sanity: the fixture really has empty string slice_id. + assert empty.metadata.get("slice_id") == "" + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = [canonical, empty] + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines.logger") as mock_logger, + ): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + # The dangerous filename MUST NOT be produced. + assert not (history_dir / "42-implement-.md").exists(), ( + "Empty slice_id must NOT be interpolated into a per-slice stem" + ) + # The canonical slice-1 file IS produced. + assert (history_dir / "42-implement-slice-1.md").exists() + # Aggregate is NOT produced (because partition mode engaged). + assert not (history_dir / "42-implement.md").exists() + # Drop warning was emitted with count=1. + warning_calls = [ + c + for c in mock_logger.warning.call_args_list + if "slice_id" in str(c) or "without canonical" in str(c) + ] + assert len(warning_calls) >= 1, "Expected drop warning for empty slice_id" + assert any(c[1].get("dropped_count") == 1 for c in warning_calls) + + def test_three_slices_all_get_distinct_files(self, tmp_path): + """N=3 slices produces 3 distinct per-slice .md/.json pairs in + deterministic order — exercises the bucket sort path.""" + from routes.pipelines import _write_brc_history + + messages = [] + for sid in ["slice-3", "slice-1", "slice-2"]: # deliberately unordered + messages.extend(self._make_implement_msgs(sid)) + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + for sid in ["slice-1", "slice-2", "slice-3"]: + assert (history_dir / f"42-implement-{sid}.md").exists(), ( + f"Per-slice file for {sid} missing" + ) + assert (history_dir / f"42-implement-{sid}.json").exists() + # Aggregate must not exist. + assert not (history_dir / "42-implement.md").exists() + assert not (history_dir / "42-implement.json").exists() + + def test_idempotent_per_slice_write(self, tmp_path): + """Running the writer twice with the same input produces + byte-identical per-slice files AND a byte-identical + ``unattributed`` sibling file (idempotency invariant from #1714 + carried over into per-slice mode + the cross-cutting sibling + added in the per-slice partition fix).""" + from routes.pipelines import _write_brc_history + + messages = [] + for sid in ["slice-1", "slice-2"]: + messages.extend(self._make_implement_msgs(sid)) + # Mix in non-CONSENSUS BRC types without slice_id so the writer + # produces the unattributed sibling alongside the per-slice + # files. The sibling is committed to the branch and read by + # reviewers, so it is on the same idempotency contract. + messages.append( + _make_brc_message( + pipeline_id="issue-42", + from_role="overseer", + message_type=MessageType.OVERSEER_ALERT, + subject="brc_confirmation_timeout", + body="elapsed", + phase="implement", + timestamp=datetime(2026, 4, 8, 12, 30, 0, tzinfo=UTC), + slice_id=None, + ) + ) + messages.append( + _make_brc_message( + pipeline_id="issue-42", + from_role="coder", + message_type=MessageType.HEARTBEAT, + subject="alive", + body="hb", + phase="implement", + timestamp=datetime(2026, 4, 8, 12, 31, 0, tzinfo=UTC), + slice_id=None, + ) + ) + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + history_dir = tmp_path / ".egg-state" / "brc-history" + + with patch("message_store.get_message_store", return_value=mock_store): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + first_s1_md = (history_dir / "42-implement-slice-1.md").read_text() + first_s2_md = (history_dir / "42-implement-slice-2.md").read_text() + first_s1_json = (history_dir / "42-implement-slice-1.json").read_text() + first_s2_json = (history_dir / "42-implement-slice-2.json").read_text() + first_unattr_md = (history_dir / "42-implement-unattributed.md").read_text() + first_unattr_json = (history_dir / "42-implement-unattributed.json").read_text() + + # Second call (e.g. PR-phase safety-net rewrite). + _write_brc_history(tmp_path, "issue-42", "implement", 42) + second_s1_md = (history_dir / "42-implement-slice-1.md").read_text() + second_s2_md = (history_dir / "42-implement-slice-2.md").read_text() + second_s1_json = (history_dir / "42-implement-slice-1.json").read_text() + second_s2_json = (history_dir / "42-implement-slice-2.json").read_text() + second_unattr_md = (history_dir / "42-implement-unattributed.md").read_text() + second_unattr_json = (history_dir / "42-implement-unattributed.json").read_text() + + assert first_s1_md == second_s1_md, "slice-1 markdown not idempotent" + assert first_s2_md == second_s2_md, "slice-2 markdown not idempotent" + assert first_s1_json == second_s1_json, "slice-1 JSON not idempotent" + assert first_s2_json == second_s2_json, "slice-2 JSON not idempotent" + assert first_unattr_md == second_unattr_md, "unattributed sibling markdown not idempotent" + assert first_unattr_json == second_unattr_json, "unattributed sibling JSON not idempotent" + + def test_message_metadata_is_always_a_dict(self): + """Pydantic invariant: ``Message.metadata`` is a dict[str, Any] field + with ``default_factory=dict`` — the writer relies on this to skip + defensive None/non-dict guards (#2548 reviewer_code non-blocking). + Pin the invariant so a future Pydantic-config change (e.g. allowing + None) shows up here rather than as a runtime crash in the writer. + """ + # Default construction yields an empty dict, never None. + m = Message( + pipeline_id="issue-42", + from_role="coder", + to_role="all", + message_type=MessageType.CONSENSUS_PROPOSE, + subject="x", + body="y", + phase="implement", + ) + assert isinstance(m.metadata, dict), ( + f"Pydantic default for Message.metadata must be a dict, got {type(m.metadata)}" + ) + assert m.metadata == {} + # Explicit dict is preserved. + m2 = Message( + pipeline_id="issue-42", + from_role="coder", + to_role="all", + message_type=MessageType.CONSENSUS_PROPOSE, + subject="x", + body="y", + phase="implement", + metadata={"slice_id": "slice-1"}, + ) + assert isinstance(m2.metadata, dict) + assert m2.metadata.get("slice_id") == "slice-1" + + def test_invalid_slice_id_pattern_treated_as_unattributed(self, tmp_path): + """slice_id values that don't match SLICE_ID_PATTERN (``^slice-[0-9]+$``) + are treated as unattributed — preventing path-traversal / + filename-injection through metadata. + + This is a defense-in-depth test: SLICE_ID_PATTERN is enforced at + every gateway-facing seam upstream, but the writer also validates + locally so a future leak cannot smuggle ``../etc/passwd`` (or any + non-canonical value) into ``42-implement-.md``. + """ + from routes.pipelines import _write_brc_history + + # A canonical slice-1 message so the writer engages partition mode + # (otherwise it would fall back to the babysit aggregate). + canonical = _make_brc_message( + pipeline_id="issue-42", + phase="implement", + from_role="coder", + message_type=MessageType.CONSENSUS_PROPOSE, + subject="canonical", + body="ok", + slice_id="slice-1", + ) + # Various malformed slice_id values that MUST NOT produce a file. + injection_payloads = [ + "../etc/passwd", # path traversal + "slice-1/extra", # directory separator + "slice-1.bad", # extra suffix + "slice-", # missing digits + "phase-1", # legacy non-canonical + "SLICE-1", # case mismatch + "slice- 1", # whitespace + "slice-01a", # non-digits + "slice-1\nx", # newline injection + ] + bad_msgs = [ + _make_brc_message( + pipeline_id="issue-42", + phase="implement", + from_role="coder", + message_type=MessageType.CONSENSUS_NACK, + subject=f"bad-{i}", + body=f"injection {payload!r}", + metadata={"slice_id": payload}, + slice_id=None, # let metadata stand + ) + for i, payload in enumerate(injection_payloads) + ] + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = [canonical, *bad_msgs] + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines.logger") as mock_logger, + ): + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + # Only the canonical slice-1 file exists. + produced = sorted(p.name for p in history_dir.glob("42-implement*")) + assert produced == [ + "42-implement-slice-1.json", + "42-implement-slice-1.md", + ], f"Malformed slice_id payloads leaked into output files: {produced}" + + # The aggregate file MUST NOT exist (we have at least one canonical). + assert not (history_dir / "42-implement.md").exists() + # Path-traversal: must not have written anywhere outside brc-history. + # Sanity: the brc-history dir is the ONLY dir under .egg-state for + # this test (write would have escaped if traversal succeeded). + sibling_dirs = sorted(p.name for p in (tmp_path / ".egg-state").iterdir() if p.is_dir()) + assert sibling_dirs == ["brc-history"], ( + f"Unexpected directories created: {sibling_dirs} — possible traversal" + ) + + # Drop warning was emitted with the right shape. + warning_calls = [ + c + for c in mock_logger.warning.call_args_list + if "slice_id" in str(c) or "without canonical" in str(c) + ] + assert len(warning_calls) >= 1, ( + f"Expected drop warning for malformed slice_ids, got: {mock_logger.warning.call_args_list}" + ) + kwargs = warning_calls[0][1] + assert kwargs.get("dropped_count") == len(injection_payloads), ( + f"Expected dropped_count={len(injection_payloads)}, got: {kwargs}" + ) + + def test_natural_sort_per_slice_iteration_order(self, tmp_path): + """Per-slice buckets iterate in natural-sort (integer-suffix) order + so a 12-slice pipeline writes ``slice-1, slice-2, … slice-12`` and + not the lexicographic ``slice-1, slice-10, slice-11, slice-12, + slice-2, …`` (#2548 reviewer_code non-blocking). + + The current writer doesn't expose iteration order externally beyond + the order of ``logger.info`` "Wrote BRC history file" calls, so we + intercept those to assert the expected sequence. + """ + from routes.pipelines import _write_brc_history + + # 12 slices in deliberately shuffled input order. + sids = ["slice-7", "slice-1", "slice-12", "slice-2", "slice-11"] + messages = [] + for sid in sids: + messages.extend(self._make_implement_msgs(sid)) + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + wrote_calls: list[dict] = [] + + def capture(*args, **kwargs): + if args and args[0] == "Wrote BRC history file": + wrote_calls.append(kwargs) + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines.logger") as mock_logger, + ): + mock_logger.info.side_effect = capture + _write_brc_history(tmp_path, "issue-42", "implement", 42) + + # Order of slice_ids in the "Wrote BRC history file" log entries + # should be sorted by integer suffix. + slice_ids_in_order = [c["slice_id"] for c in wrote_calls] + assert slice_ids_in_order == [ + "slice-1", + "slice-2", + "slice-7", + "slice-11", + "slice-12", + ], f"Expected natural-sort iteration order, got {slice_ids_in_order}" + + +class TestPerSliceImplementBrcHistoryRewriteForPr: + """The PR-phase safety-net rewrite (``_rewrite_brc_history_for_pr``) + inherits the per-slice partitioning from ``_write_brc_history`` (#2548). + These tests verify the rewrite path treats per-slice files correctly + and never produces an aggregate. + """ + + def test_rewrite_for_pr_emits_per_slice_implement_files(self, tmp_path): + """When the PR phase rewrites BRC history, the implement-phase + rewrite produces per-slice files and no aggregate file.""" + from routes.pipelines import _rewrite_brc_history_for_pr + + messages = _make_brc_messages(pipeline_id="issue-42", phase="implement", slice_id="slice-1") + messages.extend( + _make_brc_messages(pipeline_id="issue-42", phase="implement", slice_id="slice-2") + ) + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = messages + + phases = { + "implement": MagicMock(status=PipelineStatus.COMPLETE), + } + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines._commit_statefiles_to_worktree"), + ): + _rewrite_brc_history_for_pr(tmp_path, "issue-42", phases, 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + assert (history_dir / "42-implement-slice-1.md").exists() + assert (history_dir / "42-implement-slice-2.md").exists() + assert not (history_dir / "42-implement.md").exists() + assert not (history_dir / "42-implement.json").exists() + + def test_rewrite_for_pr_mixes_aggregate_refine_and_per_slice_implement(self, tmp_path): + """Mixed multi-phase rewrite: refine emits aggregate, implement + emits per-slice — both shapes coexist in the same brc-history dir.""" + from routes.pipelines import _rewrite_brc_history_for_pr + + all_messages = [] + all_messages.extend(_make_brc_messages(pipeline_id="issue-42", phase="refine")) + all_messages.extend( + _make_brc_messages(pipeline_id="issue-42", phase="implement", slice_id="slice-1") + ) + + mock_store = MagicMock(spec=MessageStore) + mock_store.get_messages.return_value = all_messages + + phases = { + "refine": MagicMock(status=PipelineStatus.COMPLETE), + "implement": MagicMock(status=PipelineStatus.COMPLETE), + } + + with ( + patch("message_store.get_message_store", return_value=mock_store), + patch("routes.pipelines._commit_statefiles_to_worktree"), + ): + _rewrite_brc_history_for_pr(tmp_path, "issue-42", phases, 42) + + history_dir = tmp_path / ".egg-state" / "brc-history" + # Refine: aggregate. Implement: per-slice. + assert (history_dir / "42-refine.md").exists() + assert (history_dir / "42-refine.json").exists() + assert (history_dir / "42-implement-slice-1.md").exists() + assert (history_dir / "42-implement-slice-1.json").exists() + # No aggregate implement file. + assert not (history_dir / "42-implement.md").exists() + assert not (history_dir / "42-implement.json").exists() + # Refine never partitions by slice. + assert not (history_dir / "42-refine-slice-1.md").exists() diff --git a/orchestrator/tests/test_brc_phase_propagation.py b/orchestrator/tests/test_brc_phase_propagation.py index b922caae19..f2d3fd0005 100644 --- a/orchestrator/tests/test_brc_phase_propagation.py +++ b/orchestrator/tests/test_brc_phase_propagation.py @@ -48,6 +48,13 @@ def _make_pipeline( ) +# Default slice_id seeded onto implement-phase BRC messages so the +# post-#2548 hard-switchover writer accepts them. Tests that need an +# unattributed (missing-slice_id) message must set ``slice_id=None`` +# AND avoid passing ``metadata={"slice_id": ...}``. +_DEFAULT_IMPLEMENT_SLICE_ID = "slice-1" + + def _make_brc_message( pipeline_id="issue-42", from_role="coder", @@ -57,8 +64,20 @@ def _make_brc_message( phase=None, timestamp=None, metadata=None, + slice_id="__default__", ): - """Create a BRC Message for testing. Defaults to phase=None to mimic pre-fix.""" + """Create a BRC Message for testing. Defaults to phase=None to mimic pre-fix. + + For implement-phase messages, ``metadata['slice_id']`` is auto-stamped + to ``slice-1`` (#2548 hard switchover) unless the caller passes an + explicit ``slice_id`` or sets the key in ``metadata`` directly. + """ + md = dict(metadata or {}) + if slice_id == "__default__": + if phase == "implement" and "slice_id" not in md: + md["slice_id"] = _DEFAULT_IMPLEMENT_SLICE_ID + elif slice_id is not None: + md.setdefault("slice_id", slice_id) return Message( pipeline_id=pipeline_id, from_role=from_role, @@ -68,7 +87,7 @@ def _make_brc_message( body=body, phase=phase, timestamp=timestamp or datetime(2026, 4, 8, 12, 0, 0, tzinfo=UTC), - metadata=metadata or {}, + metadata=md, ) @@ -715,7 +734,13 @@ def test_includes_messages_with_correct_phase(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - expected_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + # #2548: implement is per-slice — the aggregate file is gone. + expected_path = ( + tmp_path + / ".egg-state" + / "brc-history" + / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + ) assert expected_path.exists() content = expected_path.read_text() assert "New message" in content diff --git a/orchestrator/tests/test_conditional_ack.py b/orchestrator/tests/test_conditional_ack.py index 061e1cfcc9..7b8c109159 100644 --- a/orchestrator/tests/test_conditional_ack.py +++ b/orchestrator/tests/test_conditional_ack.py @@ -776,6 +776,9 @@ def _conditional_ack_message(self): "pre_merge_condition": "git mv legacy/x new/x before merge", }, "version": 1, + # #2548 hard switchover: implement-phase BRC messages must + # carry a slice_id, otherwise the writer drops them. + "slice_id": "slice-1", }, ) @@ -788,7 +791,7 @@ def test_condition_appears_in_markdown_transcript(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - md_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + md_path = tmp_path / ".egg-state" / "brc-history" / "42-implement-slice-1.md" assert md_path.exists() content = md_path.read_text() assert "pre_merge_condition" in content @@ -803,7 +806,7 @@ def test_condition_appears_in_json_companion(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - json_path = tmp_path / ".egg-state" / "brc-history" / "42-implement.json" + json_path = tmp_path / ".egg-state" / "brc-history" / "42-implement-slice-1.json" assert json_path.exists() data = json.loads(json_path.read_text()) assert len(data) == 1 diff --git a/orchestrator/tests/test_create_slice_integration_branch.py b/orchestrator/tests/test_create_slice_integration_branch.py index 9aa7ab0308..7f5eef2dbb 100644 --- a/orchestrator/tests/test_create_slice_integration_branch.py +++ b/orchestrator/tests/test_create_slice_integration_branch.py @@ -726,6 +726,243 @@ def fake_make_request(endpoint, method=None, data=None, **kwargs): assert fetch_calls[0][0] == ("+refs/heads/egg/issue-1:refs/remotes/origin/egg/issue-1") +class TestIsSliceBranchMergedIntoParent: + """#2549 — detect whether a slice's PR has already merged into its + parent. This is the inverse of the #2512 restart-recovery check: + when ``existing_sha`` (slice tip on origin) is reachable from + ``parent_sha`` (parent tip on origin), the slice's commits are + already in the parent and any attempt to (re)create the slice's + integration branch via ``parent_sha:refs/heads/`` would be + rejected as non-fast-forward. + + The bootstrap reconciliation pass and the run-loop race-protection + check in ``routes/pipelines._run_implement_phase_slices`` both rely + on this signal — a False from here lets the slice run normally; a + True short-circuits the slice to COMPLETE. + """ + + def _setup_remotes(self, parent_sha: str | None, existing_sha: str | None): + def fake_get_remote_branch_sha(pipeline_id, repo_path, ref, **kwargs): + if ref.endswith("/slice-1"): + return existing_sha + return parent_sha + + return fake_get_remote_branch_sha + + def test_returns_true_when_slice_tip_is_ancestor_of_parent(self, gateway_client): + """The literal #2549 repro: slice-1 PR was merged into the + work branch; the slice-1 ref still exists on origin at its + pre-merge tip, and the work tip now has the merge commit on + top. ``existing_sha`` is reachable from ``parent_sha`` → + merged → True.""" + parent_sha = "f3c16e3b" * 5 # work tip after merge + existing_sha = "ea591ec1" * 5 # pre-merge slice-1 tip + + merge_base_calls: list[dict] = [] + + def fake_make_request(endpoint, method=None, data=None, **kwargs): + if endpoint == "/api/v1/git/execute": + merge_base_calls.append(dict(data or {})) + # existing IS reachable from parent → returncode 0 → True + return {"success": True, "data": {"returncode": 0}} + return {"success": True, "data": {}} + + with ( + patch.object(gateway_client, "register_session", return_value=_session_info()), + patch.object(gateway_client, "delete_session", return_value=True), + patch.object(gateway_client, "fetch_branch", return_value=True), + patch.object( + gateway_client, + "get_remote_branch_sha", + side_effect=self._setup_remotes(parent_sha, existing_sha), + ), + patch.object(gateway_client, "_make_request", side_effect=fake_make_request), + ): + merged = gateway_client.is_slice_branch_merged_into_parent( + "issue-2474-v2", + "/repo", + integration_branch="egg/issue-2474-v2/slice-1", + parent_branch="egg/issue-2474-v2/work", + ) + + assert merged is True + assert len(merge_base_calls) == 1 + mb = merge_base_calls[0] + assert mb["args"] == ["--is-ancestor", existing_sha, parent_sha], ( + "ancestry direction is the inverse of #2512: existing must be " + "ancestor of parent, signalling 'slice merged into parent'" + ) + + def test_returns_false_when_slice_tip_diverged_from_parent(self, gateway_client): + """Genuinely diverged history (slice has commits parent doesn't, + or vice versa) → not merged. Caller falls through to the regular + create path so origin's rejection (if any) surfaces normally.""" + parent_sha = "deadbeef" * 5 + existing_sha = "feedface" * 5 + + def fake_make_request(endpoint, method=None, data=None, **kwargs): + if endpoint == "/api/v1/git/execute": + # not-ancestor → returncode 1 + raise GatewayError( + "git merge-base failed", + status_code=500, + details={"returncode": 1, "stdout": "", "stderr": ""}, + ) + return {"success": True, "data": {}} + + with ( + patch.object(gateway_client, "register_session", return_value=_session_info()), + patch.object(gateway_client, "delete_session", return_value=True), + patch.object(gateway_client, "fetch_branch", return_value=True), + patch.object( + gateway_client, + "get_remote_branch_sha", + side_effect=self._setup_remotes(parent_sha, existing_sha), + ), + patch.object(gateway_client, "_make_request", side_effect=fake_make_request), + ): + merged = gateway_client.is_slice_branch_merged_into_parent( + "p", + "/repo", + integration_branch="egg/issue-1/slice-1", + parent_branch="egg/issue-1", + ) + + assert merged is False + + def test_returns_false_when_integration_branch_absent(self, gateway_client): + """First-run / branch-deleted case: ``ls-remote`` returns no + SHA for the integration branch → can't be merged → False. + Crucially does NOT run merge-base (no SHA to compare).""" + parent_sha = "abc12345" * 5 + + merge_base_calls: list[dict] = [] + + def fake_make_request(endpoint, method=None, data=None, **kwargs): + if endpoint == "/api/v1/git/execute": + merge_base_calls.append(dict(data or {})) + return {"success": True, "data": {}} + + with ( + patch.object(gateway_client, "register_session", return_value=_session_info()), + patch.object(gateway_client, "delete_session", return_value=True), + patch.object(gateway_client, "fetch_branch", return_value=True), + patch.object( + gateway_client, + "get_remote_branch_sha", + side_effect=self._setup_remotes(parent_sha, existing_sha=None), + ), + patch.object(gateway_client, "_make_request", side_effect=fake_make_request), + ): + merged = gateway_client.is_slice_branch_merged_into_parent( + "p", + "/repo", + integration_branch="egg/issue-1/slice-1", + parent_branch="egg/issue-1", + ) + + assert merged is False + assert merge_base_calls == [], ( + "must not run merge-base when one of the SHAs is unresolvable" + ) + + def test_returns_false_when_parent_branch_absent(self, gateway_client): + """If the parent branch can't be resolved on origin we have + nothing to compare against — return False rather than guess.""" + existing_sha = "feedface" * 5 + + with ( + patch.object(gateway_client, "register_session", return_value=_session_info()), + patch.object(gateway_client, "delete_session", return_value=True), + patch.object(gateway_client, "fetch_branch", return_value=True), + patch.object( + gateway_client, + "get_remote_branch_sha", + side_effect=self._setup_remotes(parent_sha=None, existing_sha=existing_sha), + ), + patch.object(gateway_client, "_make_request", return_value={"success": True}), + ): + merged = gateway_client.is_slice_branch_merged_into_parent( + "p", + "/repo", + integration_branch="egg/issue-1/slice-1", + parent_branch="egg/issue-1", + ) + + assert merged is False + + def test_returns_false_when_branches_equal(self, gateway_client): + """Tips equal → no-op state, neither merged nor diverged. Let + the caller fall through to the regular fast-forward no-op path.""" + sha = "cafebabe" * 5 + + merge_base_calls: list[dict] = [] + + def fake_make_request(endpoint, method=None, data=None, **kwargs): + if endpoint == "/api/v1/git/execute": + merge_base_calls.append(dict(data or {})) + return {"success": True, "data": {}} + + with ( + patch.object(gateway_client, "register_session", return_value=_session_info()), + patch.object(gateway_client, "delete_session", return_value=True), + patch.object(gateway_client, "fetch_branch", return_value=True), + patch.object(gateway_client, "get_remote_branch_sha", return_value=sha), + patch.object(gateway_client, "_make_request", side_effect=fake_make_request), + ): + merged = gateway_client.is_slice_branch_merged_into_parent( + "p", + "/repo", + integration_branch="egg/issue-1/slice-1", + parent_branch="egg/issue-1", + ) + + assert merged is False + assert merge_base_calls == [], "no merge-base when tips are equal" + + def test_session_cleaned_up_on_success_and_failure(self, gateway_client): + """The synthetic session must be deleted via ``delete_session`` + on both the success path and any exception path — symmetric + with ``create_slice_integration_branch``.""" + parent_sha = "deadbeef" * 5 + existing_sha = "feedface" * 5 + + delete_calls: list = [] + + def _delete(token): + delete_calls.append(token) + return True + + # Force an exception in merge-base so we exercise the failure path. + def fake_make_request(endpoint, method=None, data=None, **kwargs): + raise RuntimeError("kaboom") + + with ( + patch.object( + gateway_client, "register_session", return_value=_session_info("merged-tok") + ), + patch.object(gateway_client, "delete_session", side_effect=_delete), + patch.object(gateway_client, "fetch_branch", return_value=True), + patch.object( + gateway_client, + "get_remote_branch_sha", + side_effect=self._setup_remotes(parent_sha, existing_sha), + ), + patch.object(gateway_client, "_make_request", side_effect=fake_make_request), + ): + merged = gateway_client.is_slice_branch_merged_into_parent( + "p", + "/repo", + integration_branch="egg/issue-1/slice-1", + parent_branch="egg/issue-1", + ) + + assert merged is False + assert delete_calls == ["merged-tok"], ( + "synthetic session must be cleaned up even when the call raises" + ) + + class TestShaIsAncestor: """Unit tests for the ``_sha_is_ancestor`` helper that backs the #2512 restart-recovery detection.""" diff --git a/orchestrator/tests/test_diagnostic_logging_1633.py b/orchestrator/tests/test_diagnostic_logging_1633.py index 15584f827e..d6d2f3aec6 100644 --- a/orchestrator/tests/test_diagnostic_logging_1633.py +++ b/orchestrator/tests/test_diagnostic_logging_1633.py @@ -22,6 +22,10 @@ from message_store import Message, MessageStore, MessageType from models import PipelineStatus +# Default slice_id seeded onto implement-phase BRC messages so the +# post-#2548 hard-switchover writer accepts them. +_DEFAULT_IMPLEMENT_SLICE_ID = "slice-1" + def _make_brc_message( pipeline_id="issue-42", @@ -31,8 +35,20 @@ def _make_brc_message( body="test body", phase="implement", timestamp=None, + slice_id="__default__", ): - """Create a BRC message for testing.""" + """Create a BRC message for testing. + + For implement-phase messages, ``metadata['slice_id']`` is auto-stamped + to ``slice-1`` (#2548 hard switchover) unless ``slice_id`` is set + explicitly (pass ``None`` to test the missing-slice_id drop path). + """ + md: dict = {} + if slice_id == "__default__": + if phase == "implement": + md["slice_id"] = _DEFAULT_IMPLEMENT_SLICE_ID + elif slice_id is not None: + md["slice_id"] = slice_id return Message( pipeline_id=pipeline_id, from_role=from_role, @@ -42,7 +58,7 @@ def _make_brc_message( body=body, phase=phase, timestamp=timestamp or datetime(2026, 4, 8, 12, 0, 0, tzinfo=UTC), - metadata={}, + metadata=md, ) @@ -511,10 +527,15 @@ def fake_run(cmd, **kwargs): with patch("message_store.get_message_store", return_value=mock_store): mod._rewrite_brc_history_for_pr(worktree, pipeline_id, phases, identifier) - # Assertion (a): BRC history files written for both COMPLETE phases + # Assertion (a): BRC history files written for both COMPLETE phases. + # #2548: implement phase is per-slice; aggregate file is gone. brc_dir = worktree / ".egg-state" / "brc-history" assert (brc_dir / "42-refine.md").exists(), "BRC history for refine should exist" - assert (brc_dir / "42-implement.md").exists(), "BRC history for implement should exist" + impl_file = brc_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + assert impl_file.exists(), "Per-slice BRC history for implement should exist" + assert not (brc_dir / "42-implement.md").exists(), ( + "Aggregate implement.md leaked through hard switchover" + ) # Assertion (b): BRC files have correct content refine_content = (brc_dir / "42-refine.md").read_text() @@ -524,7 +545,7 @@ def fake_run(cmd, **kwargs): assert "CONSENSUS_PROPOSE" in refine_content assert "CONSENSUS_ACK" in refine_content - implement_content = (brc_dir / "42-implement.md").read_text() + implement_content = impl_file.read_text() assert "implement phase" in implement_content assert "Tests pass" in implement_content @@ -578,7 +599,13 @@ def fake_run(cmd, **kwargs): with patch("message_store.get_message_store", return_value=mock_store): mod._rewrite_brc_history_for_pr(worktree, "issue-42", phases, 42) - history_file = worktree / ".egg-state" / "brc-history" / "42-implement.md" + # #2548: implement is per-slice — the aggregate file is gone. + history_file = ( + worktree + / ".egg-state" + / "brc-history" + / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + ) assert history_file.exists(), "BRC history file should exist on disk" content = history_file.read_text() @@ -647,7 +674,12 @@ def fake_run(cmd, **kwargs): brc_dir = worktree / ".egg-state" / "brc-history" assert (brc_dir / "42-refine.md").exists(), "COMPLETE phase should have BRC file" assert not (brc_dir / "42-plan.md").exists(), "FAILED phase should NOT have BRC file" + # #2548: implement is per-slice now; no aggregate, and the per-slice + # file should also be absent because the implement phase is RUNNING. assert not (brc_dir / "42-implement.md").exists(), "RUNNING phase should NOT have BRC file" + assert not (brc_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md").exists(), ( + "RUNNING phase should NOT have per-slice BRC file" + ) # --------------------------------------------------------------------------- @@ -682,7 +714,10 @@ def test_write_brc_history_messages_exist_but_wrong_phase(self, tmp_path): _write_brc_history(tmp_path, "issue-42", "implement", 42) # Request "implement" history_dir = tmp_path / ".egg-state" / "brc-history" + # #2548: neither aggregate nor per-slice file should exist when no + # implement-phase messages are present. assert not (history_dir / "42-implement.md").exists() + assert not (history_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md").exists() def test_write_brc_history_string_identifier(self, tmp_path): """_write_brc_history works with string pipeline identifiers.""" @@ -695,8 +730,15 @@ def test_write_brc_history_string_identifier(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", "my-pipeline") - history_file = tmp_path / ".egg-state" / "brc-history" / "my-pipeline-implement.md" + # #2548: implement is per-slice — the aggregate file is gone. + history_file = ( + tmp_path + / ".egg-state" + / "brc-history" + / f"my-pipeline-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + ) assert history_file.exists() + assert not (tmp_path / ".egg-state" / "brc-history" / "my-pipeline-implement.md").exists() def test_rewrite_brc_history_mixed_statuses_logging(self, tmp_path): """Entry log correctly reports completed vs non-completed phase counts.""" diff --git a/orchestrator/tests/test_impasse_routing.py b/orchestrator/tests/test_impasse_routing.py new file mode 100644 index 0000000000..39a96a28c1 --- /dev/null +++ b/orchestrator/tests/test_impasse_routing.py @@ -0,0 +1,411 @@ +"""Tests for orchestrator-side impasse detection and routing (#2529).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +_ORCHESTRATOR_DIR = Path(__file__).resolve().parent.parent +_SHARED_DIR = _ORCHESTRATOR_DIR.parent / "shared" +for p in (_SHARED_DIR, _ORCHESTRATOR_DIR): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +from egg_contracts.agent_roles import AgentRole as ContractAgentRole # noqa: E402 +from egg_contracts.impasse import Impasse, ImpasseCategory # noqa: E402 +from egg_contracts.loader import ( # noqa: E402 + create_contract, + load_contract, + save_contract, +) +from egg_contracts.models import Slice, Task # noqa: E402 +from impasse_routing import ( # noqa: E402 + DELEGATION_LIMIT, + ImpasseAction, + collect_impasses, + route_impasses, +) + + +def _seed_contract(repo_root: Path, slice_id: str = "slice-1") -> str: + """Create a contract with one slice + one coder task and return its + pipeline id.""" + pipeline_id = "issue-9999" + contract = create_contract( + pipeline_id=pipeline_id, + title="impasse routing fixture", + repo_root=repo_root, + ) + contract.slices = [ + Slice( + id=slice_id, + name="seed slice", + tasks=[ + Task( + id="task-1-1", + description="edit conftest", + role="coder", + files_affected=["tests/conftest.py"], + ), + ], + ) + ] + save_contract(contract, repo_root) + return pipeline_id + + +def _write_agent_output( + repo_root: Path, + pipeline_id: str, + role: str, + impasse: Impasse | None, + handoff_data: dict | None = None, +) -> Path: + out_dir = repo_root / ".egg-state" / "agent-outputs" + out_dir.mkdir(parents=True, exist_ok=True) + fp = out_dir / f"{pipeline_id}-{role}-output.json" + payload: dict = {"role": role} + if handoff_data is not None: + payload["handoff_data"] = handoff_data + if impasse is not None: + payload["impasse"] = impasse.to_dict() + fp.write_text(json.dumps(payload)) + return fp + + +class TestCollectImpasses: + def test_returns_empty_when_no_outputs(self, tmp_path): + pid = _seed_contract(tmp_path) + result = collect_impasses( + tmp_path, pid, [ContractAgentRole.CODER, ContractAgentRole.TESTER] + ) + assert result == [] + + def test_skips_outputs_without_impasse(self, tmp_path): + pid = _seed_contract(tmp_path) + _write_agent_output(tmp_path, pid, "coder", impasse=None, handoff_data={"foo": "bar"}) + result = collect_impasses(tmp_path, pid, [ContractAgentRole.CODER]) + assert result == [] + + def test_picks_up_impasse(self, tmp_path): + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="x", + suggested_role="tester", + ) + _write_agent_output(tmp_path, pid, "coder", impasse=imp) + result = collect_impasses(tmp_path, pid, [ContractAgentRole.CODER]) + assert len(result) == 1 + role, found = result[0] + assert role == ContractAgentRole.CODER + assert found.suggested_role == "tester" + + def test_malformed_impasse_is_dropped(self, tmp_path): + pid = _seed_contract(tmp_path) + out_dir = tmp_path / ".egg-state" / "agent-outputs" + out_dir.mkdir(parents=True, exist_ok=True) + fp = out_dir / f"{pid}-coder-output.json" + fp.write_text(json.dumps({"role": "coder", "impasse": {"category": "garbage"}})) + result = collect_impasses(tmp_path, pid, [ContractAgentRole.CODER]) + assert result == [] + + +class TestRouteImpassesDelegate: + def test_first_wrong_role_impasse_delegates(self, tmp_path): + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="cannot write tests/conftest.py", + task_id="task-1-1", + suggested_role="tester", + blocked_files=["tests/conftest.py"], + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert len(decisions) == 1 + d = decisions[0] + assert d.action == ImpasseAction.DELEGATE + assert d.role == "coder" + assert d.new_role == "tester" + assert d.task_id == "task-1-1" + + contract = load_contract(pid, tmp_path) + task = contract.slices[0].tasks[0] + assert task.role == "tester" + assert task.delegation_attempts == 1 + + def test_wrong_role_without_task_id_resolves_via_role_match(self, tmp_path): + # When the agent omits task_id, the router falls back to "the + # single task in this slice owned by this role". Confirm that + # path mutates the right task. + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="cannot write tests/conftest.py", + suggested_role="tester", + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.DELEGATE + contract = load_contract(pid, tmp_path) + assert contract.slices[0].tasks[0].role == "tester" + + +class TestRouteImpassesEscalate: + def test_second_impasse_escalates(self, tmp_path): + pid = _seed_contract(tmp_path) + # Pre-bump the counter to simulate "we already delegated once". + contract = load_contract(pid, tmp_path) + contract.slices[0].tasks[0].delegation_attempts = DELEGATION_LIMIT + save_contract(contract, tmp_path) + + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="still cannot write the file", + task_id="task-1-1", + suggested_role="documenter", + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.TESTER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.ESCALATE + assert decisions[0].hitl_decision_id is not None + + contract = load_contract(pid, tmp_path) + assert any(d.id == decisions[0].hitl_decision_id for d in contract.decisions) + + def test_plan_bug_always_escalates(self, tmp_path): + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.PLAN_BUG, + reason="acceptance criteria contradict each other", + task_id="task-1-1", + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.ESCALATE + contract = load_contract(pid, tmp_path) + assert contract.slices[0].tasks[0].delegation_attempts == 0 + assert contract.slices[0].tasks[0].role == "coder" # untouched + + def test_external_blocker_escalates_with_hitl_options(self, tmp_path): + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.EXTERNAL_BLOCKER, + reason="upstream PR not merged", + task_id="task-1-1", + evidence={"blocking_pr": 1234}, + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.ESCALATE + contract = load_contract(pid, tmp_path) + decision = next(d for d in contract.decisions if d.id == decisions[0].hitl_decision_id) + assert "external_blocker" in decision.question + assert "upstream PR not merged" in decision.question + + def test_self_delegation_escalates(self, tmp_path): + # Even though report_impasse rejects suggested_role==role at the + # handler boundary, defense-in-depth: if a malformed payload + # ever reaches the router, fall through to HITL rather than + # silently looping forever. + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="x", + task_id="task-1-1", + suggested_role="coder", # same as impassed role + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.ESCALATE + + def test_unknown_alternative_role_escalates(self, tmp_path): + # suggested_role=overseer is non-producer; auto-delegate must + # reject it. + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="x", + task_id="task-1-1", + suggested_role="overseer", + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.ESCALATE + contract = load_contract(pid, tmp_path) + assert contract.slices[0].tasks[0].role == "coder" + + def test_unresolved_task_id_escalates(self, tmp_path): + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="x", + task_id="task-9-9", # nonexistent + suggested_role="tester", + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.ESCALATE + assert "task_id" in decisions[0].reason + + +class TestEmptyInput: + def test_no_impasses_returns_empty(self, tmp_path): + pid = _seed_contract(tmp_path) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[], + slice_id="slice-1", + ) + assert decisions == [] + # Contract untouched + contract = load_contract(pid, tmp_path) + assert contract.slices[0].tasks[0].role == "coder" + assert contract.slices[0].tasks[0].delegation_attempts == 0 + + +class TestForceEscalate: + """``force_escalate=True`` short-circuits the delegation path. + + The slice-loop wrapper sets this on its terminal iteration: a + delegation made there can never re-run a BRC cycle, so the role + flip would silently dangle and the operator would be stuck staring + at a contract whose ``task.role`` no longer matches the agent that + actually ran (review feedback #2 on PR #2553). + """ + + def test_terminal_iteration_escalates_first_impasse(self, tmp_path): + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="cannot write tests/conftest.py", + task_id="task-1-1", + suggested_role="tester", + blocked_files=["tests/conftest.py"], + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + force_escalate=True, + ) + assert len(decisions) == 1 + d = decisions[0] + assert d.action == ImpasseAction.ESCALATE + assert d.hitl_decision_id is not None + assert "terminal" in d.reason + + # Contract untouched — task.role MUST stay on the impassed + # role so the operator's view matches the agent that ran. + contract = load_contract(pid, tmp_path) + task = contract.slices[0].tasks[0] + assert task.role == "coder" + assert task.delegation_attempts == 0 + + def test_non_terminal_iteration_still_delegates(self, tmp_path): + # Sanity check: the default (force_escalate=False) preserves + # the auto-delegation path, so this is purely an opt-in + # safety gate for the terminal iteration. + pid = _seed_contract(tmp_path) + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="cannot write tests/conftest.py", + task_id="task-1-1", + suggested_role="tester", + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.DELEGATE + + def test_full_reason_recorded_in_audit_log(self, tmp_path): + # Regression test for review feedback #6 on PR #2553. The + # delegation reason previously truncated the agent's reason to + # 120 chars, which loses signal when a post-mortem reads the + # contract audit log. The schema caps reason at 2000; persist + # it intact. + pid = _seed_contract(tmp_path) + long_reason = ( + "I tried to write tests/conftest.py but the file restriction " + "patterns block coder from any path under tests/, see " + "shared/egg_restrictions/patterns.py:34. The exact failure " + "was a gateway pre-push validation that rejected the commit " + "with `tests/conftest.py blocked by tester pattern`. The " + "task description in the contract names tests/conftest.py " + "explicitly under files_affected, which means the planner " + "mis-assigned the role. Suggested fix: hand off to tester." + ) + assert len(long_reason) > 200 # ensure we exercise non-truncation + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason=long_reason, + task_id="task-1-1", + suggested_role="tester", + ) + decisions = route_impasses( + repo_path=tmp_path, + pipeline_id=pid, + contract_identifier=pid, + impasses=[(ContractAgentRole.CODER, imp)], + slice_id="slice-1", + ) + assert decisions[0].action == ImpasseAction.DELEGATE + + contract = load_contract(pid, tmp_path) + # Find the audit-log entry for the role mutation; the full + # agent reason must be preserved verbatim. + delegation_entries = [ + e for e in contract.audit_log if "Impasse-driven delegation" in (e.reason or "") + ] + assert delegation_entries, "expected audit-log entry for the delegation" + assert long_reason in delegation_entries[0].reason diff --git a/orchestrator/tests/test_messages.py b/orchestrator/tests/test_messages.py index 721cd29657..6299f94973 100644 --- a/orchestrator/tests/test_messages.py +++ b/orchestrator/tests/test_messages.py @@ -2668,6 +2668,90 @@ def test_heartbeat_rejects_invalid_slice_id(self, client, app, bad_slice_id): ) mock_gw_client.heartbeat_session_by_container.assert_not_called() + def test_heartbeat_message_metadata_carries_slice_id(self, client, app): + """Slice-scoped HEARTBEATs land on the bus with ``slice_id`` in + ``Message.metadata`` so the implement-phase BRC writer (#2548) can + partition them into the correct per-slice transcript file. + + Without this, every slice-scoped HEARTBEAT would be routed to the + ``unattributed`` sibling file even when the producer route already + knew the slice scope. + """ + from message_store import get_message_store + + store = get_message_store() + + with app.test_request_context(): + mock_gw_client = MagicMock() + with ( + patch( + "routes.messages.get_state_store_for_pipeline" + ) as mock_get_store_for_pipeline, + patch( + "gateway_client.get_gateway_client", + return_value=mock_gw_client, + ), + ): + mock_get_store_for_pipeline.return_value = ( + MagicMock(), + _make_pipeline_mock(), + ) + resp = client.post( + "/api/v1/pipelines/heartbeat-slice-meta-pipeline/heartbeat", + json={ + "from_role": "tester", + "state": "WORKING", + "slice_id": "slice-7", + }, + ) + assert resp.status_code == 200 + + # Inspect the stored message's metadata. + messages = store.get_messages("heartbeat-slice-meta-pipeline", limit=10) + heartbeats = [m for m in messages if m.message_type == "HEARTBEAT"] + assert heartbeats, "Expected a HEARTBEAT to be persisted on the bus" + assert heartbeats[-1].metadata.get("slice_id") == "slice-7", ( + f"slice_id missing from HEARTBEAT metadata: {heartbeats[-1].metadata}" + ) + + def test_heartbeat_metadata_omits_slice_id_when_pipeline_level(self, client, app): + """Non-slice (pipeline-level) HEARTBEATs MUST NOT carry a + ``slice_id`` key in metadata. The BRC writer treats absence as + "no slice scope" and falls back to the aggregate filename for + non-slice pipelines (babysit_pr et al.); a stray empty/None value + would smuggle these messages into the slice-aware path.""" + from message_store import get_message_store + + store = get_message_store() + + with app.test_request_context(): + mock_gw_client = MagicMock() + with ( + patch( + "routes.messages.get_state_store_for_pipeline" + ) as mock_get_store_for_pipeline, + patch( + "gateway_client.get_gateway_client", + return_value=mock_gw_client, + ), + ): + mock_get_store_for_pipeline.return_value = ( + MagicMock(), + _make_pipeline_mock(), + ) + resp = client.post( + "/api/v1/pipelines/heartbeat-noslice-pipeline/heartbeat", + json={"from_role": "coder", "state": "WORKING"}, + ) + assert resp.status_code == 200 + + messages = store.get_messages("heartbeat-noslice-pipeline", limit=10) + heartbeats = [m for m in messages if m.message_type == "HEARTBEAT"] + assert heartbeats, "Expected a HEARTBEAT to be persisted on the bus" + assert "slice_id" not in heartbeats[-1].metadata, ( + f"Pipeline-level HEARTBEAT must omit slice_id key, got: {heartbeats[-1].metadata}" + ) + def test_heartbeat_sibling_slices_do_not_share_throttle(self, client, app): """Sibling slices with the same role each fan out independently (#2451). diff --git a/orchestrator/tests/test_pipeline_impasse_cleanup.py b/orchestrator/tests/test_pipeline_impasse_cleanup.py new file mode 100644 index 0000000000..459dfdd51f --- /dev/null +++ b/orchestrator/tests/test_pipeline_impasse_cleanup.py @@ -0,0 +1,186 @@ +"""Tests for the post-delegation impasse cleanup helper (#2553 review). + +The slice-loop wrapper relies on +``_clear_stale_impasses_for_producers`` to drop the ``impasse`` field +from each producer's per-pipeline agent-output file before the next +BRC cycle. ``save_agent_output`` writes ``mode="w"`` so any producer +that respawns *and* reaches its handoff write will overwrite the +stale impasse on its own — but if a producer crashes pre-handoff in +iter-N+1 (or never spawns at all under a future contract-task-driven +roster), the iter-N file would otherwise persist into the next +``collect_impasses`` scan and mis-trigger a "second impasse on same +task" HITL escalation. + +These tests lock that fragile-by-design behaviour: the cleanup drops +``impasse`` for every producer that has one, leaves the other +top-level fields intact, and is a no-op for producers without an +impasse on file. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from egg_contracts.agent_roles import AgentRole as ContractAgentRole +from egg_contracts.orchestrator import save_agent_output +from routes.pipelines import _clear_stale_impasses_for_producers + +PRODUCER_ROLES = [ + ContractAgentRole.CODER, + ContractAgentRole.TESTER, + ContractAgentRole.DOCUMENTER, +] + + +def _read_output(repo: Path, pipeline_id: str, role: ContractAgentRole) -> dict: + fp = repo / ".egg-state" / "agent-outputs" / f"{pipeline_id}-{role.value}-output.json" + return json.loads(fp.read_text()) + + +class TestClearStaleImpasses: + def test_drops_impasse_field_preserves_others(self, tmp_path): + """The ``impasse`` key disappears; ``handoff_data`` and other + top-level fields survive verbatim.""" + pipeline_id = "issue-1234" + save_agent_output( + tmp_path, + ContractAgentRole.CODER, + { + "role": "coder", + "handoff_data": {"files_modified": ["a.py"]}, + "impasse": { + "category": "wrong_role", + "reason": "stale", + "task_id": "task-1", + }, + }, + identifier=pipeline_id, + ) + + _clear_stale_impasses_for_producers( + tmp_path, + pipeline_id, + PRODUCER_ROLES, + cleanup_reason="unit test", + ) + + cleaned = _read_output(tmp_path, pipeline_id, ContractAgentRole.CODER) + assert "impasse" not in cleaned + assert cleaned["role"] == "coder" + assert cleaned["handoff_data"] == {"files_modified": ["a.py"]} + + def test_no_op_when_no_impasse_field(self, tmp_path): + """Files without an ``impasse`` key are left byte-identical + on disk — the helper short-circuits before re-writing.""" + pipeline_id = "issue-1234" + original = { + "role": "tester", + "handoff_data": {"tests_added": 3}, + } + save_agent_output( + tmp_path, + ContractAgentRole.TESTER, + original, + identifier=pipeline_id, + ) + path = tmp_path / ".egg-state" / "agent-outputs" / f"{pipeline_id}-tester-output.json" + before_bytes = path.read_bytes() + + _clear_stale_impasses_for_producers( + tmp_path, + pipeline_id, + PRODUCER_ROLES, + cleanup_reason="unit test", + ) + + assert path.read_bytes() == before_bytes + + def test_no_op_when_output_file_missing(self, tmp_path): + """A producer that never wrote in iter-N (e.g. crashed pre- + handoff) must not cause the helper to raise or to materialise + an empty file.""" + pipeline_id = "issue-1234" + out_dir = tmp_path / ".egg-state" / "agent-outputs" + out_dir.mkdir(parents=True, exist_ok=True) + + # Sanity: no producer files exist yet. + assert list(out_dir.iterdir()) == [] + + _clear_stale_impasses_for_producers( + tmp_path, + pipeline_id, + PRODUCER_ROLES, + cleanup_reason="unit test", + ) + + assert list(out_dir.iterdir()) == [] + + def test_clears_across_multiple_producers(self, tmp_path): + """When more than one producer emitted an impasse in the same + iteration, every one of them is cleaned in a single pass.""" + pipeline_id = "issue-1234" + for role in (ContractAgentRole.CODER, ContractAgentRole.DOCUMENTER): + save_agent_output( + tmp_path, + role, + { + "role": role.value, + "handoff_data": {"role-marker": role.value}, + "impasse": {"category": "plan_bug", "reason": f"stale {role.value}"}, + }, + identifier=pipeline_id, + ) + # Tester wrote a clean output (no impasse) — must stay intact. + save_agent_output( + tmp_path, + ContractAgentRole.TESTER, + {"role": "tester", "handoff_data": {"tests_added": 1}}, + identifier=pipeline_id, + ) + + _clear_stale_impasses_for_producers( + tmp_path, + pipeline_id, + PRODUCER_ROLES, + cleanup_reason="unit test", + ) + + for role in (ContractAgentRole.CODER, ContractAgentRole.DOCUMENTER): + cleaned = _read_output(tmp_path, pipeline_id, role) + assert "impasse" not in cleaned + assert cleaned["handoff_data"] == {"role-marker": role.value} + tester = _read_output(tmp_path, pipeline_id, ContractAgentRole.TESTER) + assert tester == {"role": "tester", "handoff_data": {"tests_added": 1}} + + def test_does_not_touch_other_pipelines(self, tmp_path): + """The cleanup is scoped to the named pipeline id — a + concurrent pipeline's stale impasse on the same role must + not be cleared by this call.""" + own_pipeline = "issue-1234" + other_pipeline = "issue-9999" + + save_agent_output( + tmp_path, + ContractAgentRole.CODER, + {"role": "coder", "impasse": {"category": "wrong_role", "reason": "own"}}, + identifier=own_pipeline, + ) + save_agent_output( + tmp_path, + ContractAgentRole.CODER, + {"role": "coder", "impasse": {"category": "wrong_role", "reason": "other"}}, + identifier=other_pipeline, + ) + + _clear_stale_impasses_for_producers( + tmp_path, + own_pipeline, + PRODUCER_ROLES, + cleanup_reason="unit test", + ) + + own = _read_output(tmp_path, own_pipeline, ContractAgentRole.CODER) + assert "impasse" not in own + other = _read_output(tmp_path, other_pipeline, ContractAgentRole.CODER) + assert other["impasse"] == {"category": "wrong_role", "reason": "other"} diff --git a/orchestrator/tests/test_pipeline_prompts.py b/orchestrator/tests/test_pipeline_prompts.py index 53e1f9056a..9c62862566 100644 --- a/orchestrator/tests/test_pipeline_prompts.py +++ b/orchestrator/tests/test_pipeline_prompts.py @@ -3,6 +3,7 @@ """ import json +import subprocess import sys import tempfile from pathlib import Path @@ -31,6 +32,7 @@ _get_agent_design_criteria, _get_code_review_criteria, _get_contract_review_criteria, + _get_plan_review_criteria, _get_reviewer_scope_preamble, _read_shared_criteria, _read_tester_gaps, @@ -1036,6 +1038,77 @@ def test_every_prompt_has_context_section(self): assert f"Agent Role: {role}" in result +class TestProducerEscapeHatchInPrompts: + """Producer prompts must surface the runtime escape hatch (#2529). + + The check_file_restriction / report_impasse guidance has to land in + the agent prompt for every role that *emits* an impasse — coder, + tester, and documenter. Without this section the producer cannot + discover the escape hatch and falls back to inventing workarounds + (the .github-staging/ deletion-marker anti-pattern from pipeline + issue-2474-v2 that this PR exists to prevent). + + Originally the guidance was injected only into the planner prompt + via _build_role_restrictions_section, which meant producers never + saw it. The fix moves the actionable guidance into a producer-only + helper. + """ + + @pytest.mark.parametrize("role", ["coder", "tester", "documenter"]) + def test_producer_prompt_contains_report_impasse(self, role): + result = _build_agent_prompt( + role_value=role, + phase="implement", + pipeline_id="pid-1", + pipeline_mode="issue", + prompt="# Feature", + issue_number=42, + ) + assert "mcp__sdlc__report_impasse" in result, ( + f"{role} prompt missing the report_impasse escape-hatch tool name; " + "producers must see the actionable guidance to avoid inventing workarounds." + ) + assert "mcp__sdlc__check_file_restriction" in result, ( + f"{role} prompt missing the check_file_restriction tool name." + ) + assert "DO NOT invent workarounds" in result, ( + f"{role} prompt missing the anti-workaround directive." + ) + + def test_planner_prompt_has_summary_not_actionable_guidance(self): + """Planner gets only the post-failure delegation summary, not the + producer-facing actionable text. The planner doesn't emit + impasses; it doesn't need the call-these-tools instructions.""" + result = _build_agent_prompt( + role_value="task_planner", + phase="plan", + pipeline_id="pid-1", + pipeline_mode="issue", + prompt="# Feature", + issue_number=42, + ) + assert "Runtime delegation" in result + assert "auto-delegate" in result + # The actionable producer-only directives should NOT appear in + # the planner prompt — the summary mentions ``report_impasse`` + # by name for context, but doesn't tell the planner to call it. + assert "DO NOT invent workarounds" not in result + assert "mcp__sdlc__check_file_restriction" not in result + + def test_architect_prompt_does_not_contain_escape_hatch(self): + """Architect is a non-impassing analysis role — it shouldn't + carry the producer-only guidance.""" + result = _build_agent_prompt( + role_value="architect", + phase="plan", + pipeline_id="pid-1", + pipeline_mode="issue", + prompt="# Feature", + issue_number=42, + ) + assert "mcp__sdlc__report_impasse" not in result + + # ── Additional tester-authored coverage ────────────────────────────────────── @@ -2132,6 +2205,228 @@ def test_rejected_proposal_does_not_mutate_tracker(self): mock_tracker.handle_propose.assert_not_called() +class TestPlannerRoleAlignmentValidation: + """Tests for ``_validate_planner_role_alignment`` in ``signals.py`` (#2527). + + Exercises the production code path the original PR-1 implementation + couldn't reach: in concurrent BRC mode, ``_run_concurrent_phase`` + builds every reviewer prompt up-front before the planner has produced + the plan, so a prompt-time validator can never fire on the first + cycle. This validator runs at ``CONSENSUS_PROPOSE`` instead — by + that point the planner has pushed the plan to origin, and the + orchestrator reads it via ``git show :``. + """ + + _PLAN_WITH_MISASSIGNED_TASK = ( + "# Plan\n" + "\n" + "```yaml\n" + "# yaml-tasks\n" + "slices:\n" + " - id: 1\n" + " name: Setup\n" + " goal: scaffolding\n" + " tasks:\n" + " - id: TASK-1-1\n" + " description: Add pytest fixtures\n" + " acceptance: fixtures load\n" + " role: coder\n" + " files:\n" + " - integration_tests/conftest.py\n" + "```\n" + ) + + _PLAN_WITH_CLEAN_ASSIGNMENTS = ( + "# Plan\n" + "\n" + "```yaml\n" + "# yaml-tasks\n" + "slices:\n" + " - id: 1\n" + " name: Setup\n" + " goal: scaffolding\n" + " tasks:\n" + " - id: TASK-1-1\n" + " description: Add pytest fixtures\n" + " acceptance: fixtures load\n" + " role: tester\n" + " files:\n" + " - integration_tests/conftest.py\n" + "```\n" + ) + + @staticmethod + def _patched_store(issue_number: int | None = 2527, branch: str = "egg/issue-2527"): + mock_pipeline = MagicMock() + mock_pipeline.issue_number = issue_number + mock_pipeline.branch = branch + mock_pipeline.mode = None + mock_store = MagicMock() + mock_store.load_pipeline.return_value = mock_pipeline + return patch("routes.signals.get_state_store", return_value=mock_store) + + @staticmethod + def _patched_subprocess(plan_text: str, returncode: int = 0): + result = subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=plan_text, stderr="" + ) + return patch("routes.signals.subprocess.run", return_value=result) + + @staticmethod + def _patched_worktree(): + return patch("routes.signals.resolve_worktree_path", return_value=Path("/tmp/wt")) + + def test_skips_when_commit_sha_missing(self): + """No commit SHA on payload → nothing to validate against.""" + from routes.signals import _validate_planner_role_alignment + + # Should not raise even with no other patches in place — the + # bail-out happens before any state-store / git access. + _validate_planner_role_alignment("issue-2527", {"payload": {}}, Path("/tmp")) + _validate_planner_role_alignment("issue-2527", {"commit_sha": ""}, Path("/tmp")) + + def test_rejects_misassigned_plan_at_propose_time(self): + """Planner pushed a plan with coder→test-files: validator raises.""" + from routes.signals import _validate_planner_role_alignment + + with ( + self._patched_store(), + self._patched_worktree(), + self._patched_subprocess(self._PLAN_WITH_MISASSIGNED_TASK), + ): + payload = {"commit_sha": "abc1234"} + with pytest.raises(ValueError, match="role↔files alignment violations"): + _validate_planner_role_alignment("issue-2527", payload, Path("/tmp/repo")) + + def test_accepts_clean_plan(self): + """Planner pushed a plan with correctly-assigned roles: no raise.""" + from routes.signals import _validate_planner_role_alignment + + with ( + self._patched_store(), + self._patched_worktree(), + self._patched_subprocess(self._PLAN_WITH_CLEAN_ASSIGNMENTS), + ): + payload = {"commit_sha": "abc1234"} + # Should not raise. + _validate_planner_role_alignment("issue-2527", payload, Path("/tmp/repo")) + + def test_skips_when_git_show_fails(self): + """``git show`` non-zero exit (plan absent at commit) → graceful skip.""" + from routes.signals import _validate_planner_role_alignment + + with ( + self._patched_store(), + self._patched_worktree(), + self._patched_subprocess("", returncode=128), + ): + payload = {"commit_sha": "abc1234"} + # Should not raise. + _validate_planner_role_alignment("issue-2527", payload, Path("/tmp/repo")) + + def test_skips_when_pipeline_lookup_fails(self): + """State store load failure → graceful skip.""" + from routes.signals import _validate_planner_role_alignment + from state_store import StateValidationError + + mock_store = MagicMock() + mock_store.load_pipeline.side_effect = StateValidationError("corrupt state") + + with ( + patch("routes.signals.get_state_store", return_value=mock_store), + self._patched_worktree(), + ): + payload = {"commit_sha": "abc1234"} + # Should not raise — graceful degradation + _validate_planner_role_alignment("issue-2527", payload, Path("/tmp/repo")) + + def test_skips_when_pipeline_has_no_branch(self): + """A pipeline with ``branch=None`` → graceful skip (no git context).""" + from routes.signals import _validate_planner_role_alignment + + with ( + self._patched_store(branch=None), + self._patched_worktree(), + ): + payload = {"commit_sha": "abc1234"} + # Should not raise — branch is required to resolve the worktree commit. + _validate_planner_role_alignment("issue-2527", payload, Path("/tmp/repo")) + + def test_rejected_proposal_does_not_mutate_tracker(self): + """Integration: a planner proposal carrying a misassigned plan + is rejected at ``handle_consensus_propose_signal`` BEFORE the + tracker is mutated — same guarantee as + ``test_rejected_proposal_does_not_mutate_tracker`` for + testers (#1459). + + This is the production-sequence end-to-end test the PR-1 review + flagged as missing: it builds the propose signal exactly the + way the planner agent does in concurrent BRC mode, mocks + ``git show`` to return the misassigned plan (the file the + orchestrator's worktree would read at the proposed commit), + and asserts the tracker is left untouched. + """ + from flask import Flask + from routes.signals import handle_consensus_propose_signal + + mock_tracker = MagicMock() + mock_tracker.handle_propose = MagicMock(return_value={"version": 1}) + + # Two subprocess calls happen in this path: + # 1. _verify_commit_on_branch's git fetch + # 2. _verify_commit_on_branch's git branch --contains + # 3. _validate_planner_role_alignment's git show + # The first two return success; the third returns the misassigned plan. + side_effect = [ + subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""), + subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=" origin/egg/issue-2527\n", + stderr="", + ), + subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=self._PLAN_WITH_MISASSIGNED_TASK, + stderr="", + ), + ] + + app = Flask(__name__) + with ( + app.app_context(), + self._patched_store(), + self._patched_worktree(), + patch("routes.signals.subprocess.run", side_effect=side_effect), + patch("peer_consensus.get_peer_consensus_tracker", return_value=mock_tracker), + ): + data = { + "agent_role": "task_planner", + "payload": { + "summary": ( + "Plan v1: 1 slice / 1 task with task-1-1 assigned " + "(coder) for the integration_tests fixture work" + ), + "artifacts": [".egg-state/drafts/2527-plan.md"], + "commit_sha": "abc1234", + }, + } + response, status_code = handle_consensus_propose_signal( + "issue-2527", data, Path("/tmp/repo") + ) + # Rejected with 400 (ValueError → make_error_response 400). + assert status_code == 400 + data_out = response.get_json() + assert "role↔files alignment violations" in data_out.get("message", "") + # Tracker.handle_propose must NOT have been called — the + # validator runs BEFORE the tracker, so a rejected proposal + # never mutates tracker state. This is the regression + # guarantee the PR-1 review flagged as the missing + # production-sequence test. + mock_tracker.handle_propose.assert_not_called() + + class TestReadTesterGapsNamespacedEdgeCases: """Additional edge-case tests for _read_tester_gaps with identifier.""" @@ -4300,3 +4595,58 @@ def test_revert_of_coordination_detected(self): assert "HANDOFF" in preamble # Must clarify relationship to consensus assert "supplementary" in preamble or "consensus" in preamble.lower() + + +# --------------------------------------------------------------------------- +# #2527 — plan reviewer's task role↔files alignment check +# --------------------------------------------------------------------------- +# +# Original PR-1 design built a "Structural Role-Alignment Check" section +# into the reviewer prompt at _build_review_prompt time. PR-1 review +# flagged that as a cross-module silent no-op in concurrent BRC mode: +# in production, all agent prompts are built up-front by +# _run_concurrent_phase BEFORE the planner has produced the plan +# (concurrent_executor.spawn_all). The plan draft does not exist on +# the orchestrator's worktree at that moment, so the section was always +# empty and the reviewer was told "absence = no violations" — the +# opposite of the truth. +# +# Resolution (this PR-2): orchestrator-side validation runs at +# CONSENSUS_PROPOSE in routes/signals.py:_validate_planner_role_alignment, +# rejecting the planner's proposal with HTTP 400 before the tracker +# state is mutated. The reviewer prompt no longer carries a per-prompt +# section; the validator-runs-here tests live in this same file under +# class TestPlannerRoleAlignmentValidation (above). + + +class TestPlanReviewCriteriaReflectsOrchestratorSideValidation: + """The plan-review criteria string documents that role↔files + alignment is enforced orchestrator-side at CONSENSUS_PROPOSE so a + reviewer reading the criteria does not expect a per-prompt section + (which the broken PR-1 wiring promised but never delivered in + concurrent mode).""" + + def test_criteria_references_orchestrator_side_enforcement(self): + criteria = _get_plan_review_criteria() + # Must still call out the dimension by name. + assert "Role" in criteria and "Alignment" in criteria + assert "#2527" in criteria + # Must explicitly tell the reviewer the check runs at + # CONSENSUS_PROPOSE rather than at prompt-build time. + assert "CONSENSUS_PROPOSE" in criteria + assert "orchestrator-side" in criteria + # Must describe rejection of the proposal (not "absence = no + # violations" — that was the PR-1 false-clean wording). + assert "rejected" in criteria + # The push-time backstop (`403 restricted_path_modified`) is + # the link to the gateway's existing enforcement. + assert "403" in criteria + + def test_criteria_does_not_reuse_pr1_false_clean_wording(self): + # Specific regression guard: PR-1 included the line + # "The absence of that section means the automated check found + # no violations". That sentence was structurally a false-clean + # in concurrent BRC mode (the section was always absent because + # the plan didn't exist yet). Make sure it doesn't reappear. + criteria = _get_plan_review_criteria() + assert "absence of that section" not in criteria.lower() diff --git a/orchestrator/tests/test_pr_phase_brc_rewrite.py b/orchestrator/tests/test_pr_phase_brc_rewrite.py index 0ddf87ea9b..0907f4d738 100644 --- a/orchestrator/tests/test_pr_phase_brc_rewrite.py +++ b/orchestrator/tests/test_pr_phase_brc_rewrite.py @@ -18,6 +18,10 @@ from message_store import Message, MessageStore, MessageType from models import PipelineStatus +# Default slice_id stamped on implement-phase messages so the post-#2548 +# hard-switchover writer accepts them. +_DEFAULT_IMPLEMENT_SLICE_ID = "slice-1" + def _make_brc_message( pipeline_id="issue-42", @@ -27,8 +31,21 @@ def _make_brc_message( body="test body", phase="implement", timestamp=None, + slice_id="__default__", ): - """Create a BRC message for testing.""" + """Create a BRC message for testing. + + For implement-phase messages, ``metadata['slice_id']`` is auto-stamped + to ``slice-1`` (#2548 hard switchover) so the writer keeps producing a + file. Pass ``slice_id=None`` explicitly to test the missing-slice_id + drop path. + """ + md: dict = {} + if slice_id == "__default__": + if phase == "implement": + md["slice_id"] = _DEFAULT_IMPLEMENT_SLICE_ID + elif slice_id is not None: + md["slice_id"] = slice_id return Message( pipeline_id=pipeline_id, from_role=from_role, @@ -38,7 +55,7 @@ def _make_brc_message( body=body, phase=phase, timestamp=timestamp or datetime(2026, 4, 8, 12, 0, 0, tzinfo=UTC), - metadata={}, + metadata=md, ) @@ -84,7 +101,11 @@ def test_writes_history_for_completed_phases(self, tmp_path): history_dir = tmp_path / ".egg-state" / "brc-history" assert (history_dir / "42-refine.md").exists() assert (history_dir / "42-plan.md").exists() - assert (history_dir / "42-implement.md").exists() + # #2548: implement is per-slice — aggregate is gone. + assert (history_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md").exists() + assert not (history_dir / "42-implement.md").exists(), ( + "Aggregate implement.md leaked through hard switchover" + ) def test_skips_non_complete_phases(self, tmp_path): """Phases with FAILED or RUNNING status are not written.""" @@ -114,8 +135,9 @@ def test_skips_non_complete_phases(self, tmp_path): history_dir = tmp_path / ".egg-state" / "brc-history" assert (history_dir / "42-refine.md").exists() assert (history_dir / "42-plan.md").exists() - # implement was FAILED, so not written + # implement was FAILED, so neither aggregate nor per-slice file exists. assert not (history_dir / "42-implement.md").exists() + assert not (history_dir / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md").exists() def test_idempotent_rewrite(self, tmp_path): """Re-writing BRC history overwrites existing files safely.""" @@ -135,7 +157,12 @@ def test_idempotent_rewrite(self, tmp_path): with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) - history_file = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + history_file = ( + tmp_path + / ".egg-state" + / "brc-history" + / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + ) first_content = history_file.read_text() # Add more messages and re-write @@ -201,7 +228,12 @@ def test_regeneration_with_same_messages_is_byte_identical(self, tmp_path): mock_store = MagicMock(spec=MessageStore) mock_store.get_messages.return_value = messages - history_file = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + history_file = ( + tmp_path + / ".egg-state" + / "brc-history" + / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + ) with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) @@ -230,7 +262,12 @@ def test_generated_timestamp_tracks_latest_message(self, tmp_path): mock_store = MagicMock(spec=MessageStore) mock_store.get_messages.return_value = messages - history_file = tmp_path / ".egg-state" / "brc-history" / "42-implement.md" + history_file = ( + tmp_path + / ".egg-state" + / "brc-history" + / f"42-implement-{_DEFAULT_IMPLEMENT_SLICE_ID}.md" + ) with patch("message_store.get_message_store", return_value=mock_store): _write_brc_history(tmp_path, "issue-42", "implement", 42) diff --git a/orchestrator/tests/test_short_flow_contract_population.py b/orchestrator/tests/test_short_flow_contract_population.py index d4a12a01ef..7491438032 100644 --- a/orchestrator/tests/test_short_flow_contract_population.py +++ b/orchestrator/tests/test_short_flow_contract_population.py @@ -154,6 +154,75 @@ def test_no_plan_draft_is_noop(self, tmp_path: Path): contract = load_contract(pipeline_id, tmp_path) assert len(contract.phases) == 0 # Still empty + def test_populate_contract_from_plan_preserves_deferred_actions(self, tmp_path: Path): + """A re-populate must preserve runtime-only ``PRMetadata`` fields. + + Regression for the slice-1 review in PR #2555: the populator + rebuilds ``contract.pr`` wholesale from the plan, and a prior + version preserved ``context_branch`` / ``context_pr_number`` + but silently wiped ``deferred_actions`` — the merge-blocking + Pre-merge Obligations handoff written by the conditional-ACK + gate at ``decisions.py:complete_phase``. The + ``start_phase=implement`` re-entry path can hit this populator + after ``deferred_actions`` is already populated; losing it + erases the only durable handoff for git-mv / migration / + cross-repo flips. + + Setup: create a contract, populate ``contract.pr`` once from + the plan, then mutate ``contract.pr.deferred_actions`` and + ``contract.pr.context_branch`` / ``context_pr_number`` to + simulate runtime-populated state, save, and re-run the + populator. Assert the runtime fields survive while the + planner-emitted fields are refreshed from the plan. + """ + from egg_contracts.loader import create_contract, load_contract, save_contract + from egg_contracts.models import DeferredAction + from routes.pipelines import _populate_contract_from_plan + + pipeline_id = "pipeline-deferred-preserve" + + create_contract(pipeline_id=pipeline_id, title="Test", repo_root=tmp_path) + + drafts_dir = tmp_path / ".egg-state" / "drafts" + drafts_dir.mkdir(parents=True, exist_ok=True) + (drafts_dir / f"{pipeline_id}-plan.md").write_text(SAMPLE_PLAN) + + # First populate — establishes ``contract.pr`` from the plan. + _populate_contract_from_plan(tmp_path, pipeline_id, "local") + + # Simulate runtime-populated state: a conditional-ACK gate + # resolved at ``complete_phase`` and stamped a deferred action, + # plus the orchestrator opened the context PR and stamped the + # branch / PR-number. + contract = load_contract(pipeline_id, tmp_path) + assert contract.pr is not None + contract.pr.deferred_actions = [ + DeferredAction( + reviewer="reviewer_code", + condition="must rename foo → bar before merge", + resolved_in_diff="", + ) + ] + contract.pr.context_branch = "egg/pipeline-deferred-preserve/context" + contract.pr.context_pr_number = 7777 + save_contract(contract, tmp_path) + + # Re-run the populator (e.g. start_phase=implement re-entry). + _populate_contract_from_plan(tmp_path, pipeline_id, "local") + + # All three runtime-populated fields must survive the re-build. + contract_after = load_contract(pipeline_id, tmp_path) + assert contract_after.pr is not None + assert len(contract_after.pr.deferred_actions) == 1 + assert ( + contract_after.pr.deferred_actions[0].condition == "must rename foo → bar before merge" + ) + assert contract_after.pr.deferred_actions[0].reviewer == "reviewer_code" + assert contract_after.pr.context_branch == "egg/pipeline-deferred-preserve/context" + assert contract_after.pr.context_pr_number == 7777 + # And the planner-emitted fields are still refreshed from the plan. + assert contract_after.pr.title == "Add retry logic to API client" + class TestEnsureStatefilesRestoresPRMetadata: """_ensure_statefiles_on_branch re-populates PR metadata from plan draft. diff --git a/orchestrator/tests/test_signals.py b/orchestrator/tests/test_signals.py index e995b24783..371ae4bd75 100644 --- a/orchestrator/tests/test_signals.py +++ b/orchestrator/tests/test_signals.py @@ -1463,6 +1463,163 @@ def test_valid_decision_proceeds(self, app): assert data["success"] is True mock_tracker.excuse_producer.assert_called_once_with("coder", "Not delivering") + def test_excuse_producer_status_carries_slice_id_metadata(self, app): + """Slice-scoped excuse-producer STATUS lands on the bus with + ``slice_id`` in ``Message.metadata`` so the implement-phase BRC + writer (#2548) routes it into the producer's per-slice transcript.""" + with app.app_context(): + from models import DecisionStatus + from routes.signals import handle_consensus_excuse_producer_signal + + mock_decision = MagicMock() + mock_decision.status = DecisionStatus.RESOLVED + mock_decision.context = "failed_role:coder" + + mock_queue = MagicMock() + mock_queue.get_decision.return_value = mock_decision + + mock_tracker = MagicMock() + mock_tracker.excuse_producer.return_value = { + "status": "excused", + "affected_reviewers": ["reviewer_code"], + } + + mock_store_inst = MagicMock() + + with ( + patch("decision_queue.get_decision_queue", return_value=mock_queue), + patch( + "peer_consensus.get_peer_consensus_tracker", + return_value=mock_tracker, + ), + patch("message_store.get_message_store", return_value=mock_store_inst), + patch("routes.signals._resolve_pipeline_phase", return_value="implement"), + ): + response, status_code = handle_consensus_excuse_producer_signal( + "issue-42", + { + "producer_role": "coder", + "reason": "Not delivering", + "decision_id": "dec-123", + "slice_id": "slice-3", + }, + Path("/tmp/repo"), + ) + + assert status_code == 200 + # Inspect the Message that was added to the store. + mock_store_inst.add_message.assert_called_once() + stored_message = mock_store_inst.add_message.call_args[0][0] + assert stored_message.message_type == "STATUS" + assert stored_message.metadata.get("slice_id") == "slice-3", ( + f"slice_id missing from excuse-producer STATUS metadata: {stored_message.metadata}" + ) + + def test_ready_to_confirm_status_carries_slice_id_metadata(self, app): + """Slice-scoped ``_emit_ready_to_confirm_nudges`` stamps + ``slice_id`` on the ready-to-confirm STATUS so the implement-phase + BRC writer routes the nudge into the producer's per-slice + transcript (#2548 follow-up; pins the metadata stamp on the + ready-to-confirm STATUS path that the three call sites — propose, + ACK, producer-push — feed).""" + with app.app_context(): + from routes.signals import _emit_ready_to_confirm_nudges + + mock_store_inst = MagicMock() + mock_tracker = MagicMock() + + with patch("message_store.get_message_store", return_value=mock_store_inst): + _emit_ready_to_confirm_nudges( + "issue-42", + "implement", + [{"role": "coder", "version": 3}], + tracker=mock_tracker, + slice_id="slice-2", + ) + + mock_store_inst.add_message.assert_called_once() + stored = mock_store_inst.add_message.call_args[0][0] + assert stored.message_type == "STATUS" + assert stored.metadata.get("ready_to_confirm") is True + assert stored.metadata.get("version") == 3 + assert stored.metadata.get("slice_id") == "slice-2", ( + f"slice_id missing from ready-to-confirm STATUS metadata: {stored.metadata}" + ) + + def test_ready_to_confirm_status_omits_slice_id_when_pipeline_level(self, app): + """Pipeline-level (non-slice) ready-to-confirm STATUS MUST NOT + carry a ``slice_id`` key. ``_emit_ready_to_confirm_nudges`` + defaults the parameter to ``None``; the writer treats absence as + "no slice scope" so babysit_pr et al. continue to land in the + aggregate file.""" + with app.app_context(): + from routes.signals import _emit_ready_to_confirm_nudges + + mock_store_inst = MagicMock() + + with patch("message_store.get_message_store", return_value=mock_store_inst): + _emit_ready_to_confirm_nudges( + "issue-42", + "implement", + [{"role": "coder", "version": 1}], + ) + + mock_store_inst.add_message.assert_called_once() + stored = mock_store_inst.add_message.call_args[0][0] + assert "slice_id" not in stored.metadata, ( + f"Pipeline-level ready-to-confirm STATUS must omit slice_id, got: {stored.metadata}" + ) + + def test_excuse_producer_status_omits_slice_id_when_pipeline_level(self, app): + """Non-slice (pipeline-level) excuse-producer STATUS MUST NOT + carry a ``slice_id`` key — the BRC writer treats absence as + "no slice scope" and falls back to the aggregate filename + (babysit_pr et al.).""" + with app.app_context(): + from models import DecisionStatus + from routes.signals import handle_consensus_excuse_producer_signal + + mock_decision = MagicMock() + mock_decision.status = DecisionStatus.RESOLVED + mock_decision.context = "failed_role:coder" + + mock_queue = MagicMock() + mock_queue.get_decision.return_value = mock_decision + + mock_tracker = MagicMock() + mock_tracker.excuse_producer.return_value = { + "status": "excused", + "affected_reviewers": ["reviewer_code"], + } + + mock_store_inst = MagicMock() + + with ( + patch("decision_queue.get_decision_queue", return_value=mock_queue), + patch( + "peer_consensus.get_peer_consensus_tracker", + return_value=mock_tracker, + ), + patch("message_store.get_message_store", return_value=mock_store_inst), + patch("routes.signals._resolve_pipeline_phase", return_value="implement"), + ): + response, status_code = handle_consensus_excuse_producer_signal( + "issue-42", + { + "producer_role": "coder", + "reason": "Not delivering", + "decision_id": "dec-123", + }, + Path("/tmp/repo"), + ) + + assert status_code == 200 + mock_store_inst.add_message.assert_called_once() + stored_message = mock_store_inst.add_message.call_args[0][0] + assert "slice_id" not in stored_message.metadata, ( + f"Pipeline-level STATUS must omit slice_id key, got: {stored_message.metadata}" + ) + # --------------------------------------------------------------------------- # ACK version forwarding tests (#1637) diff --git a/orchestrator/tests/test_slice_run_loop_integration.py b/orchestrator/tests/test_slice_run_loop_integration.py index 7c186f0fa1..2ac09c4d1e 100644 --- a/orchestrator/tests/test_slice_run_loop_integration.py +++ b/orchestrator/tests/test_slice_run_loop_integration.py @@ -439,6 +439,11 @@ def _make_spawner(self) -> MagicMock: spawner = MagicMock() spawner.gateway = MagicMock() spawner.gateway.create_slice_pr.return_value = "https://example/pr/1" + # #2549 — bootstrap reconciliation + run-loop race-protection both + # call this gateway helper. Default to False so existing tests + # exercise the spawn-and-run path; merged-detection tests set it + # to True explicitly. + spawner.gateway.is_slice_branch_merged_into_parent.return_value = False return spawner def _make_loader_save_pair(self, contract: Contract) -> tuple[MagicMock, MagicMock]: @@ -942,6 +947,342 @@ def test_single_slice_path_skips_pr_when_repo_unset(self) -> None: spawner.gateway.create_slice_pr.assert_not_called() +# --------------------------------------------------------------------------- +# #2549 — already-merged-slice detection (bootstrap + race protection) +# --------------------------------------------------------------------------- + + +class TestSliceMergedDetection: + """#2549 — orchestrator must skip slices whose PR has already merged. + + Live repro: pipeline ``issue-2474-v2`` slice-1 merged → operator + ran ``start_pipeline`` to resume from slice-2 → orchestrator tried + to recreate slice-1's integration branch → push rejected as + non-fast-forward → slice-1 cascade-failed slices 2-5 in 5 seconds. + + Two layers cover the failure: + + * **Bootstrap reconciliation** runs once before the slice run loop + starts. Folds in (A) ``Slice.status == COMPLETE`` from prior + run's contract write and (B) gateway-detected + ``is_slice_branch_merged_into_parent`` for slices the contract + doesn't know about yet (e.g. pipelines whose merge happened + before the writer landed). + + * **Run-loop race protection** runs at slice spawn. Catches the + narrow window where a slice's PR is merged after bootstrap but + before the slice's wave executes. + + Both layers persist ``slice.status = SliceStatus.COMPLETE`` on + the contract so subsequent restarts go through the cheap + contract-only path. + """ + + def _make_spawner(self) -> MagicMock: + spawner = MagicMock() + spawner.gateway = MagicMock() + spawner.gateway.create_slice_pr.return_value = "https://example/pr/1" + spawner.gateway.is_slice_branch_merged_into_parent.return_value = False + return spawner + + def test_bootstrap_skips_slice_marked_complete_on_contract(self) -> None: + """(A) — Slice already marked COMPLETE on the contract is + skipped without calling ``is_slice_branch_merged_into_parent`` + (cheap path: trust the contract, no GitHub round-trip).""" + pipeline = _make_pipeline() + slice1 = _make_slice("slice-1", tasks=[_make_task("task-1-1")]) + slice1.status = SliceStatus.COMPLETE # prior run wrote this on success + slice2 = _make_slice("slice-2", deps=["slice-1"], tasks=[_make_task("task-2-1")]) + contract = _make_contract(slices=[slice1, slice2]) + + with ( + patch("egg_contracts.loader.load_contract", return_value=contract), + patch("egg_contracts.loader.save_contract"), + patch("routes.pipelines._start_stacked_pr_reconciler") as mock_start_recon, + patch( + "routes.pipelines._run_concurrent_phase", return_value=(0, "ok") + ) as mock_run_phase, + patch("orchestrator.peer_consensus.remove_peer_consensus_tracker"), + ): + mock_start_recon.return_value = (MagicMock(), threading.Event()) + spawner = self._make_spawner() + exit_code, _ = _run_implement_phase_slices( + pipeline_id=pipeline.id, + pipeline=pipeline, + spawner=spawner, + repo_volumes={}, + gateway_mode="public", + repos=["owner/repo"], + sandbox_env={}, + store=MagicMock(), + certs_volume=None, + worktree_repo_path=Path("/tmp/x"), + ) + assert exit_code == 0 + # Only slice-2 ran — slice-1 was trusted from the contract. + invoked = {c.kwargs["slice_id"] for c in mock_run_phase.call_args_list} + assert invoked == {"slice-2"}, ( + "slice-1 must be skipped at bootstrap when its contract status is COMPLETE" + ) + # No PR opened for slice-1 (it's already done). + pr_slice_ids = [ + c.kwargs["slice_id"] for c in spawner.gateway.create_slice_pr.call_args_list + ] + assert "slice-1" not in pr_slice_ids + assert "slice-2" in pr_slice_ids + # Step (A) trusts the contract — no GitHub round-trip for the COMPLETE slice. + merged_calls_for_slice1 = [ + c + for c in spawner.gateway.is_slice_branch_merged_into_parent.call_args_list + if c.kwargs.get("integration_branch", "").endswith("/slice-1") + ] + assert merged_calls_for_slice1 == [], ( + "step (A) must skip the GitHub-side merged-detection when contract " + "already records COMPLETE" + ) + + def test_bootstrap_detects_merged_slice_on_origin(self) -> None: + """(B) — slice still PENDING on contract but merged on origin + (the literal #2549 repro). Bootstrap detects via + ``is_slice_branch_merged_into_parent``, marks the slice + complete, persists ``status=COMPLETE``, and the run loop + proceeds with slice-2 alone.""" + pipeline = _make_pipeline() + slice1 = _make_slice("slice-1", tasks=[_make_task("task-1-1")]) + slice2 = _make_slice("slice-2", deps=["slice-1"], tasks=[_make_task("task-2-1")]) + contract = _make_contract(slices=[slice1, slice2]) + + # The contract write under the lock loads + saves; mock the + # save to capture what status got persisted. + save_calls: list[Contract] = [] + + def _capture_save(c: Contract, _path: Any) -> None: + save_calls.append(c) + + # Slice-1 is the merged slice; slice-2 is not. + def _merged_side_effect(*_args: Any, **kwargs: Any) -> bool: + return kwargs.get("integration_branch", "").endswith("/slice-1") + + with ( + patch("egg_contracts.loader.load_contract", return_value=contract), + patch("egg_contracts.loader.save_contract", side_effect=_capture_save), + patch("routes.pipelines._start_stacked_pr_reconciler") as mock_start_recon, + patch( + "routes.pipelines._run_concurrent_phase", return_value=(0, "ok") + ) as mock_run_phase, + patch("orchestrator.peer_consensus.remove_peer_consensus_tracker"), + ): + mock_start_recon.return_value = (MagicMock(), threading.Event()) + spawner = self._make_spawner() + spawner.gateway.is_slice_branch_merged_into_parent.side_effect = _merged_side_effect + exit_code, _ = _run_implement_phase_slices( + pipeline_id=pipeline.id, + pipeline=pipeline, + spawner=spawner, + repo_volumes={}, + gateway_mode="public", + repos=["owner/repo"], + sandbox_env={}, + store=MagicMock(), + certs_volume=None, + worktree_repo_path=Path("/tmp/x"), + ) + assert exit_code == 0 + # slice-1 detected as merged → not run; slice-2 runs normally. + invoked = {c.kwargs["slice_id"] for c in mock_run_phase.call_args_list} + assert invoked == {"slice-2"}, ( + "slice-1 must be skipped at bootstrap when origin shows it merged" + ) + # No agent spawn or PR creation for slice-1. + pr_slice_ids = [ + c.kwargs["slice_id"] for c in spawner.gateway.create_slice_pr.call_args_list + ] + assert "slice-1" not in pr_slice_ids + # Status persisted to contract so future restarts hit the cheap path. + assert slice1.status == SliceStatus.COMPLETE, ( + "step (B) must persist slice.status=COMPLETE so subsequent restarts " + "skip the GitHub round-trip" + ) + + def test_bootstrap_does_nothing_when_pipeline_repo_unset(self) -> None: + """No ``pipeline.repo`` (e.g. local-only test pipeline) → step + (B) is skipped (no remote to query). Step (A) still applies + because it's a pure contract read — covered here by a + ``status=COMPLETE`` slice that must be skipped without any + gateway round-trip.""" + pipeline = _make_pipeline() + pipeline.repo = None + # slice-1 was completed on a prior run (step (A) — contract + # already records COMPLETE). slice-2 still has work to do and + # must run; step (B) cannot help here because there's no + # remote to query. + slice1 = _make_slice("slice-1", tasks=[_make_task("task-1-1")]) + slice1.status = SliceStatus.COMPLETE + slice2 = _make_slice("slice-2", deps=["slice-1"], tasks=[_make_task("task-2-1")]) + contract = _make_contract(slices=[slice1, slice2]) + + with ( + patch("egg_contracts.loader.load_contract", return_value=contract), + patch("egg_contracts.loader.save_contract"), + patch("routes.pipelines._start_stacked_pr_reconciler") as mock_start_recon, + patch( + "routes.pipelines._run_concurrent_phase", return_value=(0, "ok") + ) as mock_run_phase, + patch("orchestrator.peer_consensus.remove_peer_consensus_tracker"), + ): + mock_start_recon.return_value = (MagicMock(), threading.Event()) + spawner = self._make_spawner() + spawner.gateway.is_slice_branch_merged_into_parent.return_value = True + _run_implement_phase_slices( + pipeline_id=pipeline.id, + pipeline=pipeline, + spawner=spawner, + repo_volumes={}, + gateway_mode="public", + repos=["owner/repo"], + sandbox_env={}, + store=MagicMock(), + certs_volume=None, + worktree_repo_path=Path("/tmp/x"), + ) + # Step (B) skipped wholesale when pipeline.repo is None — we + # have no remote to query against. + spawner.gateway.is_slice_branch_merged_into_parent.assert_not_called() + # Step (A) still applies: slice-1 (already COMPLETE on the + # contract) is skipped; slice-2 runs normally. + invoked = {c.kwargs["slice_id"] for c in mock_run_phase.call_args_list} + assert invoked == {"slice-2"}, ( + "step (A) must trust the contract even when pipeline.repo is None" + ) + + def test_run_loop_race_skip_when_slice_merges_after_bootstrap(self) -> None: + """Race: bootstrap saw slice as PENDING (not merged); slice's + PR merges before the wave runs. ``_run_one_slice_inner`` must + re-check before push and skip cleanly — no agent spawn, no + slice-PR creation, no integration-branch push.""" + pipeline = _make_pipeline() + slice1 = _make_slice("slice-1", tasks=[_make_task("task-1-1")]) + contract = _make_contract(slices=[slice1]) + + # First call (bootstrap): not merged. Second call (race + # protection in _run_one_slice_inner): merged. + merged_call_count = {"n": 0} + + def _merged_side_effect(*_args: Any, **_kwargs: Any) -> bool: + merged_call_count["n"] += 1 + return merged_call_count["n"] >= 2 + + with ( + patch("egg_contracts.loader.load_contract", return_value=contract), + patch("egg_contracts.loader.save_contract"), + patch("routes.pipelines._start_stacked_pr_reconciler") as mock_start_recon, + patch( + "routes.pipelines._run_concurrent_phase", return_value=(0, "ok") + ) as mock_run_phase, + patch("orchestrator.peer_consensus.remove_peer_consensus_tracker"), + ): + mock_start_recon.return_value = (MagicMock(), threading.Event()) + spawner = self._make_spawner() + spawner.gateway.is_slice_branch_merged_into_parent.side_effect = _merged_side_effect + exit_code, logs = _run_implement_phase_slices( + pipeline_id=pipeline.id, + pipeline=pipeline, + spawner=spawner, + repo_volumes={}, + gateway_mode="public", + repos=["owner/repo"], + sandbox_env={}, + store=MagicMock(), + certs_volume=None, + worktree_repo_path=Path("/tmp/x"), + ) + assert exit_code == 0 + # No agent spawn — race-protection caught it after bootstrap missed it. + mock_run_phase.assert_not_called() + # No integration-branch push, no slice-PR creation. + spawner.gateway.create_slice_integration_branch.assert_not_called() + spawner.gateway.create_slice_pr.assert_not_called() + # Sanity: detection helper was actually called twice + # (bootstrap + race protection). + assert merged_call_count["n"] >= 2 + + def test_successful_slice_persists_status_complete_to_contract(self) -> None: + """Once a slice reaches PR-creation success, its + ``status=COMPLETE`` must land on the contract. Subsequent + restarts then skip via step (A) without a GitHub round-trip.""" + pipeline = _make_pipeline() + slice1 = _make_slice("slice-1", tasks=[_make_task("task-1-1")]) + contract = _make_contract(slices=[slice1]) + + with ( + patch("egg_contracts.loader.load_contract", return_value=contract), + patch("egg_contracts.loader.save_contract"), + patch("routes.pipelines._start_stacked_pr_reconciler") as mock_start_recon, + patch("routes.pipelines._run_concurrent_phase", return_value=(0, "ok")), + patch("orchestrator.peer_consensus.remove_peer_consensus_tracker"), + ): + mock_start_recon.return_value = (MagicMock(), threading.Event()) + spawner = self._make_spawner() + exit_code, _ = _run_implement_phase_slices( + pipeline_id=pipeline.id, + pipeline=pipeline, + spawner=spawner, + repo_volumes={}, + gateway_mode="public", + repos=["owner/repo"], + sandbox_env={}, + store=MagicMock(), + certs_volume=None, + worktree_repo_path=Path("/tmp/x"), + ) + assert exit_code == 0 + assert slice1.status == SliceStatus.COMPLETE, ( + "successful slice run must persist status=COMPLETE on the contract — " + "the durable signal that lets future restarts skip via step (A)" + ) + + def test_bootstrap_detection_failure_falls_through(self) -> None: + """``is_slice_branch_merged_into_parent`` raising must not + block the run loop — it's best-effort. The slice runs + through the regular path and the orchestrator tolerates the + gateway transient.""" + pipeline = _make_pipeline() + slice1 = _make_slice("slice-1", tasks=[_make_task("task-1-1")]) + contract = _make_contract(slices=[slice1]) + + with ( + patch("egg_contracts.loader.load_contract", return_value=contract), + patch("egg_contracts.loader.save_contract"), + patch("routes.pipelines._start_stacked_pr_reconciler") as mock_start_recon, + patch( + "routes.pipelines._run_concurrent_phase", return_value=(0, "ok") + ) as mock_run_phase, + patch("orchestrator.peer_consensus.remove_peer_consensus_tracker"), + ): + mock_start_recon.return_value = (MagicMock(), threading.Event()) + spawner = self._make_spawner() + spawner.gateway.is_slice_branch_merged_into_parent.side_effect = RuntimeError( + "gateway transient" + ) + exit_code, _ = _run_implement_phase_slices( + pipeline_id=pipeline.id, + pipeline=pipeline, + spawner=spawner, + repo_volumes={}, + gateway_mode="public", + repos=["owner/repo"], + sandbox_env={}, + store=MagicMock(), + certs_volume=None, + worktree_repo_path=Path("/tmp/x"), + ) + assert exit_code == 0 + # Detection raised → bootstrap and race-protection both treat + # as not-merged → slice runs the regular path. + assert mock_run_phase.call_count == 1 + spawner.gateway.create_slice_pr.assert_called_once() + + # --------------------------------------------------------------------------- # Coder fixes for reviewer_code_holistic v1 NACK (now regression guards) # --------------------------------------------------------------------------- @@ -997,6 +1338,7 @@ def _track_create_pr(*args: Any, **kwargs: Any) -> str: side_effect=_track_create_branch ) spawner.gateway.create_slice_pr = MagicMock(side_effect=_track_create_pr) + spawner.gateway.is_slice_branch_merged_into_parent = MagicMock(return_value=False) _run_implement_phase_slices( pipeline_id=pipeline.id, @@ -1284,6 +1626,11 @@ def _make_spawner(self) -> MagicMock: spawner = MagicMock() spawner.gateway = MagicMock() spawner.gateway.create_slice_pr.return_value = "https://example/pr/1" + # #2549 — bootstrap reconciliation + run-loop race-protection both + # call this gateway helper. Default to False so existing tests + # exercise the spawn-and-run path; merged-detection tests set it + # to True explicitly. + spawner.gateway.is_slice_branch_merged_into_parent.return_value = False return spawner def test_release_called_on_consensus_path(self) -> None: @@ -1539,6 +1886,7 @@ def _track_run_phase(*args: Any, **kwargs: Any) -> tuple[int, str]: side_effect=_track_create_branch ) spawner.gateway.create_slice_pr = MagicMock(return_value="https://example/pr/1") + spawner.gateway.is_slice_branch_merged_into_parent = MagicMock(return_value=False) _run_implement_phase_slices( pipeline_id=pipeline.id, @@ -1652,6 +2000,7 @@ def _capture(*args: Any, **kwargs: Any) -> bool: spawner.gateway = MagicMock() spawner.gateway.create_slice_integration_branch = MagicMock(side_effect=_capture) spawner.gateway.create_slice_pr = MagicMock(return_value="https://example/pr/1") + spawner.gateway.is_slice_branch_merged_into_parent = MagicMock(return_value=False) _run_implement_phase_slices( pipeline_id=pipeline.id, diff --git a/pyproject.toml b/pyproject.toml index 2b66658854..1f0d1521fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -213,11 +213,8 @@ addopts = "-v --tb=short" timeout = 60 filterwarnings = ["ignore::DeprecationWarning"] markers = [ - "integration: marks tests as integration tests (require Docker, may be slow)", - "functional: marks tests as functional tests (require Docker, faster than integration)", - "e2e: marks tests as end-to-end tests (require real API keys)", + "integration: marks tests as integration tests (require k3s, may be slow)", "security: marks tests as security/pentesting tests", - "agent_flaky: marks tests with non-deterministic agent behavior (non-blocking in CI)", ] [build-system] diff --git a/sandbox/egg_agent_tools/handlers/brc.py b/sandbox/egg_agent_tools/handlers/brc.py index ec336e2c33..2473f1d943 100644 --- a/sandbox/egg_agent_tools/handlers/brc.py +++ b/sandbox/egg_agent_tools/handlers/brc.py @@ -14,6 +14,7 @@ get_agent_role, get_pipeline_id, orchestrator_request, + resolve_slice_id, ) from egg_agent_tools.handlers._gateway import maybe_attach_slice_id as _maybe_attach_slice_id from egg_agent_tools.handlers.errors import GatewayError, HandlerError @@ -804,9 +805,16 @@ def brc_resolve_obligation(req: dict[str, Any]) -> dict[str, Any]: "CONSENSUS_PROPOSE", "CONSENSUS_ACK", "CONSENSUS_NACK", + "CONSENSUS_WITHDRAW", "CONSENSUS_CONFIRMED", "CONSENSUS_RE_REVIEW", - "CONSENSUS_WITHDRAWN", + "CONSENSUS_OBLIGATION_RESOLVED", + "STATUS", + "HANDOFF", + "AGENT_FAILED", + "NUDGE", + "OVERSEER_ALERT", + "HEARTBEAT", } ) @@ -878,15 +886,34 @@ def brc_read_peer_artifact(req: dict[str, Any]) -> dict[str, Any]: """Read consensus history for a peer from the local brc-history log. No CLI counterpart (decision-8): reads from the local - ``.egg-state/brc-history/-.json`` file - written by ``orchestrator.routes.pipelines._write_brc_history`` - so reviewers never have to hand-grep JSON off disk. + ``.egg-state/brc-history/`` files written by + ``orchestrator.routes.pipelines._write_brc_history`` so reviewers + never have to hand-grep JSON off disk. + + File resolution mirrors the writer's per-slice partition (#2548): + + * ``phase ∈ {refine, plan, pr}`` — reads the aggregate + ``{identifier}-{phase}.json`` file. + * ``phase == "implement"`` and ``EGG_SLICE_ID`` is set — reads the + per-slice ``{identifier}-implement-{slice_id}.json`` file. If a + sibling ``{identifier}-implement-unattributed.json`` exists + (cross-cutting messages without slice scope: HEARTBEAT, + OVERSEER_ALERT, AGENT_FAILED, etc.), its records are merged into + the response and re-sorted by timestamp so reviewers see the + slice transcript and the cross-cutting context interleaved. + Pass ``include_unattributed=False`` to skip the merge. + * ``phase == "implement"`` and ``EGG_SLICE_ID`` is unset — reads + the aggregate ``{identifier}-implement.json`` file (babysit_pr + and other non-slice pipelines). Security: caller-supplied ``pipeline_id``/``issue``/``repo_path`` are ignored; the identifier and repo root are resolved server-side from ``EGG_PIPELINE_ID`` / ``EGG_ISSUE_NUMBER`` / ``EGG_REPO_PATH`` - (risk_analyst R2 + reviewer_code NACK #1). The resolved file path - is canonicalised and asserted to sit under + (risk_analyst R2 + reviewer_code NACK #1). ``EGG_SLICE_ID`` is + validated against the canonical ``slice-`` regex before being + interpolated into the filename — same defense-in-depth as the + writer-side seam (`orchestrator/routes/pipelines.py` ~line 8406). + The resolved file path is canonicalised and asserted to sit under ``/.egg-state/brc-history/``; anything else raises ``HandlerError``. ``peer_role`` must match ``[a-z0-9_-]``. @@ -900,6 +927,11 @@ def brc_read_peer_artifact(req: dict[str, Any]) -> dict[str, Any]: ``message_type``; accepts a single value or a list. limit (int): optional page size (default 50, max 500). cursor (str): opaque pagination token. + include_unattributed (bool): optional, default ``True``. When + reading a slice-scoped implement transcript, also merge + records from the sibling + ``{identifier}-implement-unattributed.json`` file. Set + ``False`` to read only the per-slice file. Response: { ok: True, phase: str, items: [...], next_cursor: str|None, @@ -954,6 +986,14 @@ def brc_read_peer_artifact(req: dict[str, Any]) -> dict[str, Any]: if limit > 500: raise HandlerError("'limit' must be <= 500") + raw_include = req.get("include_unattributed") + if raw_include is None: + include_unattributed = True + elif isinstance(raw_include, bool): + include_unattributed = raw_include + else: + raise HandlerError("'include_unattributed' must be a boolean if provided") + cursor_state = _decode_cursor(req.get("cursor")) offset = cursor_state["offset"] prior_skipped = cursor_state["skipped_malformed"] @@ -961,13 +1001,60 @@ def brc_read_peer_artifact(req: dict[str, Any]) -> dict[str, Any]: identifier = _resolve_env_identifier_for_brc_history() repo_root = Path(os.environ.get("EGG_REPO_PATH") or os.getcwd()).resolve() history_dir = (repo_root / ".egg-state" / "brc-history").resolve() - history_file = (history_dir / f"{identifier}-{phase}.json").resolve() - # Containment check: catches symlinks / .. in identifier/phase that - # escape the allowed directory even after the env-only resolution. - if not history_file.is_relative_to(history_dir): - raise HandlerError("Resolved brc-history path escapes .egg-state/brc-history/") - if not history_file.exists(): + # Mirror the writer's per-slice partition for the implement phase + # (#2548). When EGG_SLICE_ID is set and phase=="implement" we read + # the slice's transcript file and (by default) merge in the + # cross-cutting `unattributed` sibling. Other phases and non-slice + # implement runs read the aggregate file. + history_files: list[Path] = [] + if phase == "implement": + # Defense-in-depth via the public _gateway helper: resolves + # EGG_SLICE_ID, validates against the canonical `^slice-$` + # regex (same seam the orchestrator writer enforces at + # `pipelines.py` ~8406), and raises HandlerError on malformed + # values before we interpolate into the filename. Pass `{}` so + # caller-supplied `slice_id` is ignored — slice scope is an + # env-only signal here for the same cross-pipeline-read + # hardening as `_resolve_env_identifier_for_brc_history`. + slice_id_env = resolve_slice_id({}) + if slice_id_env is not None: + slice_file = (history_dir / f"{identifier}-implement-{slice_id_env}.json").resolve() + history_files.append(slice_file) + if include_unattributed: + unattr_file = (history_dir / f"{identifier}-implement-unattributed.json").resolve() + history_files.append(unattr_file) + else: + history_files.append((history_dir / f"{identifier}-{phase}.json").resolve()) + else: + history_files.append((history_dir / f"{identifier}-{phase}.json").resolve()) + + # Containment check on every resolved path: catches symlinks / .. in + # identifier/phase/slice_id that escape the allowed directory even + # after the env-only resolution and SLICE_ID_PATTERN validation. + for hf in history_files: + if not hf.is_relative_to(history_dir): + raise HandlerError("Resolved brc-history path escapes .egg-state/brc-history/") + + records: list[Any] = [] + any_existed = False + for hf in history_files: + if not hf.exists(): + continue + any_existed = True + try: + chunk = json.loads(hf.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise HandlerError( + f"Failed to read brc-history file for phase {phase!r}: {exc}" + ) from exc + if not isinstance(chunk, list): + raise HandlerError( + f"Malformed brc-history file for phase {phase!r}: expected a JSON array" + ) + records.extend(chunk) + + if not any_existed: return { "ok": True, "phase": phase, @@ -977,13 +1064,6 @@ def brc_read_peer_artifact(req: dict[str, Any]) -> dict[str, Any]: "skipped_malformed": prior_skipped, } - try: - records = json.loads(history_file.read_text()) - except (OSError, json.JSONDecodeError) as exc: - raise HandlerError(f"Failed to read brc-history file for phase {phase!r}: {exc}") from exc - if not isinstance(records, list): - raise HandlerError(f"Malformed brc-history file for phase {phase!r}: expected a JSON array") - filtered: list[dict[str, Any]] = [] skipped_malformed = 0 for rec in records: @@ -996,6 +1076,12 @@ def brc_read_peer_artifact(req: dict[str, Any]) -> dict[str, Any]: continue filtered.append(rec) + # Re-sort merged records by timestamp so the per-slice transcript + # and the unattributed sibling interleave chronologically. Records + # without a timestamp sort last, in original order (stable sort). + if len(history_files) > 1: + filtered.sort(key=lambda r: (r.get("timestamp") is None, r.get("timestamp") or "")) + total = len(filtered) total_skipped = prior_skipped + skipped_malformed if offset >= total: diff --git a/sandbox/egg_agent_tools/handlers/restrictions.py b/sandbox/egg_agent_tools/handlers/restrictions.py new file mode 100644 index 0000000000..715341b747 --- /dev/null +++ b/sandbox/egg_agent_tools/handlers/restrictions.py @@ -0,0 +1,331 @@ +"""File-restriction self-check + impasse reporting handlers (#2529). + +Two cheap handlers an agent calls when its assigned task looks +structurally impossible: + +- ``check_file_restriction(req)`` — pure local read against + ``shared/egg_restrictions/patterns.py``. No gateway round-trip; the + pattern registry is statically resolvable inside the sandbox image. +- ``report_impasse(req)`` — persists a typed + :class:`egg_contracts.Impasse` under ``AgentOutput.impasse`` (the + same JSON file used for ``handoff_data`` today). The orchestrator + scans for impasses post-phase and routes — see + ``orchestrator/impasse_routing.py``. + +The agent should call ``check_file_restriction`` *before* burning +tokens on exploration, and ``report_impasse`` once it has decided the +task is impossible — never together, and never alongside a code +commit. Once impasse is reported the agent should exit cleanly; the +orchestrator will either delegate the task to ``suggested_role`` or +escalate to HITL. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from egg_agent_tools.handlers._gateway import ( + get_agent_role, + get_contract_identifier, + get_repo_path, +) +from egg_agent_tools.handlers.errors import HandlerError + +_VALID_CATEGORIES = {"wrong_role", "plan_bug", "external_blocker", "unknown"} + + +def _load_pattern_registry() -> dict[str, Any]: + """Lazily import the pattern registry so host-side tests don't drag + the whole sandbox-Python tree on every module load.""" + from egg_restrictions.patterns import AGENT_PATTERNS + + return AGENT_PATTERNS + + +def _alternative_role(blocked_role: str, file_path: str) -> str | None: + """Return the single producer role that *can* write ``file_path`` if + one exists, else ``None``. + + Limited to the producer trio (``coder``/``tester``/``documenter``) — + cross-phase roles like ``overseer`` or ``conflict_resolver`` are not + valid suggestions for an impasse delegation. + """ + registry = _load_pattern_registry() + candidates: list[str] = [] + for role in ("coder", "tester", "documenter"): + if role == blocked_role: + continue + pattern = registry.get(role) + if pattern is None: + continue + if pattern.can_write(file_path): + candidates.append(role) + if len(candidates) == 1: + return candidates[0] + return None + + +def check_file_restriction(req: dict[str, Any]) -> dict[str, Any]: + """Check whether the named role can write the named path(s). + + Pure read against the pattern registry — does not mutate state and + does not call the gateway. Used by the agent before deciding to + explore a file or hand off the task. + + No CLI counterpart: pattern matching is pure CPU and the registry + ships in the sandbox image; a CLI shim would just shell out to + re-import the same module. Decision-13 rationale. + + Request: + path (str | list[str]): a single path or a list. Required. + role (str): role to check. Defaults to ``EGG_AGENT_ROLE``. + + Response (single path): + { + ok: True, + role: "coder", + path: "tests/test_x.py", + can_write: False, + reason: "matches blocked pattern '**/test_*.py'", + alternative_role: "tester", + } + + Response (list of paths): + { + ok: True, + role: "coder", + results: [ + {path, can_write, reason, alternative_role}, ... + ], + } + + ``alternative_role`` is populated only when exactly one producer + role (other than the queried one) can write the path. Multi-role + or no-role coverage returns ``None`` — the agent should treat that + as "ask for HITL", not "guess a role". + """ + raw_path = req.get("path") + if raw_path is None: + raise HandlerError("'path' is required (string or list of strings)") + + role = req.get("role") or get_agent_role() + if not role: + raise HandlerError("'role' required. Set EGG_AGENT_ROLE or pass 'role' explicitly.") + + registry = _load_pattern_registry() + pattern = registry.get(role) + if pattern is None: + raise HandlerError(f"Unknown role {role!r}. Known roles: {sorted(registry.keys())}") + + def _check_one(path: str) -> dict[str, Any]: + can_write = pattern.can_write(path) + if can_write: + return { + "path": path, + "can_write": True, + "reason": "matches an allowed pattern", + "alternative_role": None, + } + return { + "path": path, + "can_write": False, + "reason": ( + f"role {role!r} is blocked from {path!r} by shared/egg_restrictions/patterns.py" + ), + "alternative_role": _alternative_role(role, path), + } + + if isinstance(raw_path, list): + if not raw_path: + raise HandlerError("'path' list cannot be empty") + results = [] + for entry in raw_path: + if not isinstance(entry, str) or not entry: + raise HandlerError("'path' list entries must be non-empty strings") + results.append(_check_one(entry)) + return {"ok": True, "role": role, "results": results} + + if not isinstance(raw_path, str) or not raw_path: + raise HandlerError("'path' must be a non-empty string or list") + + single = _check_one(raw_path) + return {"ok": True, "role": role, **single} + + +def report_impasse(req: dict[str, Any]) -> dict[str, Any]: + """Persist a typed :class:`egg_contracts.Impasse` to the agent's + output file so the orchestrator can route post-phase. + + No CLI counterpart: this is a structured runtime signal that lives + inside the agent-output JSON the orchestrator already collects; + introducing a parallel CLI surface would just create a second + write path that could drift from the MCP one. Decision-13 + rationale. + + Request: + category (str): one of ``wrong_role`` / ``plan_bug`` / + ``external_blocker`` / ``unknown``. Required. + reason (str): human-readable explanation. Required. + task_id (str): contract task ID, e.g. ``task-1-3``. Optional; + the orchestrator infers it from the slice's task list when + omitted. + suggested_role (str): for ``wrong_role`` only — the producer + role that *can* write the blocked files. Use the + ``alternative_role`` returned by + :func:`check_file_restriction`. + blocked_files (list[str]): files the assigned role cannot + write. Optional but recommended for ``wrong_role``. + evidence (dict): free-form structured evidence to surface in + the HITL decision body. Optional. + role (str): override (defaults to ``EGG_AGENT_ROLE``). + identifier / repo_path: optional overrides. + + Response: + { + ok: True, + written_to: "", + category: "...", + suggested_role: "...", + guidance: "Stop work and exit. Do not commit code; the " + "orchestrator will route this impasse " + "post-phase.", + } + + The handler does not touch the contract — the orchestrator owns + role-flips and the ``delegation_attempts`` counter. The agent + should not call any other producer tool after this returns. + """ + category = req.get("category") + if not category or not isinstance(category, str): + raise HandlerError(f"'category' is required: one of {sorted(_VALID_CATEGORIES)}") + if category not in _VALID_CATEGORIES: + raise HandlerError( + f"Unknown category {category!r}. Expected one of {sorted(_VALID_CATEGORIES)}" + ) + + reason = req.get("reason") + if not reason or not isinstance(reason, str): + raise HandlerError("'reason' is required (non-empty string)") + + role = req.get("role") or get_agent_role() + if not role: + raise HandlerError("'role' required. Set EGG_AGENT_ROLE or pass 'role' explicitly.") + + suggested_role = req.get("suggested_role") + if suggested_role is not None and not isinstance(suggested_role, str): + raise HandlerError("'suggested_role' must be a string when provided") + if category == "wrong_role": + # ``wrong_role`` is the only auto-delegateable category; without + # ``suggested_role`` the orchestrator-side router can only + # escalate to HITL, which silently degrades the producer's + # deliberately-set ``category=wrong_role`` signal into + # "always-escalate". Reject at the handler boundary and point + # the agent at ``check_file_restriction`` so the fix lands in + # the same iteration. + if not suggested_role: + raise HandlerError( + "'suggested_role' is required for category='wrong_role'. " + "Call mcp__sdlc__check_file_restriction first to discover " + "the producer role that *can* write the blocked files, " + "then pass it as suggested_role. Use category='unknown' " + "if no single producer role covers the impasse." + ) + if suggested_role == role: + raise HandlerError( + "'suggested_role' must differ from the impassed role " + f"({role!r}); a wrong_role impasse cannot delegate to itself." + ) + + blocked_files = req.get("blocked_files") or [] + if not isinstance(blocked_files, list) or not all( + isinstance(f, str) and f for f in blocked_files + ): + raise HandlerError("'blocked_files' must be a list of non-empty strings") + + evidence = req.get("evidence") or {} + if not isinstance(evidence, dict): + raise HandlerError("'evidence' must be a dict when provided") + + task_id = req.get("task_id") + if task_id is not None and not isinstance(task_id, str): + raise HandlerError("'task_id' must be a string when provided") + # ``wrong_role`` triggers an auto-delegation against a specific + # task — the role-match fallback in the orchestrator-side router + # is fragile when a slice contains multiple tasks per role or + # role-less tasks. Require an explicit task_id so the routing + # never has to guess. Other categories (plan_bug, + # external_blocker, unknown) escalate either way and tolerate + # task-level ambiguity. + if category == "wrong_role" and not task_id: + raise HandlerError( + "'task_id' is required for category='wrong_role' so the " + "orchestrator can route precisely. Look it up in your " + "spawn prompt or via `egg-contract show`." + ) + + repo_path = Path(req.get("repo_path") or get_repo_path()) + identifier = req.get("identifier") or req.get("issue") or req.get("pipeline_id") + if identifier is None: + identifier = get_contract_identifier() + # ``identifier`` may legitimately be None for ad-hoc / standalone + # agents — ``save_agent_output`` falls back to the unprefixed path + # in that case. + + impasse_payload: dict[str, Any] = { + "category": category, + "reason": reason, + "task_id": task_id, + "suggested_role": suggested_role, + "blocked_files": list(blocked_files), + "evidence": dict(evidence), + "created_at": datetime.now(UTC).isoformat(), + } + + # Load any existing output for this role/identifier so we don't + # clobber handoff_data, files_changed, etc. Falls back to a fresh + # dict when the file doesn't exist yet (the common case — the + # agent typically calls report_impasse before any handoff write). + from egg_contracts.agent_roles import AgentRole as ContractAgentRole + from egg_contracts.orchestrator import ( + load_agent_output, + save_agent_output, + ) + + try: + contract_role = ContractAgentRole(role) + except ValueError as exc: + raise HandlerError( + f"Role {role!r} is not a known contract AgentRole; cannot " + "persist impasse to the role-keyed agent-output file." + ) from exc + + existing = load_agent_output(repo_path, contract_role, identifier=identifier) + if not isinstance(existing, dict): + existing = {} + + output: dict[str, Any] = dict(existing) + output["role"] = role + output["impasse"] = impasse_payload + # Preserve the timestamp shape that AgentOutput.from_dict expects + # so the orchestrator-side loader doesn't synthesise a different + # one on its read. + output.setdefault("timestamp", datetime.now(UTC).isoformat()) + + written_path = save_agent_output(repo_path, contract_role, output, identifier=identifier) + + return { + "ok": True, + "written_to": str(written_path), + "category": category, + "suggested_role": suggested_role, + "task_id": task_id, + "guidance": ( + "Impasse recorded. Stop all further work for this task and " + "exit cleanly. Do not commit code or invent a workaround — " + "the orchestrator will read this signal post-phase and " + "either delegate to suggested_role (first attempt) or " + "escalate to HITL (second attempt or no eligible role)." + ), + } diff --git a/sandbox/egg_agent_tools/tools/brc.py b/sandbox/egg_agent_tools/tools/brc.py index f765ffe558..b8d72183be 100644 --- a/sandbox/egg_agent_tools/tools/brc.py +++ b/sandbox/egg_agent_tools/tools/brc.py @@ -260,7 +260,10 @@ "description": ( "Optional message_type filter; accepts a single type or a " "list (CONSENSUS_PROPOSE, CONSENSUS_ACK, CONSENSUS_NACK, " - "CONSENSUS_CONFIRMED, CONSENSUS_RE_REVIEW, CONSENSUS_WITHDRAWN)" + "CONSENSUS_WITHDRAW, CONSENSUS_CONFIRMED, " + "CONSENSUS_RE_REVIEW, CONSENSUS_OBLIGATION_RESOLVED, " + "STATUS, HANDOFF, AGENT_FAILED, NUDGE, OVERSEER_ALERT, " + "HEARTBEAT)" ), }, "limit": { @@ -272,6 +275,18 @@ "type": "string", "description": "Opaque pagination token returned by a prior call", }, + "include_unattributed": { + "type": "boolean", + "default": True, + "description": ( + "When reading a slice-scoped implement transcript " + "(EGG_SLICE_ID set + phase='implement'), also merge " + "records from the sibling " + "-implement-unattributed.json file " + "(cross-cutting messages without slice scope). " + "Default true; set false to read only the per-slice file." + ), + }, }, "required": ["phase"], "additionalProperties": False, diff --git a/sandbox/egg_agent_tools/tools/sdlc.py b/sandbox/egg_agent_tools/tools/sdlc.py index 19da8f23fe..837271ec62 100644 --- a/sandbox/egg_agent_tools/tools/sdlc.py +++ b/sandbox/egg_agent_tools/tools/sdlc.py @@ -4,6 +4,7 @@ from typing import Any +from egg_agent_tools.handlers import restrictions as restriction_handlers from egg_agent_tools.handlers import sdlc as handlers from egg_agent_tools.tools._common import invoke_handler from egg_agent_tools.tools._tool_compat import tool @@ -109,6 +110,84 @@ "required": ["criterion"], } +_CHECK_FILE_RESTRICTION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "path": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}, "minItems": 1}, + ], + "description": ( + "Path (or list of paths) to check against the role's " + "file-write restrictions in shared/egg_restrictions/" + "patterns.py." + ), + }, + "role": { + "type": "string", + "description": ( + "Role to check (defaults to EGG_AGENT_ROLE). Typically " + "left unset so the agent checks itself." + ), + }, + }, + "required": ["path"], +} + +_REPORT_IMPASSE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["wrong_role", "plan_bug", "external_blocker", "unknown"], + "description": ( + "Why the task is impossible. ``wrong_role`` triggers " + "auto-delegation; the others always escalate to HITL." + ), + }, + "reason": { + "type": "string", + "description": ( + "Human-readable explanation surfaced verbatim in the " + "HITL decision and structured logs." + ), + }, + "task_id": { + "type": "string", + "description": ( + "Contract task ID, e.g. ``task-1-3``. Optional — the " + "orchestrator infers it when omitted." + ), + }, + "suggested_role": { + "type": "string", + "description": ( + "For ``wrong_role`` only: the producer role that *can* " + "write the blocked files. Use the ``alternative_role`` " + "returned by check_file_restriction." + ), + }, + "blocked_files": { + "type": "array", + "items": {"type": "string"}, + "description": "Files the assigned role cannot write.", + }, + "evidence": { + "type": "object", + "description": ( + "Free-form structured evidence (error messages, " + "tool outputs) surfaced in the HITL decision body." + ), + }, + "role": {"type": "string"}, + "issue": {"type": "integer"}, + "pipeline_id": {"type": "string"}, + "repo_path": {"type": "string"}, + }, + "required": ["category", "reason"], +} + @tool( "register_open_question", @@ -164,6 +243,35 @@ async def verify_criterion(args: dict[str, Any]) -> dict[str, Any]: return await invoke_handler(handlers.verify_criterion, args) +@tool( + "check_file_restriction", + "Check whether the named role can write the named path(s) per " + "shared/egg_restrictions/patterns.py. Read-only; no gateway round-trip. " + "Use this BEFORE exploring a file you suspect is outside your role's " + "boundary so you can hand off cleanly instead of building a workaround. " + "Returns can_write + alternative_role (the role that *can* write the " + "path, when exactly one producer role covers it).", + _CHECK_FILE_RESTRICTION_SCHEMA, +) +async def check_file_restriction(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(restriction_handlers.check_file_restriction, args) + + +@tool( + "report_impasse", + "Persist a typed Impasse signal stating that the assigned task is " + "structurally impossible (file restrictions, plan bug, external " + "blocker). The orchestrator detects the impasse post-phase and either " + "delegates to suggested_role (first attempt) or escalates to HITL " + "(second attempt or no eligible role). Emit this INSTEAD of inventing " + "file-staging workarounds. After calling, stop work and exit cleanly " + "without committing.", + _REPORT_IMPASSE_SCHEMA, +) +async def report_impasse(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(restriction_handlers.report_impasse, args) + + from egg_agent_tools.tools._registry import ToolRegistration # noqa: E402,I001 REGISTRATIONS: list[ToolRegistration] = [ @@ -202,4 +310,18 @@ async def verify_criterion(args: dict[str, Any]) -> dict[str, Any]: sdk_tool=verify_criterion, cli_command=("egg-contract", "verify-criterion"), ), + ToolRegistration( + name="mcp__sdlc__check_file_restriction", + namespace=NAMESPACE, + handler=restriction_handlers.check_file_restriction, + sdk_tool=check_file_restriction, + cli_command=None, + ), + ToolRegistration( + name="mcp__sdlc__report_impasse", + namespace=NAMESPACE, + handler=restriction_handlers.report_impasse, + sdk_tool=report_impasse, + cli_command=None, + ), ] diff --git a/sandbox/tests/test_restrictions_handlers.py b/sandbox/tests/test_restrictions_handlers.py new file mode 100644 index 0000000000..259c37df72 --- /dev/null +++ b/sandbox/tests/test_restrictions_handlers.py @@ -0,0 +1,235 @@ +"""Tests for the runtime escape-hatch handlers (#2529). + +Covers ``check_file_restriction`` (pure local read) and +``report_impasse`` (writes typed Impasse to the role's agent-output +file). Both back the ``mcp__sdlc__check_file_restriction`` and +``mcp__sdlc__report_impasse`` tools registered in +``sandbox/egg_agent_tools/tools/sdlc.py``. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +# The host-side test harness mirrors the sandbox-image layout via +# PYTHONPATH; insert here for IDE / pytest -m runs that don't go +# through the Makefile. +_SANDBOX_DIR = Path(__file__).resolve().parent.parent +_SHARED_DIR = _SANDBOX_DIR.parent / "shared" +for p in (_SHARED_DIR, _SANDBOX_DIR): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +from egg_agent_tools.handlers import restrictions # noqa: E402 +from egg_agent_tools.handlers.errors import HandlerError # noqa: E402 + + +@pytest.fixture(autouse=True) +def _set_role(monkeypatch): + monkeypatch.setenv("EGG_AGENT_ROLE", "coder") + monkeypatch.delenv("EGG_PIPELINE_ID", raising=False) + monkeypatch.delenv("EGG_ISSUE_NUMBER", raising=False) + + +class TestCheckFileRestriction: + def test_blocked_path_for_coder(self): + out = restrictions.check_file_restriction({"path": "tests/test_x.py"}) + assert out["ok"] is True + assert out["role"] == "coder" + assert out["can_write"] is False + assert out["alternative_role"] == "tester" + assert "blocked" in out["reason"] + + def test_allowed_path_for_coder(self): + out = restrictions.check_file_restriction({"path": "orchestrator/routes/pipelines.py"}) + assert out["can_write"] is True + assert out["alternative_role"] is None + + def test_github_path_no_alternative(self): + # `.github/` is hard-blocked for every producer (#2508), so + # alternative_role MUST be None — we don't want to misroute + # the agent into a follow-on impasse. + out = restrictions.check_file_restriction({"path": ".github/workflows/ci.yml"}) + assert out["can_write"] is False + assert out["alternative_role"] is None + + def test_batch_form(self): + out = restrictions.check_file_restriction({"path": ["tests/test_x.py", "src/app.py"]}) + assert out["ok"] is True + assert len(out["results"]) == 2 + blocked = [r for r in out["results"] if not r["can_write"]] + allowed = [r for r in out["results"] if r["can_write"]] + assert len(blocked) == 1 and blocked[0]["alternative_role"] == "tester" + assert len(allowed) == 1 + + def test_explicit_role_override(self): + # Tester should be allowed to write conftest.py. + out = restrictions.check_file_restriction({"path": "tests/conftest.py", "role": "tester"}) + assert out["role"] == "tester" + assert out["can_write"] is True + + def test_unknown_role_raises(self): + with pytest.raises(HandlerError): + restrictions.check_file_restriction({"path": "x.py", "role": "wizard"}) + + def test_empty_path_raises(self): + with pytest.raises(HandlerError): + restrictions.check_file_restriction({"path": ""}) + + def test_empty_list_raises(self): + with pytest.raises(HandlerError): + restrictions.check_file_restriction({"path": []}) + + def test_no_role_no_env_raises(self, monkeypatch): + monkeypatch.delenv("EGG_AGENT_ROLE", raising=False) + with pytest.raises(HandlerError): + restrictions.check_file_restriction({"path": "x.py"}) + + +class TestReportImpasse: + def test_persists_to_agent_output(self, tmp_path, monkeypatch): + monkeypatch.setenv("EGG_REPO_PATH", str(tmp_path)) + monkeypatch.setenv("EGG_PIPELINE_ID", "pid-42") + + out = restrictions.report_impasse( + { + "category": "wrong_role", + "reason": "task lists tests/conftest.py but coder cannot write it", + "task_id": "task-1-1", + "suggested_role": "tester", + "blocked_files": ["tests/conftest.py"], + "evidence": {"detected_by": "check_file_restriction"}, + } + ) + assert out["ok"] is True + assert out["category"] == "wrong_role" + assert out["suggested_role"] == "tester" + assert "Stop all further work" in out["guidance"] + + on_disk = Path(out["written_to"]) + assert on_disk.exists() + data = json.loads(on_disk.read_text()) + assert data["impasse"]["category"] == "wrong_role" + assert data["impasse"]["suggested_role"] == "tester" + assert data["impasse"]["task_id"] == "task-1-1" + assert data["impasse"]["blocked_files"] == ["tests/conftest.py"] + + def test_preserves_pre_existing_handoff_data(self, tmp_path, monkeypatch): + monkeypatch.setenv("EGG_REPO_PATH", str(tmp_path)) + monkeypatch.setenv("EGG_PIPELINE_ID", "pid-99") + + # Simulate a prior write of handoff_data on the same role-keyed + # output file (the common case is that report_impasse comes + # before any handoff write, but we shouldn't clobber if not). + outputs_dir = tmp_path / ".egg-state" / "agent-outputs" + outputs_dir.mkdir(parents=True) + existing = outputs_dir / "pid-99-coder-output.json" + existing.write_text(json.dumps({"role": "coder", "handoff_data": {"foo": "bar"}})) + + restrictions.report_impasse( + { + "category": "plan_bug", + "reason": "task contradicts itself", + } + ) + data = json.loads(existing.read_text()) + assert data["handoff_data"] == {"foo": "bar"} + assert data["impasse"]["category"] == "plan_bug" + + def test_self_delegation_rejected(self, tmp_path, monkeypatch): + monkeypatch.setenv("EGG_REPO_PATH", str(tmp_path)) + monkeypatch.setenv("EGG_PIPELINE_ID", "pid") + + with pytest.raises(HandlerError, match="cannot delegate to itself"): + restrictions.report_impasse( + { + "category": "wrong_role", + "reason": "x", + "suggested_role": "coder", + } + ) + + def test_unknown_category_rejected(self): + with pytest.raises(HandlerError, match="Unknown category"): + restrictions.report_impasse({"category": "wat", "reason": "x"}) + + def test_empty_reason_rejected(self): + with pytest.raises(HandlerError, match="reason"): + restrictions.report_impasse({"category": "wrong_role", "reason": ""}) + + def test_blocked_files_must_be_strings(self, tmp_path, monkeypatch): + monkeypatch.setenv("EGG_REPO_PATH", str(tmp_path)) + monkeypatch.setenv("EGG_PIPELINE_ID", "pid") + with pytest.raises(HandlerError, match="blocked_files"): + restrictions.report_impasse( + { + "category": "wrong_role", + "reason": "x", + "task_id": "task-1-1", + "suggested_role": "tester", + "blocked_files": [1, 2], + } + ) + + def test_wrong_role_without_suggested_role_rejected(self, tmp_path, monkeypatch): + # ``wrong_role`` is the auto-delegateable category; without + # suggested_role the orchestrator-side router can only escalate + # to HITL, which silently degrades the producer's deliberately + # set wrong_role signal. The handler boundary should reject + # the call so the agent fixes the input in the same iteration. + monkeypatch.setenv("EGG_REPO_PATH", str(tmp_path)) + monkeypatch.setenv("EGG_PIPELINE_ID", "pid") + with pytest.raises(HandlerError, match="suggested_role.* is required"): + restrictions.report_impasse( + { + "category": "wrong_role", + "reason": "cannot write tests/conftest.py", + "task_id": "task-1-1", + "blocked_files": ["tests/conftest.py"], + } + ) + + def test_wrong_role_without_task_id_rejected(self, tmp_path, monkeypatch): + # Same defense-in-depth motivation — without task_id the + # router's role-match fallback is fragile (multiple tasks per + # role, role-less tasks). Require explicit task_id at the + # handler boundary so routing never has to guess. + monkeypatch.setenv("EGG_REPO_PATH", str(tmp_path)) + monkeypatch.setenv("EGG_PIPELINE_ID", "pid") + with pytest.raises(HandlerError, match="task_id.* is required"): + restrictions.report_impasse( + { + "category": "wrong_role", + "reason": "cannot write tests/conftest.py", + "suggested_role": "tester", + "blocked_files": ["tests/conftest.py"], + } + ) + + def test_plan_bug_without_task_id_accepted(self, tmp_path, monkeypatch): + # Non-wrong_role categories don't auto-delegate, so the + # task-level ambiguity that breaks the role-match fallback + # never matters — keep these accepted without task_id so an + # agent can flag a plan-wide impasse. + monkeypatch.setenv("EGG_REPO_PATH", str(tmp_path)) + monkeypatch.setenv("EGG_PIPELINE_ID", "pid") + out = restrictions.report_impasse( + { + "category": "plan_bug", + "reason": "two slices contradict each other on schema shape", + } + ) + assert out["ok"] is True + + +class TestToolRegistration: + def test_mcp_names_registered(self): + from egg_agent_tools.tools.sdlc import REGISTRATIONS + + names = {r.name for r in REGISTRATIONS} + assert "mcp__sdlc__check_file_restriction" in names + assert "mcp__sdlc__report_impasse" in names diff --git a/scripts/check-hardcoded-ports.py b/scripts/check-hardcoded-ports.py index ca3e831776..538bce330f 100644 --- a/scripts/check-hardcoded-ports.py +++ b/scripts/check-hardcoded-ports.py @@ -48,9 +48,8 @@ # Docker compose files "docker-compose.yml", "docker-compose.yaml", - # Integration test infrastructure (compose files, conftest, network tests) + # Integration test infrastructure (conftest, network tests) "integration_tests/conftest.py", - "integration_tests/docker-compose.yml", "integration_tests/local_pipeline/", "integration_tests/test_network_", # CI/CD workflows (YAML cannot import Python) @@ -63,7 +62,6 @@ "tests/fixtures/", # Test files that validate config defaults or use hardcoded values in assertions "tests/egg_config/test_configs.py", - "tests/functional/conftest.py", "tests/shared/egg_container/test_build_cmd.py", "tests/shared/egg_container/test_config_builder.py", "tests/shared/egg_contracts/test_checkpoint_cli_http.py", diff --git a/scripts/file-size-allowlist.yaml b/scripts/file-size-allowlist.yaml index ab0029a198..5efff26f1e 100644 --- a/scripts/file-size-allowlist.yaml +++ b/scripts/file-size-allowlist.yaml @@ -50,3 +50,12 @@ files: issue: "2248" orchestrator/kubernetes_spawner.py: issue: "2248" + # On the egg/issue-2548/work merge target, slice-1's + # extract_pr_context_metadata_from_yaml + ParseResult.pr_context_* + # plumbing (#2548) stacks on top of #2527's validate_task_role_alignment + # additions, pushing the file to ~1,530 lines. The slice-1 branch alone + # is 1,388 lines (under the 1,500-line hard cap), but the merged + # work-branch state breaches the cap. Allowlisting under #2548 so the + # BRC implement-phase lint passes; decompose under #2569. + shared/egg_contracts/plan_parser.py: + issue: "2548" diff --git a/shared/egg_contracts/__init__.py b/shared/egg_contracts/__init__.py index 40fbb8f787..a1a07cba06 100644 --- a/shared/egg_contracts/__init__.py +++ b/shared/egg_contracts/__init__.py @@ -121,6 +121,10 @@ start_debounce, update_comment_with_countdown, ) +from .impasse import ( + Impasse, + ImpasseCategory, +) from .loader import ( ContractNotFoundError, ContractValidationError, @@ -198,6 +202,7 @@ parse_plan, parse_plan_file, validate_forest, + validate_task_role_alignment, ) from .resilience import ( CheckpointState, @@ -250,6 +255,8 @@ "DecisionType", "DeferredAction", "HumanReviewMechanism", + "Impasse", + "ImpasseCategory", "PhaseConfig", # Roles "FIELD_OWNERSHIP", @@ -299,6 +306,7 @@ "parse_plan", "parse_plan_file", "validate_forest", + "validate_task_role_alignment", # Phase Defaults "get_default_phase_config", "get_effective_phase_config", diff --git a/shared/egg_contracts/agent_roles.py b/shared/egg_contracts/agent_roles.py index 535671d630..fa7ac48d06 100644 --- a/shared/egg_contracts/agent_roles.py +++ b/shared/egg_contracts/agent_roles.py @@ -336,6 +336,23 @@ def depends_on(self, other: AgentRole) -> bool: ) # Plan-phase agent role definitions +# Plan-phase agents (architect, task_planner, risk_analyst) share a +# blocked_write list mirroring ``_PLAN_AGENT_BLOCKED`` in +# ``shared/egg_restrictions/patterns.py``. Keeping the two views in +# lockstep is the invariant issue #2532 closed; using a shared constant +# eliminates one future drift surface within ``agent_roles.py`` itself. +_PLAN_AGENT_BLOCKED_WRITE = [ + "**/*.py", + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + "**/*.go", + "**/*.java", + ".egg-state/contracts/", + # Issue #2532: parity with _PLAN_AGENT_BLOCKED in patterns.py — see #2508 / #2521. + ".github/", +] ARCHITECT_ROLE = AgentRoleDefinition( role=AgentRole.ARCHITECT, @@ -355,16 +372,7 @@ def depends_on(self, other: AgentRole) -> bool: ".egg-state/drafts/", ".egg-state/agent-outputs/", ], - blocked_write=[ - "**/*.py", - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx", - "**/*.go", - "**/*.java", - ".egg-state/contracts/", - ], + blocked_write=_PLAN_AGENT_BLOCKED_WRITE, ), produces_outputs=["architecture_analysis", "technical_decisions"], requires_inputs=[], @@ -388,16 +396,7 @@ def depends_on(self, other: AgentRole) -> bool: ".egg-state/drafts/", ".egg-state/agent-outputs/", ], - blocked_write=[ - "**/*.py", - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx", - "**/*.go", - "**/*.java", - ".egg-state/contracts/", - ], + blocked_write=_PLAN_AGENT_BLOCKED_WRITE, ), produces_outputs=["task_breakdown", "acceptance_criteria"], requires_inputs=["architecture_analysis"], @@ -421,16 +420,7 @@ def depends_on(self, other: AgentRole) -> bool: ".egg-state/drafts/", ".egg-state/agent-outputs/", ], - blocked_write=[ - "**/*.py", - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx", - "**/*.go", - "**/*.java", - ".egg-state/contracts/", - ], + blocked_write=_PLAN_AGENT_BLOCKED_WRITE, ), can_run_in_parallel=True, # Can run in parallel with task_planner produces_outputs=["risk_assessment", "mitigation_plan"], @@ -488,6 +478,8 @@ def depends_on(self, other: AgentRole) -> bool: "test/", ".egg-state/contracts/", ".egg-state/drafts/", + # Issue #2532: parity with _REVIEWER_BLOCKED in patterns.py — see #2508 / #2521. + ".github/", ] REVIEWER_CODE_ROLE = AgentRoleDefinition( @@ -551,6 +543,8 @@ def depends_on(self, other: AgentRole) -> bool: "tests/", "test/", ".egg-state/drafts/", + # Issue #2532: parity with _REVIEWER_CONTRACT_BLOCKED in patterns.py — see #2508 / #2521. + ".github/", ] REVIEWER_CONTRACT_ROLE = AgentRoleDefinition( diff --git a/shared/egg_contracts/impasse.py b/shared/egg_contracts/impasse.py new file mode 100644 index 0000000000..623b0fe282 --- /dev/null +++ b/shared/egg_contracts/impasse.py @@ -0,0 +1,156 @@ +"""Typed Impasse primitive for runtime escape-hatch (#2529). + +When a producer agent discovers mid-execution that its assigned task is +structurally impossible — file restrictions block its role, the plan is +buggy, an external dependency is missing, etc. — it emits a typed +``Impasse`` instead of inventing a workaround. The orchestrator detects +the impasse post-phase and routes accordingly: + +- Role-restriction impasse with a single eligible alternative role: + delegate (mutate ``task.role``) and re-run the slice. +- Second impasse on the same task, or no eligible alternative role: + escalate to HITL. + +This module defines the schema only; the agent-side handler lives in +``sandbox/egg_agent_tools/handlers/sdlc.py`` and the routing helpers +live in ``orchestrator/impasse_routing.py``. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ImpasseCategory(StrEnum): + """Why a task is structurally impossible for the assigned role.""" + + WRONG_ROLE = "wrong_role" + """Role-restriction patterns block the assigned role from one or more + files in ``task.files_affected``. The agent should populate + ``suggested_role`` with the role that *can* write the blocked files. + """ + + PLAN_BUG = "plan_bug" + """The task as written is internally inconsistent (e.g. acceptance + criteria contradict each other, files reference paths that do not + exist and no role could create them, the dependency in + ``files_affected`` is the wrong artifact). Cannot be resolved by + swapping role; needs HITL or a re-plan. + """ + + EXTERNAL_BLOCKER = "external_blocker" + """Required external state is missing — upstream dependency not yet + merged, an env var the task assumes is unset, a referenced ticket + was closed without the work being done. Surface to HITL with the + evidence so the operator can resolve the blocker. + """ + + UNKNOWN = "unknown" + """The agent recognises the task is impossible but cannot classify + the cause. Surface to HITL with the agent's reasoning verbatim. + """ + + +class Impasse(BaseModel): + """A typed signal that a task is structurally impossible. + + Emitted by a producer via the ``mcp__sdlc__report_impasse`` tool and + serialised under ``AgentOutput.impasse``. The orchestrator's + impasse-routing helpers consume this post-phase to decide whether to + delegate (for ``WRONG_ROLE`` with a single eligible alternative) or + escalate to HITL. + + The agent emits this *instead of* inventing a workaround (e.g. the + ``.github-staging/`` deletion-marker pattern that triggered the + follow-on NACK in pipeline ``issue-2474-v2``). Once emitted, the + agent should stop work and exit cleanly — its container will be + terminated by the orchestrator after the phase completes. + """ + + model_config = ConfigDict(extra="forbid") + + category: ImpasseCategory = Field( + ..., + description="Why the task is impossible for the assigned role.", + ) + reason: str = Field( + ..., + min_length=1, + max_length=2000, + description=( + "Human-readable explanation of what the agent observed. " + "Surfaced verbatim in the HITL decision and structured logs." + ), + ) + task_id: str | None = Field( + default=None, + description=( + "Contract task ID this impasse applies to (e.g. " + "``task-1-3``). When omitted, the orchestrator infers it " + "from the agent's currently assigned task in the slice." + ), + ) + suggested_role: str | None = Field( + default=None, + description=( + "For ``WRONG_ROLE`` impasses, the producer role that *can* " + "write the blocked files. The orchestrator uses this for " + "auto-delegation when ``delegation_attempts`` is below the " + "limit. ``None`` for non-WRONG_ROLE categories or when no " + "single role covers all files." + ), + ) + blocked_files: list[str] = Field( + default_factory=list, + description=( + "Files the assigned role cannot write, when relevant. " + "Populated by the agent or the gateway-side preflight; " + "empty for non-restriction impasses." + ), + ) + evidence: dict[str, Any] = Field( + default_factory=dict, + description=( + "Free-form structured evidence — error messages, links, " + "tool outputs the agent collected before deciding the " + "task was impossible. Surfaced in the HITL decision body." + ), + ) + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + description="Wall-clock time the impasse was reported.", + ) + + def to_dict(self) -> dict[str, Any]: + """Round-trip-safe dict for JSON serialisation.""" + return { + "category": self.category.value, + "reason": self.reason, + "task_id": self.task_id, + "suggested_role": self.suggested_role, + "blocked_files": list(self.blocked_files), + "evidence": dict(self.evidence), + "created_at": self.created_at.isoformat(), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Impasse: + """Inverse of :meth:`to_dict`.""" + raw_ts = data.get("created_at") + ts = datetime.fromisoformat(raw_ts) if isinstance(raw_ts, str) else datetime.now(UTC) + return cls( + category=ImpasseCategory(data["category"]), + reason=data["reason"], + task_id=data.get("task_id"), + suggested_role=data.get("suggested_role"), + blocked_files=list(data.get("blocked_files") or []), + evidence=dict(data.get("evidence") or {}), + created_at=ts, + ) + + +__all__ = ["Impasse", "ImpasseCategory"] diff --git a/shared/egg_contracts/models.py b/shared/egg_contracts/models.py index ee9683e498..4ed1f35f3a 100644 --- a/shared/egg_contracts/models.py +++ b/shared/egg_contracts/models.py @@ -212,6 +212,20 @@ class Task(EggContractBaseModel): review_cycles: int = Field(default=0, ge=0, description="Number of review cycles") max_cycles: int = Field(default=3, ge=1, description="Max cycles before escalation") escalated: bool = Field(default=False, description="Whether escalated") + delegation_attempts: int = Field( + default=0, + ge=0, + description=( + "Number of times this task has been delegated to a different " + "producer role via the runtime impasse escape hatch (#2529). " + "Bumped by the orchestrator when a producer emits an " + "``Impasse`` and the orchestrator mutates ``role`` to the " + "suggested alternative. A second impasse on the same task " + "(``delegation_attempts >= 1``) bypasses auto-delegation and " + "escalates to HITL instead. Loads as ``0`` for contracts " + "written before this field existed." + ), + ) gaps: list[TaskGap] = Field( default_factory=list, description=( @@ -369,7 +383,23 @@ class DeferredAction(EggContractBaseModel): class PRMetadata(EggContractBaseModel): - """Planner-generated PR metadata: title, description, test plan, and manual steps.""" + """Planner-generated PR metadata: title, description, test plan, and manual steps. + + Schema 1.1 (#2548) adds four optional ``context_*`` fields used by the + new dedicated context-PR mechanism. The context PR sits at the root of + the slice stack and carries the refine/plan analysis docs and BRC + consensus history, so that strategic narrative reaches ``main`` even + when slice PRs cascade-merge through the work branch. + + * ``context_title`` / ``context_description`` are populated by the + planner when it wants the context PR framed differently from the + slice PRs (e.g. "Strategic plan for #N" vs the slice's "Implement + …"). When omitted the orchestrator falls back to ``title`` / + ``description``. + * ``context_branch`` / ``context_pr_number`` are populated by the + orchestrator after the context branch is created and the context + PR is opened — planners must NOT emit these fields. + """ title: str = Field(..., min_length=1, description="PR title (recommended max 70 chars)") description: str = Field(default="", description="PR description/body") @@ -381,6 +411,41 @@ class PRMetadata(EggContractBaseModel): default="", description="Manual pre/post-merge steps (migrations, config changes, etc.)", ) + # ------------------------------------------------------------------ + # #2548 — context-PR fields (schema 1.1). + # ------------------------------------------------------------------ + context_title: str | None = Field( + default=None, + description=( + "Optional title for the dedicated context PR (#2548). Lets the " + "context PR be framed differently from slice PRs (e.g. " + "'Strategic plan for #N'). Falls back to ``title`` when None." + ), + ) + context_description: str | None = Field( + default=None, + description=( + "Optional body for the dedicated context PR (#2548). Falls " + "back to ``description`` when None." + ), + ) + context_branch: str | None = Field( + default=None, + description=( + "Branch name ``egg//context`` once the orchestrator " + "has created it (#2548). Populated by the orchestrator hook that " + "runs after plan_gate; planners must NOT emit this field." + ), + ) + context_pr_number: int | None = Field( + default=None, + ge=1, + description=( + "GitHub PR number once the context PR has been opened (#2548). " + "Populated by the orchestrator; planners must NOT emit this field. " + "Constrained to >=1 because GitHub PR numbers are positive." + ), + ) deferred_actions: list[DeferredAction] = Field( default_factory=list, description=( @@ -613,7 +678,16 @@ class Contract(EggContractBaseModel): """The complete SDLC contract.""" schemaVersion: str = Field( # noqa: N815 - default="1.0", pattern=r"^[0-9]+\.[0-9]+$", description="Schema version" + default="1.1", + pattern=r"^[0-9]+\.[0-9]+$", + description=( + "Schema version. Bumped to ``1.1`` in #2548 to track the addition " + "of the optional ``pr.context_*`` fields. Pre-1.1 contracts load " + "transparently — the new fields default to None — and are " + "promoted to ``1.1`` whenever they are loaded into the model; " + "the new value is then persisted on the next save. See " + "``_migrate_schema_version_to_1_1``." + ), ) issue: IssueInfo | None = Field(default=None, description="Issue metadata") pipeline_id: str | None = Field( @@ -748,6 +822,38 @@ def _migrate_phases_to_slices(cls, data: Any, handler: Any) -> Contract: instance._legacy_phases = legacy_phases return instance + @model_validator(mode="after") + def _migrate_schema_version_to_1_1(self) -> Contract: + """Promote pre-1.1 contracts to schema ``1.1`` (#2548). + + The ``1.0`` → ``1.1`` bump is purely additive — it documents the + arrival of the ``pr.context_*`` fields, which are all optional + and default to ``None``. Pre-1.1 JSON loads cleanly without the + fields; we just stamp the new version so downstream tooling + (audit, status renderers) sees a consistent value. + + We deliberately do NOT touch versions outside ``{1.0}`` so that + an unrelated future bump (e.g. a hypothetical ``2.0``) does not + get silently downgraded back to ``1.1``. + + This validator runs in ``mode="after"``, so the bump happens at + every load — including in-memory ``Contract.model_validate(...)`` + calls — not lazily on the next save. The mutation is idempotent + (the conditional only fires when the value is exactly ``"1.0"``) + so re-running the validator on an already-migrated contract is + a no-op. + + Note: the bump is silent — no ``AuditEntry`` is appended. + Operators inspecting the audit trail after a 1.0 → 1.1 + promotion will not see a record of the change. Schema bumps + are uncommon enough that this is intentional; if a future + bump warrants audit visibility, a dedicated audit hook on + the migration validator is the right place to add it. + """ + if self.schemaVersion == "1.0": + self.schemaVersion = "1.1" + return self + @model_validator(mode="after") def _require_issue_or_pipeline_id(self) -> Contract: """At least one of issue or pipeline_id must be set.""" diff --git a/shared/egg_contracts/plan_parser.py b/shared/egg_contracts/plan_parser.py index 8d8eefd653..87ab71d2fa 100644 --- a/shared/egg_contracts/plan_parser.py +++ b/shared/egg_contracts/plan_parser.py @@ -56,12 +56,14 @@ from __future__ import annotations +import posixpath import re from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml +from egg_restrictions.matchers import match_pattern from .agent_roles import EXECUTION_ROLE_VALUES from .models import Slice, SliceStatus, Task, TaskStatus @@ -205,6 +207,11 @@ class ParseResult: pr_description: str | None = None pr_test_plan: str | None = None pr_manual_steps: str | None = None + # #2548 — context-PR fields. Optional; default to None when the + # planner omits them (the orchestrator falls back to ``pr_title`` / + # ``pr_description`` for the context-PR framing in that case). + pr_context_title: str | None = None + pr_context_description: str | None = None def to_contract_phases(self) -> list[Slice]: """Backward-compat alias for ``to_contract_slices`` (#2137). @@ -915,6 +922,95 @@ def extract_pr_metadata_from_yaml( return pr_title, pr_description, pr_test_plan, pr_manual_steps, warnings +def extract_pr_context_metadata_from_yaml( + yaml_data: dict[str, Any] | None, +) -> tuple[str | None, str | None, list[ParseWarning]]: + """Extract optional context-PR framing fields from the ``pr:`` block. + + Added in #2548 alongside the dedicated context-PR mechanism. The + planner can emit ``pr.context_title`` and ``pr.context_description`` + to frame the strategic-plan PR differently from the slice PRs (e.g. + "Strategic plan for #N" vs "Implement …"). Both keys are optional — + when omitted the orchestrator falls back to ``pr.title`` / + ``pr.description`` for the context PR's framing. + + The orchestrator-populated fields ``pr.context_branch`` and + ``pr.context_pr_number`` are intentionally NOT extracted here: + planners must not emit them, and a future plan-reviewer may emit a + warning if they do appear in a planner-authored YAML. We currently + accept-and-ignore unknown keys to stay forward-compatible with + minor planner-prompt drift. + + Args: + yaml_data: Parsed YAML data from a yaml-tasks code fence. + + Returns: + Tuple of (context_title, context_description, warnings). Each + of the two value slots is ``None`` when absent or malformed. + """ + warnings: list[ParseWarning] = [] + + if yaml_data is None: + return None, None, warnings + + pr_data = yaml_data.get("pr") + if not isinstance(pr_data, dict): + # ``extract_pr_metadata_from_yaml`` already produces a structural + # warning for the non-dict case; do not duplicate it here. + return None, None, warnings + + raw_title = pr_data.get("context_title") + raw_description = pr_data.get("context_description") + + context_title: str | None = None + if raw_title is not None: + if not isinstance(raw_title, str): + warnings.append( + ParseWarning( + line_number=None, + message=( + f"'pr.context_title' must be a string, got {type(raw_title).__name__}" + ), + context="context-PR title will fall back to pr.title", + ) + ) + else: + stripped = raw_title.strip() + context_title = stripped if stripped else None + + # Normalize description to a non-empty string, then collapse the + # absent/empty case to ``None`` so the orchestrator can reliably + # detect "fall back to pr.description" semantics. The existing + # ``pr.description`` field defaults to "" because PRMetadata + # requires a string body, but ``context_description`` is Optional + # at the model layer. + # + # Symmetric with the ``context_title`` branch above: warn loudly + # when the planner emitted a non-string scalar (e.g. an int or a + # nested mapping). Without this check ``_normalize_optional_string`` + # would silently coerce via ``str(value)`` and a planner-prompt + # regression that started emitting structured values would land + # quietly on the contract. + context_description: str | None = None + if raw_description is not None: + if not isinstance(raw_description, str): + warnings.append( + ParseWarning( + line_number=None, + message=( + f"'pr.context_description' must be a string, got " + f"{type(raw_description).__name__}" + ), + context="context-PR description will fall back to pr.description", + ) + ) + else: + normalized = _normalize_optional_string(raw_description) + context_description = normalized if normalized else None + + return context_title, context_description, warnings + + def parse_phases_from_markdown(content: str) -> list[ParsedPhase]: """ Parse phase sections from markdown content. @@ -1101,6 +1197,14 @@ def parse_plan(content: str) -> ParseResult: ) warnings.extend(pr_warnings) + # Extract optional context-PR framing fields (#2548). These are + # captured separately to keep ``extract_pr_metadata_from_yaml``'s + # 5-tuple signature stable for existing callers. + pr_context_title, pr_context_description, pr_context_warnings = ( + extract_pr_context_metadata_from_yaml(yaml_data) + ) + warnings.extend(pr_context_warnings) + return ParseResult( success=True, phases=phases, @@ -1110,6 +1214,8 @@ def parse_plan(content: str) -> ParseResult: pr_description=pr_description, pr_test_plan=pr_test_plan, pr_manual_steps=pr_manual_steps, + pr_context_title=pr_context_title, + pr_context_description=pr_context_description, ) @@ -1272,6 +1378,145 @@ def dfs(node: str, path: list[str]) -> None: return cycles +# --------------------------------------------------------------------------- +# #2527 — task role ↔ files_affected alignment +# --------------------------------------------------------------------------- + + +def _is_file_blocked_for_role(role: str, file_path: str) -> bool: + """Return True if ``file_path`` is blocked for ``role`` per the role's + ``AGENT_PATTERNS`` blocklist (with block-exempt carve-outs). + + Mirrors ``gateway/phase_filter.py::FileRestriction.is_file_blocked`` + so plan-time validation matches push-time enforcement 1:1. The + gateway's check intentionally consults only blocked + block-exempt + patterns (not allowed_patterns), and so does this function. + """ + # AGENT_PATTERNS is imported lazily here to avoid a circular import: + # egg_restrictions.patterns imports egg_contracts.agent_roles, which + # triggers egg_contracts/__init__.py, which imports this module. A + # module-scope import would deadlock that cycle and break the gateway + # production boot path. egg_restrictions.matchers.match_pattern is + # deliberately split out of patterns.py for safe module-scope use + # (see matchers.py docstring); only AGENT_PATTERNS needs to be lazy. + from egg_restrictions.patterns import AGENT_PATTERNS + + pattern = AGENT_PATTERNS.get(role) + if pattern is None: + return False + + normalized = posixpath.normpath(file_path) + if normalized.startswith("./"): + normalized = normalized[2:] + if normalized.startswith("../") or normalized.startswith("/"): + return True + + if not any(match_pattern(normalized, p) for p in pattern.blocked_patterns): + return False + if any(match_pattern(normalized, p) for p in pattern.block_exempt_patterns): + return False + return True + + +def _eligible_producer_roles(files: list[str]) -> list[str]: + """Return the producer roles (coder/tester/documenter) for which + every file in ``files`` passes the gateway's blocked-pattern check. + + The result preserves the canonical coder→tester→documenter ordering + so suggestions are deterministic across runs. + """ + from .agent_roles import AgentRole + + ordered_roles = (AgentRole.CODER, AgentRole.TESTER, AgentRole.DOCUMENTER) + eligible: list[str] = [] + for role in ordered_roles: + if all(not _is_file_blocked_for_role(role, f) for f in files): + eligible.append(role.value) + return eligible + + +def _check_role_files(task: Task, slice_id: str) -> str | None: + """Return a structured error string for a misaligned task, or + ``None`` if the task's ``role`` can push every file in + ``files_affected``. + + Per-task hook so the #2530 follow-up can thread a future + ``includes_tests: true`` opt-in through here without restructuring + the outer walk: a coder task that legitimately couples tests to + its own production code is the most common false-positive case + (24 of 25 misassignments in the #2530 audit), and that flag is the + proposed exception. Until the flag exists this function reports + every coder-with-test-files mismatch. + + Tasks without a ``role`` or with empty ``files_affected`` return + ``None`` — the parser already treats ``role`` as optional, and an + empty file list leaves nothing to check (prose/research tasks). + """ + role = task.role + files = list(task.files_affected or []) + if not role or not files: + return None + blocked = [f for f in files if _is_file_blocked_for_role(role, f)] + if not blocked: + return None + eligible = _eligible_producer_roles(files) + if len(eligible) == 1: + hint = f"Reassign to role '{eligible[0]}' — it can push every file in this task." + elif len(eligible) > 1: + hint = ( + f"Eligible roles for this file set: {eligible}. " + "Pick one and update the task's 'role' field." + ) + else: + hint = ( + "No producer role can push every file in this task. Either " + "split the task so each subtask falls within a single " + "role's scope, or — for `.github/` files — stage them " + "under top-level `.github-staging/` and let the PR " + "builder emit a manual reviewer step (issue #2508)." + ) + return ( + f"Task '{task.id}' (slice '{slice_id}') is assigned role " + f"'{role}' but files {blocked} are blocked for that role per " + f"shared/egg_restrictions/patterns.py. {hint}" + ) + + +def validate_task_role_alignment(slices: list[Slice]) -> list[str]: + """Walk the slice/task tree and reject tasks whose ``role`` cannot + push their ``files_affected``. + + Added in #2527. The plan-phase ``task_planner`` can assign tasks to + producer roles whose ``shared/egg_restrictions/patterns.py`` + blocklist forbids the listed files; the mismatch is otherwise only + caught at push time by the gateway's + ``check_file_restrictions``, which means the producer agent gets + spawned, explores, sometimes builds workarounds, and only then + hits ``403 restricted_path_modified``. Running the same check + at plan time lets the plan reviewer NACK the planner before any + producer cycle is wasted. + + Per-task logic lives in ``_check_role_files`` so the #2530 + ``includes_tests`` follow-up has a clear hook point. + + Args: + slices: The slice list extracted from the contract / plan. + + Returns: + A list of structured-error strings — one entry per offending + task. Each entry names the task ID, the assigned role, the + blocked files, and the eligible-role hint so the plan reviewer + can surface an actionable NACK reason. + """ + errors: list[str] = [] + for slice_ in slices: + for task in slice_.tasks: + err = _check_role_files(task, slice_.id) + if err is not None: + errors.append(err) + return errors + + __all__ = ( "ParsedPhase", "ParsedTask", @@ -1281,4 +1526,5 @@ def dfs(node: str, path: list[str]) -> None: "parse_plan", "parse_plan_file", "validate_forest", + "validate_task_role_alignment", ) diff --git a/shared/egg_contracts/roles.py b/shared/egg_contracts/roles.py index 72435aa916..fdf16c2188 100644 --- a/shared/egg_contracts/roles.py +++ b/shared/egg_contracts/roles.py @@ -46,6 +46,17 @@ class Role(StrEnum): # Task status: shared between implementer (mark done during implementation) # and reviewer (validate/override during review) "phases.*.tasks.*.status": frozenset({Role.IMPLEMENTER, Role.REVIEWER}), + # Task role: owned by the orchestrator (SYSTEM) so impasse-driven + # delegation (#2529) can mutate ``role`` to the producer the agent + # suggested. Producers must not rewrite their own role mid-flight — + # the suggestion is encoded in the ``Impasse`` payload they emit and + # applied by the orchestrator after the phase exits, not by the + # agent itself. + "phases.*.tasks.*.role": Role.SYSTEM, + # Delegation counter: bumped by the orchestrator alongside any + # role-flip so a second impasse on the same task escalates to HITL + # instead of looping forever. + "phases.*.tasks.*.delegation_attempts": Role.SYSTEM, # Phase commit: implementer links a commit SHA to the phase "phases.*.commit": Role.IMPLEMENTER, # Phase status: shared between implementer (mark done after completing all diff --git a/shared/egg_contracts/tests/test_validate_task_role_alignment.py b/shared/egg_contracts/tests/test_validate_task_role_alignment.py new file mode 100644 index 0000000000..47cf47e08c --- /dev/null +++ b/shared/egg_contracts/tests/test_validate_task_role_alignment.py @@ -0,0 +1,286 @@ +"""Tests for ``plan_parser.validate_task_role_alignment`` (#2527). + +The plan-phase ``task_planner`` can assign tasks to producer roles +(``coder`` / ``tester`` / ``documenter``) whose +``shared/egg_restrictions/patterns.py`` blocklist forbids the listed +files. The mismatch is otherwise only caught at push time by the +gateway, after a producer has been spawned and burned tokens. The +validator runs the same blocked-pattern check at plan time so the plan +reviewer can NACK before any producer cycle starts. + +These tests cover: + +* Clean assignments (each producer role pushing files within its scope) + return no errors. +* Coder assigned to test files — the dominant misassignment in the + #2530 audit (24 of 25 cases) — is flagged with ``tester`` as the + single eligible role. +* Coder assigned to ``**/conftest.py`` is flagged (separate fixture + pattern from ``test_*.py``). +* Coder assigned to ``.github/`` files — no producer role can push + these — surfaces the ``.github-staging/`` (#2508) remediation hint. +* Coder assigned to a markdown file is flagged with ``documenter`` as + the single eligible role. +* Tasks without an explicit ``role`` or without + ``files_affected`` are skipped (no error, no false positive). +* The actual ``files`` from issue #2527's evidence table parse to the + expected structured errors. +""" + +from __future__ import annotations + +from egg_contracts.models import Slice, Task +from egg_contracts.plan_parser import validate_task_role_alignment + + +def _slice(slice_id: str, tasks: list[Task]) -> Slice: + return Slice(id=slice_id, name=f"slice {slice_id}", tasks=tasks) + + +def _task( + task_id: str, + files: list[str], + role: str | None, + description: str = "task", +) -> Task: + return Task( + id=task_id, + description=description, + acceptance_criteria="acc", + files_affected=files, + role=role, + ) + + +class TestCleanAssignments: + """Properly assigned tasks should produce zero errors.""" + + def test_empty_input(self) -> None: + assert validate_task_role_alignment([]) == [] + + def test_coder_with_source_file(self) -> None: + slices = [_slice("slice-1", [_task("task-1-1", ["src/foo.py"], "coder")])] + assert validate_task_role_alignment(slices) == [] + + def test_tester_with_test_file(self) -> None: + slices = [_slice("slice-1", [_task("task-1-1", ["tests/test_foo.py"], "tester")])] + assert validate_task_role_alignment(slices) == [] + + def test_tester_with_conftest(self) -> None: + slices = [ + _slice( + "slice-1", + [_task("task-1-1", ["integration_tests/conftest.py"], "tester")], + ) + ] + assert validate_task_role_alignment(slices) == [] + + def test_documenter_with_markdown(self) -> None: + slices = [_slice("slice-1", [_task("task-1-1", ["docs/guide.md"], "documenter")])] + assert validate_task_role_alignment(slices) == [] + + +class TestSkippedTasks: + """Tasks that the validator must intentionally pass over.""" + + def test_no_role_is_skipped(self) -> None: + # Tasks without an explicit role default downstream; the + # validator's job is catching mis-assignments, not enforcing + # role declaration (#2527 scope). + slices = [ + _slice( + "slice-1", + [_task("task-1-1", ["integration_tests/conftest.py"], None)], + ) + ] + assert validate_task_role_alignment(slices) == [] + + def test_empty_files_is_skipped(self) -> None: + # Prose/research tasks legitimately omit files_affected — + # nothing to check. + slices = [_slice("slice-1", [_task("task-1-1", [], "coder")])] + assert validate_task_role_alignment(slices) == [] + + def test_role_without_files_is_skipped(self) -> None: + slices = [_slice("slice-1", [_task("task-1-1", [], "documenter")])] + assert validate_task_role_alignment(slices) == [] + + +class TestSingleEligibleRoleHint: + """When exactly one producer role can push every file in the task, + the validator must name that role in its hint.""" + + def test_coder_with_conftest_suggests_tester(self) -> None: + slices = [ + _slice( + "slice-1", + [_task("task-1-1", ["integration_tests/conftest.py"], "coder")], + ) + ] + errors = validate_task_role_alignment(slices) + assert len(errors) == 1 + msg = errors[0] + assert "task-1-1" in msg + assert "slice-1" in msg + assert "'coder'" in msg + assert "integration_tests/conftest.py" in msg + assert "Reassign to role 'tester'" in msg + + def test_coder_with_test_py_suggests_tester(self) -> None: + # 24 of 25 misassignments in the #2530 audit were coder + # tasks containing test_*.py files. + slices = [_slice("slice-1", [_task("task-1-1", ["tests/test_foo.py"], "coder")])] + errors = validate_task_role_alignment(slices) + assert len(errors) == 1 + assert "Reassign to role 'tester'" in errors[0] + + def test_coder_with_markdown_suggests_documenter(self) -> None: + slices = [_slice("slice-1", [_task("task-1-1", ["docs/guide.md"], "coder")])] + errors = validate_task_role_alignment(slices) + assert len(errors) == 1 + assert "Reassign to role 'documenter'" in errors[0] + + +class TestNoEligibleRoleHint: + """Files no producer role can push — `.github/` is the canonical case + per #2508 — must surface the `.github-staging/` remediation.""" + + def test_coder_with_github_workflow_no_eligible_role(self) -> None: + slices = [ + _slice( + "slice-1", + [_task("task-1-1", [".github/workflows/ci.yml"], "coder")], + ) + ] + errors = validate_task_role_alignment(slices) + assert len(errors) == 1 + msg = errors[0] + assert ".github/workflows/ci.yml" in msg + assert "No producer role can push" in msg + assert ".github-staging/" in msg + + +class TestMixedRoleFiles: + """When files cross role boundaries no single role is eligible — the + validator must say so without listing a specific role to switch to.""" + + def test_task_mixing_test_and_doc_files_has_no_eligible_role(self) -> None: + # tests/test_foo.py is blocked for coder + documenter (the + # latter via the ``tests/`` directory rule). + # docs/guide.md is blocked for coder + tester (both via + # ``**/*.md``). No producer role can push both. + slices = [ + _slice( + "slice-1", + [ + _task( + "task-1-1", + ["tests/test_foo.py", "docs/guide.md"], + "coder", + ) + ], + ) + ] + errors = validate_task_role_alignment(slices) + assert len(errors) == 1 + assert "No producer role can push" in errors[0] + + +class TestMultipleSlicesAndTasks: + """The walk must cover every (slice, task) pair and emit one error + per offender.""" + + def test_evidence_from_issue_2527(self) -> None: + # Reproduces the misassignments listed in the issue's evidence + # table for pipeline issue-2474-v2 slice-1: TASK-1-1 (conftest), + # TASK-1-3 (test_*.py + .github/), TASK-1-4 (conftest) all + # assigned to coder. + slices = [ + _slice( + "slice-1", + [ + _task( + "task-1-1", + [ + "integration_tests/conftest.py", + "integration_tests/local_pipeline/conftest.py", + ], + "coder", + ), + # task-1-2 is correctly assigned -> no error + _task("task-1-2", ["src/foo.py"], "coder"), + _task( + "task-1-3", + [ + ".github/workflows/test-e2e.yml", + "integration_tests/test_e2e.py", + ], + "coder", + ), + _task("task-1-4", ["integration_tests/conftest.py"], "coder"), + ], + ) + ] + errors = validate_task_role_alignment(slices) + assert len(errors) == 3 + offending_ids = { + tid for err in errors for tid in ("task-1-1", "task-1-3", "task-1-4") if tid in err + } + assert offending_ids == {"task-1-1", "task-1-3", "task-1-4"} + + def test_errors_walk_every_slice(self) -> None: + slices = [ + _slice("slice-1", [_task("task-1-1", ["tests/test_a.py"], "coder")]), + _slice("slice-2", [_task("task-2-1", ["docs/x.md"], "coder")]), + ] + errors = validate_task_role_alignment(slices) + assert len(errors) == 2 + assert any("slice-1" in e and "task-1-1" in e for e in errors) + assert any("slice-2" in e and "task-2-1" in e for e in errors) + + +class TestImportOrderingRegression: + """Guard against the egg_restrictions ↔ egg_contracts import cycle. + + A clean-interpreter ``import egg_restrictions.patterns`` must succeed + even when nothing has pre-loaded ``egg_contracts``. Hoisting + ``AGENT_PATTERNS`` to module scope in ``plan_parser.py`` triggers an + ``ImportError: cannot import name 'AGENT_PATTERNS' from partially + initialized module`` because patterns.py imports + ``egg_contracts.agent_roles``, which runs ``egg_contracts/__init__.py``, + which loads ``plan_parser`` mid-cycle. The gateway production boot + path runs ``python3 gateway.py`` (no pytest pre-loader), so this test + runs the import in a subprocess to mirror that condition. See + ``shared/egg_restrictions/matchers.py`` docstring for context. + """ + + def test_egg_restrictions_patterns_imports_cleanly(self) -> None: + import os + import subprocess + import sys + from pathlib import Path + + # Resolve the repo's ``shared/`` directory from this file's location: + # tests/ → egg_contracts/ → shared/. The gateway runs + # ``python3 gateway.py`` with ``PYTHONPATH=/app`` (see + # gateway/Dockerfile:99 and gateway/entrypoint.sh:286) where + # shared/ modules are copied to /app/ (see + # gateway/Dockerfile:70-75). ``PYTHONPATH=shared`` mirrors that + # import surface — no orchestrator/, no gateway/. + shared_dir = Path(__file__).resolve().parents[2] + env = {**os.environ, "PYTHONPATH": str(shared_dir)} + + # Fresh interpreter — no pytest pre-loader has run, so the cycle + # surfaces if AGENT_PATTERNS is hoisted to module scope in + # plan_parser.py. + result = subprocess.run( + [sys.executable, "-c", "import egg_restrictions.patterns"], + capture_output=True, + text=True, + check=False, + env=env, + ) + assert result.returncode == 0, ( + f"import egg_restrictions.patterns failed in clean interpreter:\n" + f"stderr:\n{result.stderr}" + ) diff --git a/shared/tests/test_github_block_alignment.py b/shared/tests/test_github_block_alignment.py new file mode 100644 index 0000000000..9174ee39c4 --- /dev/null +++ b/shared/tests/test_github_block_alignment.py @@ -0,0 +1,82 @@ +"""Cross-view alignment of the ``.github/`` block (issue #2532). + +The planner prompt reads ``shared/egg_contracts/agent_roles.py`` via +``get_file_patterns()``; the gateway reads ``shared/egg_restrictions/patterns.py`` +via ``AgentFilePattern.can_write()``. Earlier work (#2508, #2514, #2521, +#2525) added an explicit ``.github/`` block to several roles in lockstep +across both files. Issue #2532 closes the remaining drift for plan-side +and reviewer roles. + +A "load-bearing" test in the style of #2525 (a path the role's allowlist +matches that only the new ``.github/`` block stops) is not constructible +here: every affected role's allowlist is confined to ``.egg-state/...``, +which never collides with ``.github/``. The block is therefore benign +today — but the same forward-compat argument from #2521 applies. These +tests assert the two views agree, so any future allowlist widening +cannot silently bypass the branch-protection invariant. +""" + +from __future__ import annotations + +import pytest +from egg_contracts.agent_roles import AgentRole, get_file_patterns +from egg_restrictions import get_agent_pattern + +# Roles whose ``blocked_write`` / ``blocked_patterns`` must include +# ``.github/`` per #2532. The list is intentionally hand-maintained: +# adding a new role here is a deliberate decision, not an automatic +# consequence of role registration. +ROLES_WITH_GITHUB_BLOCK = [ + # Plan-side (#2532). + AgentRole.ARCHITECT, + AgentRole.TASK_PLANNER, + AgentRole.RISK_ANALYST, + # Reviewers sharing _REVIEWER_BLOCKED_WRITE / _REVIEWER_BLOCKED (#2532). + AgentRole.REVIEWER_CODE, + AgentRole.REVIEWER_CODE_HOLISTIC, + AgentRole.REVIEWER_AGENT_DESIGN, + AgentRole.REVIEWER_REFINE, + AgentRole.REVIEWER_PLAN, + AgentRole.REVIEWER_SECURITY, + AgentRole.REVIEWER_CONCURRENCY, + # Reviewer with its own blocked list (#2532). + AgentRole.REVIEWER_CONTRACT, +] + + +@pytest.mark.parametrize("role", ROLES_WITH_GITHUB_BLOCK, ids=lambda r: r.value) +def test_agent_roles_view_blocks_github(role: AgentRole) -> None: + """``agent_roles.py`` (planner-prompt view) blocks ``.github/``.""" + patterns = get_file_patterns(role.value) + assert patterns is not None, f"{role.value} has no file_access patterns" + assert ".github/" in patterns["blocked"], ( + f"{role.value} blocked_write is missing '.github/' — " + "see issue #2532 for the drift this test guards against." + ) + + +@pytest.mark.parametrize("role", ROLES_WITH_GITHUB_BLOCK, ids=lambda r: r.value) +def test_patterns_view_blocks_github(role: AgentRole) -> None: + """``patterns.py`` (gateway view) blocks ``.github/``.""" + pattern = get_agent_pattern(role.value) + assert pattern is not None, f"{role.value} not registered in AGENT_PATTERNS" + assert ".github/" in pattern.blocked_patterns, ( + f"{role.value} blocked_patterns is missing '.github/' — " + "see issue #2532 for the drift this test guards against." + ) + + +@pytest.mark.parametrize("role", ROLES_WITH_GITHUB_BLOCK, ids=lambda r: r.value) +def test_two_views_agree_on_github(role: AgentRole) -> None: + """The planner-prompt view and the gateway view must agree on ``.github/``.""" + contracts_patterns = get_file_patterns(role.value) + restrictions_pattern = get_agent_pattern(role.value) + assert contracts_patterns is not None + assert restrictions_pattern is not None + contracts_blocks_github = ".github/" in contracts_patterns["blocked"] + restrictions_blocks_github = ".github/" in restrictions_pattern.blocked_patterns + assert contracts_blocks_github == restrictions_blocks_github, ( + f"{role.value}: agent_roles.py and patterns.py disagree on '.github/' — " + f"agent_roles={contracts_blocks_github}, patterns={restrictions_blocks_github}. " + "See issue #2532." + ) diff --git a/shared/tests/test_impasse_schema.py b/shared/tests/test_impasse_schema.py new file mode 100644 index 0000000000..e8e62d60aa --- /dev/null +++ b/shared/tests/test_impasse_schema.py @@ -0,0 +1,86 @@ +"""Tests for the typed Impasse primitive (#2529).""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from egg_contracts import Impasse, ImpasseCategory +from pydantic import ValidationError + + +class TestImpasseConstruction: + def test_minimal_payload_succeeds(self): + imp = Impasse(category=ImpasseCategory.UNKNOWN, reason="not sure why") + assert imp.category == ImpasseCategory.UNKNOWN + assert imp.reason == "not sure why" + assert imp.task_id is None + assert imp.suggested_role is None + assert imp.blocked_files == [] + assert imp.evidence == {} + assert isinstance(imp.created_at, datetime) + + def test_full_payload_round_trips(self): + imp = Impasse( + category=ImpasseCategory.WRONG_ROLE, + reason="cannot write tests/conftest.py", + task_id="task-1-1", + suggested_role="tester", + blocked_files=["tests/conftest.py"], + evidence={"detected_by": "check_file_restriction"}, + ) + d = imp.to_dict() + imp2 = Impasse.from_dict(d) + assert imp2.category == imp.category + assert imp2.reason == imp.reason + assert imp2.task_id == imp.task_id + assert imp2.suggested_role == imp.suggested_role + assert imp2.blocked_files == imp.blocked_files + assert imp2.evidence == imp.evidence + # created_at must survive isoformat round trip + assert imp2.created_at == imp.created_at + + def test_reason_required(self): + with pytest.raises(ValidationError): + Impasse(category=ImpasseCategory.WRONG_ROLE, reason="") + + def test_category_must_be_known(self): + with pytest.raises(ValidationError): + Impasse(category="bogus", reason="x") # type: ignore[arg-type] + + def test_extra_fields_rejected(self): + with pytest.raises(ValidationError): + Impasse( # type: ignore[call-arg] + category=ImpasseCategory.PLAN_BUG, + reason="x", + bogus_field=1, + ) + + def test_from_dict_uses_now_when_timestamp_missing(self): + before = datetime.now(UTC) + imp = Impasse.from_dict({"category": "external_blocker", "reason": "needs upstream merge"}) + after = datetime.now(UTC) + assert before <= imp.created_at <= after + assert imp.category == ImpasseCategory.EXTERNAL_BLOCKER + + def test_from_dict_handles_missing_optional_lists(self): + imp = Impasse.from_dict( + { + "category": "wrong_role", + "reason": "blocked", + "suggested_role": "tester", + } + ) + assert imp.blocked_files == [] + assert imp.evidence == {} + + +class TestImpasseCategory: + def test_string_values_match_schema(self): + # The MCP tool schema enumerates these literal strings; if anyone + # renames a category we want the test suite to flag it before it + # hits a sandbox agent that emits the old name. + assert ImpasseCategory.WRONG_ROLE.value == "wrong_role" + assert ImpasseCategory.PLAN_BUG.value == "plan_bug" + assert ImpasseCategory.EXTERNAL_BLOCKER.value == "external_blocker" + assert ImpasseCategory.UNKNOWN.value == "unknown" diff --git a/skills/sdlc/SKILL.md b/skills/sdlc/SKILL.md index 7587a95417..f6d914ae8c 100644 --- a/skills/sdlc/SKILL.md +++ b/skills/sdlc/SKILL.md @@ -362,7 +362,9 @@ Drive the pipeline through one Monitor invocation per quiet stretch. On entry: **Trigger allowlist:** `OVERSEER_ALERT`, `CONSENSUS_CONFIRMED`, `CONSENSUS_NACK`, `CONSENSUS_RE_REVIEW`, `phase.started`, `phase.completed`, `pipeline.completed`, `pipeline.failed`, `pipeline.cancelled`, `decision.created`. `decision.resolved` is **deliberately excluded** so the host doesn't self-wake on a `provide_input` it just submitted. -4. **Render the dashboard** on each line: +4. **Render the dashboard** on each line. There are two render paths — pick based on whether the line carries `concurrent.consensus`: + + **Path A — non-BRC line (no `concurrent.consensus`):** the 3-line compact form. ``` --- Pipeline Status --- @@ -370,6 +372,27 @@ Drive the pipeline through one Monitor invocation per quiet stretch. On entry: Recent: ``` + For Path A only, you may render deltas-only on subsequent emits (skip lines that haven't changed) to keep the output concise. + + **Path B — BRC line (`concurrent.consensus` is present):** a per-role status table. See [Consensus Monitoring](#consensus-monitoring) for column derivation. Always render the full table on every emit — the table is the operator's at-a-glance scan, so partial renders defeat the point. + + ``` + Phase: | Status: | Elapsed: s | Consensus: / | NACKs: + + | Role | Phase | Confirmed | Latest activity | + |-------------------------|----------------------|-----------|----------------------------------------------| + | coder | PROPOSED | ✓ | re-proposed at 19:03:40, accepted | + | documenter | PROPOSED | ✓ | no-op attestation (slice-1 is code-only) | + | tester | WORKING / REVIEWING | | writing TASK-1-2 tests against coder's diff | + | reviewer_code | WORKING | | reviewing | + | reviewer_security | CONFIRMED | ✓ | ACK at 19:05:41 | + + ⚠️ reviewer_concurrency → coder: "missing lock around _producer_phases" (only when unresolved_nacks is non-empty) + ⚠️ reviewer_contract: silent for ~12m — no BRC messages (only when a silent agent is detected) + ``` + + The header values come straight from the JSON-line: `current_phase`, `status`, `phase_elapsed_seconds`. `Consensus: /` counts agents with `confirmed: true` over `len(agents)`; `NACKs: ` is `len(unresolved_nacks)`. + Use the server-computed `phase_elapsed_seconds` from the line. The line carries only the dashboard-relevant subset (`current_phase`, `status`, `phase_elapsed_seconds`, `concurrent.consensus`) — it does **not** include the full snapshot (running_agents, completed_agents, recent_messages, pipeline metadata, `pending_decisions`). When you need the full envelope — for example to enrich an `OVERSEER_ALERT` with `recent_messages`, or to render `pending_decisions` ahead of HITL on a `decision.created` line — call `get_status(task_id)` again as a one-shot snapshot and refresh `last_status`. 5. **Check for overseer alerts** on each `trigger: "message"` line where any entry's `type` is `OVERSEER_ALERT` — see [Overseer Alert Detection](#overseer-alert-detection) below. @@ -385,7 +408,7 @@ Drive the pipeline through one Monitor invocation per quiet stretch. On entry: **Important: `wait-status` blocks server-side and emits events as they arrive. Do NOT wrap the Monitor invocation in an outer `for`-loop or `sleep` — the CLI is already the loop, server-side, and Monitor surfaces each emitted line as its own notification. The skill's liveness guarantee comes from the CLI re-issuing the route call with the threaded cursor on every Path-B no-change return; intra-process loop, no LLM turn.** When Monitor's `timeout_ms` (or the Bash 10-min cap, if you're on the fallback path) forces the CLI to terminate, simply re-invoke with the latest `last_cursor` from your conversation context. The overseer is the primary deadlock detector and emits `OVERSEER_ALERT` on stalls, which is in the trigger allowlist. See [Host-Side Waits](../../docs/reference/agent-wait-patterns.md#7-host-side-waits--egg-orch-pipeline-wait-status) for the full event allowlist, exit-code contract, and concurrency model. -Keep the dashboard output concise. Only show changes from the previous emit when possible. +Path A keeps the dashboard concise via deltas — skip lines that didn't change from the previous emit. Path B always renders full state, including the optional NACK and silent-agent rows. ### Failed Status Grace Period @@ -479,21 +502,31 @@ After firing, write the sentinel file so the fallback does not fire again this p When the pipeline uses concurrent agents (BRC protocol), each `wait-status` JSON-line and the cached `last_status` may include a `concurrent.consensus` object. The CLI ships `concurrent.consensus` on every emitted line whenever the route saw it, so consensus drift never goes invisible during quiet phases on BRC pipelines. On each emitted line, check this data for red flags and surface problems to the user before they escalate. -**Enhanced dashboard** — When consensus data is present, extend the status display: +**Per-role status table (Path B render).** When consensus data is present, the dashboard from step 4 is the per-role table — there is one rendering path for BRC, not a separate "consensus block" stacked under the compact form. Column derivation: -``` ---- Pipeline Status --- -Phase: | Status: -Agents: running, completed -Consensus: / confirmed | Blocking: , -Recent: -``` +| Column | Source | +|--------|--------| +| Role | Keys of `concurrent.consensus.agents`, sorted producers-first then reviewers. Use `concurrent.consensus.review_graph.producers` for the producer block and `review_graph.reviewers` for the reviewer block (both already alphabetical in the payload — `peer_consensus.evaluate()` sorts them in `ReviewGraph.to_dict()`). **Skip any role from the reviewer block that already appeared in the producer block** — dual-role agents (`tester` is the canonical case, present in both lists for the implement graph) render once, in the producer block, with the combined phase per the Phase column rule below. This generalizes across phases — refine has `refiner`, plan has `architect` / `task_planner` / `risk_analyst`, implement has `coder` / `tester` / `documenter`. Producer/reviewer is decidable from which of `producer_phase` / `reviewer_phase` is set on the agent entry; `review_graph` is the canonical source. | +| Phase | `producer_phase` for producers, `reviewer_phase` for reviewers. For dual-role agents (`tester` is the canonical case — both `producer_phase` and `reviewer_phase` set) render ` / ` (e.g. `WORKING / REVIEWING`). | +| Confirmed | `✓` if `agents[role].confirmed` is true, blank otherwise. | +| Latest activity | Free-form, derived from the **cached** `last_status.recent_messages` combined with any `messages[]` ferried by a `trigger: "message"` line — not a fresh `get_status` per emit. Pick the most recent entry where `from_role == role`; render its `subject` (truncated to ~50 chars). Fall back to `—` when the role hasn't sent any messages this phase. | + +**Header line** (one line above the table): -If `has_unresolved_nacks` is true, add: ``` -NACKs: : "" +Phase: | Status: | Elapsed: s | Consensus: / | NACKs: ``` +- `/` = `sum(1 for a in agents.values() if a.confirmed) / len(agents)`. +- `` = `len(unresolved_nacks)`. + +**Optional rows below the table:** + +- **Unresolved NACK rows** — one per entry in `concurrent.consensus.unresolved_nacks` (structured field: `{reviewer, producer, reason, version}`). Render as `⚠️ : ""`. This replaces the previous separate `NACKs:` line. +- **Silent agent rows** — for any role in `running_agents` whose `elapsed_seconds` exceeds the silent threshold (10+ minutes by default; mirror the `agent-silent` detector) AND has zero messages in `recent_messages`, render `⚠️ : silent for ~m — no BRC messages`. + +The optional rows render only when their condition holds; omit them otherwise. + ### Consensus Fallback (when `concurrent.consensus` is missing) The `concurrent.consensus` object may not be present in all status responses (e.g., for non-BRC pipelines). When it is absent, **fall back to message-based consensus tracking** by classifying entries in `recent_messages`. (The `wait-status` JSON-line does not ship `recent_messages`; combine the cached `last_status.recent_messages` with any `messages` array ferried by a `trigger: "message"` JSON-line.): @@ -502,10 +535,11 @@ The `concurrent.consensus` object may not be present in all status responses (e. 2. **Identify roles using the `from_role` field** — each message includes `from_role` indicating which agent sent it. 3. Maintain an in-memory map of `{role: {last_message_type, last_message_time, message_count}}` built from `recent_messages` 4. Infer consensus state: if all roles listed in `running_agents` have sent `CONSENSUS_CONFIRMED` messages, consensus is likely complete -5. For the enhanced dashboard, approximate the fields: - - Confirmed count: roles with `CONSENSUS_CONFIRMED` messages - - Blocking: roles with no `CONSENSUS_CONFIRMED` message - - Unresolved NACKs: `CONSENSUS_NACK` messages not followed by a `CONSENSUS_PROPOSE` from the producer +5. For the per-role table (Path B above), approximate the fields when `concurrent.consensus` is missing: + - `Phase` cell: render `—` for every row. The `producer_phase` / `reviewer_phase` source is gone in fallback mode and message types do not give a reliable per-role phase mapping (e.g. a `CONSENSUS_PROPOSE` from a producer means the producer is in `PROPOSED`, but says nothing about reviewer phases on its own). `—` is the safe floor; do not invent a message-type-to-phase mapping. + - `Confirmed` cell: `✓` if the role has emitted a `CONSENSUS_CONFIRMED` message, blank otherwise + - Header `/` confirmed: count of roles with `CONSENSUS_CONFIRMED` messages + - Optional NACK rows: `CONSENSUS_NACK` messages not followed by a `CONSENSUS_PROPOSE` from the named producer (use `subject` to extract the reason) 6. Use `subject` only for supplementary detail (e.g., extracting NACK reasons or human-readable context for the dashboard) **Stall detection** — *Skip this block when `config.overseer_owns_host_detection` is `True` (issue #1962): the overseer's `agent-stall` / `agent-nack-unresolved` migrated detectors fire and the host receives them as `OVERSEER_ALERT` messages.* Track agent phase progression using wall-clock time (not poll counts, since poll interval varies). Flag an agent as potentially stalled when: @@ -938,6 +972,7 @@ Phase: - Show PR link if available in the pipeline data - List agents that ran (from `completed_agents`) - Note any agents that failed +- If the final emit (or the cached `last_status`) carried `concurrent.consensus`, render the per-role table from [Consensus Monitoring](#consensus-monitoring) one final time — gives the operator a closing snapshot of which roles confirmed and any leftover NACK / silent rows for the record. ### On failure: ``` @@ -1354,23 +1389,19 @@ Drive the pipeline through one Monitor invocation per quiet stretch. On entry: **Trigger allowlist:** `OVERSEER_ALERT`, `CONSENSUS_CONFIRMED`, `CONSENSUS_NACK`, `CONSENSUS_RE_REVIEW`, `phase.started`, `phase.completed`, `pipeline.completed`, `pipeline.failed`, `pipeline.cancelled`, `decision.created`. `decision.resolved` is excluded so the host doesn't self-wake on a `provide_input` it just submitted. -4. **Render the dashboard** on each line: +4. **Render the dashboard** on each line, picking the same two paths as Phase 3 (see [Phase 3 step 4](#phase-3--monitor) and [Consensus Monitoring](#consensus-monitoring) for the full column derivation): - ``` - --- Pipeline Status --- - Phase: | Status: | Elapsed: s - Consensus: / confirmed (when concurrent.consensus is present) - Recent: - ``` + - **Path A — non-BRC line (`concurrent.consensus` absent):** the 3-line compact form (`Phase / Status / Elapsed` + `Recent`). Deltas-only on subsequent emits is fine. + - **Path B — BRC line (`concurrent.consensus` present):** the per-role status table with the `Phase | Status | Elapsed | Consensus | NACKs` header and `Role | Phase | Confirmed | Latest activity` columns. Always render full state, including the optional `⚠️` NACK and silent-agent rows. - The JSON-line ships only the dashboard-relevant subset (`current_phase`, `status`, `phase_elapsed_seconds`, `concurrent.consensus`) — it does **not** include the full snapshot (agent list, recent_messages, pipeline metadata, `pending_decisions`). When you need the full envelope (e.g. on `decision.created` to render `pending_decisions` ahead of HITL), call `get_status(task_id)` as a one-shot and refresh `last_status`. + The JSON-line ships only the dashboard-relevant subset (`current_phase`, `status`, `phase_elapsed_seconds`, `concurrent.consensus`) — it does **not** include the full snapshot (agent list, recent_messages, pipeline metadata, `pending_decisions`). When you need the full envelope (e.g. on `decision.created` to render `pending_decisions` ahead of HITL, or to populate the table's `Latest activity` column from `recent_messages`), call `get_status(task_id)` as a one-shot and refresh `last_status`. 5. **State transitions:** - On `event_type: "decision.created"` → re-fetch the full snapshot via `get_status(task_id)` (the JSON-line does not carry `pending_decisions`) and handle the decision inline (see below). - On `status: "complete"` or `event_type: "pipeline.completed"` → exit, move to Phase S6. - On `status: "failed"` or `event_type: "pipeline.failed"` → apply the **failed status grace period** (see below) before exiting. -Keep the dashboard output concise. Only show changes from the previous emit when possible. +Path A keeps the dashboard concise via deltas — skip lines that didn't change from the previous emit. Path B always renders full state, including the optional NACK and silent-agent rows. **Important: `wait-status` blocks server-side and emits events as they arrive. Do NOT wrap the Monitor invocation in an outer `for`-loop or `sleep` — the CLI is already the loop, server-side, and Monitor surfaces each emitted line as its own notification. The skill's liveness guarantee comes from the CLI re-issuing the route call with the threaded cursor on every Path-B no-change return; intra-process loop, no LLM turn.** When Monitor's `timeout_ms` (or the 10-min Bash cap, if you're on the fallback path) forces the CLI to terminate, simply re-invoke with the latest `last_cursor` from your conversation context. See [Host-Side Waits](../../docs/reference/agent-wait-patterns.md#7-host-side-waits--egg-orch-pipeline-wait-status) for the event allowlist, exit-code contract, and concurrency model. @@ -1434,6 +1465,7 @@ Status: Success - Show PR link if available in the pipeline data - If no PR link is found, check `gh pr list --repo --state open --json number,title,url --limit 5` to find a recently created PR +- If the final emit (or the cached `last_status`) carried `concurrent.consensus`, render the per-role table from [Consensus Monitoring](#consensus-monitoring) one final time — gives the operator a closing snapshot of which roles confirmed and any leftover NACK / silent rows for the record. (Lightweight pipelines start at implement, so BRC consensus data is the common case here.) ### On failure: ``` diff --git a/tests/config/test_ci_config.py b/tests/config/test_ci_config.py index 7e3c8d711c..809565cf95 100644 --- a/tests/config/test_ci_config.py +++ b/tests/config/test_ci_config.py @@ -42,7 +42,13 @@ def test_pyproject_testpaths_include_all_suites(self): ) def test_pyproject_has_required_markers(self): - """Standard markers must be defined to avoid warnings.""" + """Standard markers must be defined to avoid warnings. + + Issue #2474 retired the docker-compose runtime, deleting the + ``functional`` tier (``tests/functional/``) and the real-LLM + ``e2e`` / ``agent_flaky`` tiers (``integration_tests/test_e2e_*``). + Only ``integration`` (k3s) and ``security`` markers remain. + """ import tomllib with open(REPO_ROOT / "pyproject.toml", "rb") as f: @@ -50,7 +56,7 @@ def test_pyproject_has_required_markers(self): markers = cfg["tool"]["pytest"]["ini_options"]["markers"] marker_names = {m.split(":")[0].strip() for m in markers} - required = {"integration", "functional", "e2e", "security", "agent_flaky"} + required = {"integration", "security"} assert required.issubset(marker_names), f"Missing markers: {required - marker_names}" def test_pyproject_has_kubernetes_dev_dependency(self): diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py deleted file mode 100644 index d4bdbda050..0000000000 --- a/tests/functional/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -Functional tests for egg gateway components. - -Functional tests sit between unit tests and full integration tests: -- Use Docker containers for realistic testing -- Lighter-weight fixtures than integration_tests/ -- Focus on component pairs rather than full system -- Target ~5-10s startup vs ~30s for full stack - -Test modules: -- test_git_wrappers.py: Git command routing and validation -- test_session_lifecycle.py: Session create/heartbeat/delete flow -- test_network_modes.py: Private vs public mode behavior -""" diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py deleted file mode 100644 index a01525ac4e..0000000000 --- a/tests/functional/conftest.py +++ /dev/null @@ -1,487 +0,0 @@ -""" -Shared fixtures for functional tests. - -Functional tests sit between unit tests and full integration tests: -- They use Docker containers for realistic testing -- But use lighter-weight fixtures than integration_tests/ -- Focus on component pairs rather than full system -- Target ~5-10s startup vs ~30s for full stack - -These fixtures reuse patterns from integration_tests/conftest.py -but with module-scoped lifecycle for faster iteration. -""" - -import os -import secrets -import shutil -import subprocess -import tempfile -import time -from collections.abc import Callable, Generator -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import pytest -import requests - -from tests.utils.gateway_client import ( - GatewayClientMixin, - docker_available, - wait_for_healthy, -) - -# Project root (two levels up from tests/functional/) -PROJECT_ROOT = Path(__file__).parent.parent.parent - -# Network configuration for functional tests -# Uses 172.42.x to avoid collision with integration tests (172.40/41) -FUNCTIONAL_SUBNET = "172.42.0.0/24" -GATEWAY_IP = "172.42.0.2" -GATEWAY_PORT = 9848 - -# Counter for allocating unique container IPs -_next_container_ip_suffix = 100 - - -def _write_minimal_config(config_dir: str, launcher_secret: str) -> None: - """Generate minimal gateway config for functional tests. - - Creates lightweight config without squid proxy for faster startup. - """ - config_path = Path(config_dir) - config_path.mkdir(parents=True, exist_ok=True) - - # repositories.yaml -- minimal config - (config_path / "repositories.yaml").write_text( - """\ -github_username: test-user -bot_username: james-in-a-box - -writable_repos: - - test-owner/test-repo - -repo_settings: - test-owner/test-repo: - auth_mode: bot - -user_mode: - github_user: test-user - git_name: Test User - git_email: test@example.com - -local_repos: - paths: - - /home/egg/repos/test-repo -""" - ) - - # secrets.env -- minimal for functional tests (no real API calls) - (config_path / "secrets.env").write_text( - "CLAUDE_CODE_OAUTH_TOKEN=dummy-anthropic-token\n" - "GATEWAY_BOT_NAME=james-in-a-box\n" - "GATEWAY_BOT_BRANCH_PREFIX=james-in-a-box\n" - ) - os.chmod(config_path / "secrets.env", 0o600) - - # launcher-secret - (config_path / "launcher-secret").write_text(launcher_secret) - os.chmod(config_path / "launcher-secret", 0o600) - - -@dataclass -class MinimalGateway(GatewayClientMixin): - """Lightweight gateway instance for functional tests. - - Unlike EggStack (session-scoped, full Docker Compose), this is: - - Module-scoped for faster test isolation - - Single container (no proxy, no squid) - - Faster startup (~5-10s vs ~30s) - - Inherits common API methods from GatewayClientMixin to reduce - duplication with integration_tests/conftest.py:EggStack. - """ - - gateway_url: str - gateway_ip: str - gateway_port: int - launcher_secret: str - container_id: str - network_name: str - config_dir: str - source_ip: str = "" - - -@pytest.fixture(scope="module") -def minimal_gateway() -> Generator[MinimalGateway]: - """Module-scoped fixture: start a lightweight gateway container. - - Unlike the full egg_stack, this: - - Starts a single gateway container directly (no docker-compose) - - Skips squid proxy for faster startup - - Uses a separate network (172.42.x) to avoid conflicts - """ - if not docker_available(): - pytest.skip("Docker is not available") - - # Generate unique identifiers - project_id = f"func-{os.getpid()}-{int(time.time())}" - network_name = f"egg-func-{project_id}" - container_id = f"egg-gateway-{project_id}" - launcher_secret = secrets.token_urlsafe(32) - - # Create temp config directory - config_dir = tempfile.mkdtemp(prefix="egg-func-config-") - _write_minimal_config(config_dir, launcher_secret) - - try: - # Create network - subprocess.run( - [ - "docker", - "network", - "create", - "--subnet", - FUNCTIONAL_SUBNET, - network_name, - ], - capture_output=True, - timeout=30, - check=True, - ) - - # Build gateway image if needed - dockerfile = PROJECT_ROOT / "gateway" / "Dockerfile" - if dockerfile.exists(): - subprocess.run( - [ - "docker", - "build", - "-t", - "egg-gateway:func-test", - "-f", - str(dockerfile), - str(PROJECT_ROOT), - ], - capture_output=True, - timeout=300, - check=True, - ) - - # Start gateway container - subprocess.run( # noqa: EGG100 - test fixture starts minimal gateway container - [ - "docker", - "run", - "-d", - "--name", - container_id, - "--network", - network_name, - "--ip", - GATEWAY_IP, - "-p", - f"0:{GATEWAY_PORT}", - "-e", - f"EGG_LAUNCHER_SECRET={launcher_secret}", - "-e", - "EGG_REPO_CONFIG=/config/repositories.yaml", - "-e", - "GITHUB_USER_TOKEN=dummy-github-token", - "-e", - "HOST_UID=1000", - "-e", - "HOST_GID=1000", - "-e", - "EGG_USER_GIT_NAME=test-user", - "-e", - "EGG_USER_GIT_EMAIL=test@example.com", - "-v", - f"{config_dir}/repositories.yaml:/config/repositories.yaml:ro", - "-v", - f"{config_dir}/secrets.env:/secrets/secrets.env:ro", - "-v", - f"{config_dir}/launcher-secret:/secrets/launcher-secret:ro", - "egg-gateway:func-test", - ], - capture_output=True, - text=True, - timeout=60, - check=True, - ) - - # Get mapped port - port_result = subprocess.run( - [ - "docker", - "port", - container_id, - str(GATEWAY_PORT), - ], - capture_output=True, - text=True, - timeout=10, - check=True, - ) - host_port = port_result.stdout.strip().split(":")[-1] - gateway_url = f"http://localhost:{host_port}" - - # Wait for gateway to become healthy - if not wait_for_healthy(gateway_url, timeout=60): - # Dump logs for debugging - logs = subprocess.run( - ["docker", "logs", container_id], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - pytest.fail( - f"Gateway did not become healthy within 60s.\nLogs:\n{logs.stdout}\n{logs.stderr}" - ) - - gateway = MinimalGateway( - gateway_url=gateway_url, - gateway_ip=GATEWAY_IP, - gateway_port=int(host_port), - launcher_secret=launcher_secret, - container_id=container_id, - network_name=network_name, - config_dir=config_dir, - ) - - # detect_source_ip() is inside try block to ensure cleanup on failure - gateway.detect_source_ip() - - yield gateway - - finally: - # Cleanup - subprocess.run( - ["docker", "rm", "-f", container_id], - capture_output=True, - timeout=15, - check=False, - ) - subprocess.run( - ["docker", "network", "rm", network_name], - capture_output=True, - timeout=15, - check=False, - ) - shutil.rmtree(config_dir, ignore_errors=True) - - -@pytest.fixture -def functional_session( - minimal_gateway: MinimalGateway, -) -> Generator[dict[str, Any]]: - """Function-scoped fixture: create a gateway session for test isolation. - - Creates a unique session per test and cleans it up afterwards. - """ - container_id = f"func-test-{os.getpid()}-{time.time_ns()}" - result = minimal_gateway.create_session( - container_id=container_id, - mode="private", - ) - - if not result.get("success"): - pytest.skip(f"Could not create test session: {result.get('message')}") - - session_data = result.get("data", result) - - # Validate session_token is present before proceeding - token = session_data.get("session_token") - if not token: - pytest.fail( - f"Session created successfully but missing session_token. Response data: {session_data}" - ) - - session_data["container_id"] = container_id - yield session_data - - # Cleanup - token is guaranteed to exist from validation above - minimal_gateway.delete_session(token) - - -@dataclass -class GitCommandResult: - """Result of a git command execution via the gateway.""" - - success: bool - output: str - error: str - status_code: int - raw_response: dict[str, Any] - - -@pytest.fixture -def git_command_tester( - minimal_gateway: MinimalGateway, - functional_session: dict[str, Any], -) -> Callable[..., GitCommandResult]: - """Factory fixture for testing git command handling. - - Usage: - def test_git_status(git_command_tester): - result = git_command_tester("status", args=["--porcelain"]) - assert result.success or result.status_code == 400 # repo may not exist - """ - - def _test( - operation: str, - *, - repo_path: str = "/home/egg/repos/test-repo", - args: list[str] | None = None, - ) -> GitCommandResult: - token = functional_session.get("session_token") - resp = minimal_gateway.api_request( - "POST", - "/api/v1/git/execute", - token=token, - json_data={ - "repo_path": repo_path, - "operation": operation, - "args": args or [], - "container_id": functional_session.get("container_id"), - }, - ) - - try: - body = resp.json() - except requests.exceptions.JSONDecodeError: - body = {"success": False, "message": resp.text} - - data = body.get("data", body) - return GitCommandResult( - success=body.get("success", False), - output=data.get("output", data.get("stdout", "")), - error=data.get("stderr", body.get("message", "")), - status_code=resp.status_code, - raw_response=body, - ) - - return _test - - -@dataclass -class GhCommandResult: - """Result of a gh command execution via the gateway.""" - - success: bool - output: str - error: str - status_code: int - raw_response: dict[str, Any] - - -@pytest.fixture -def gh_command_tester( - minimal_gateway: MinimalGateway, - functional_session: dict[str, Any], -) -> Callable[..., GhCommandResult]: - """Factory fixture for testing gh command handling. - - Usage: - def test_gh_version(gh_command_tester): - result = gh_command_tester(["--version"]) - assert result.success - assert "gh version" in result.output - """ - - def _test( - args: list[str], - *, - repo: str | None = None, - ) -> GhCommandResult: - token = functional_session.get("session_token") - json_data: dict[str, Any] = {"args": args} - if repo: - json_data["repo"] = repo - - resp = minimal_gateway.api_request( - "POST", - "/api/v1/gh/execute", - token=token, - json_data=json_data, - ) - - try: - body = resp.json() - except requests.exceptions.JSONDecodeError: - body = {"success": False, "message": resp.text} - - data = body.get("data", body) - return GhCommandResult( - success=body.get("success", False), - output=data.get("output", data.get("stdout", "")), - error=data.get("stderr", body.get("message", "")), - status_code=resp.status_code, - raw_response=body, - ) - - return _test - - -@pytest.fixture -def session_lifecycle_tester( - minimal_gateway: MinimalGateway, -) -> Generator[Callable[..., dict[str, Any]]]: - """Factory fixture for testing session lifecycle operations. - - Usage: - def test_session_create_delete(session_lifecycle_tester): - result = session_lifecycle_tester("create") - assert result["success"] - token = result["data"]["session_token"] - result = session_lifecycle_tester("delete", token=token) - assert result["success"] - """ - created_tokens: list[str] = [] - - def _test( - operation: str, - *, - token: str | None = None, - container_id: str | None = None, - mode: str = "private", - ) -> dict[str, Any]: - if operation == "create": - if container_id is None: - container_id = f"lifecycle-{time.time_ns()}" - result = minimal_gateway.create_session( - container_id=container_id, - mode=mode, - ) - if result.get("success"): - t = result.get("data", result).get("session_token") - if t: - created_tokens.append(t) - return result - - elif operation == "delete": - if not token: - return {"success": False, "message": "Token required for delete"} - result = minimal_gateway.delete_session(token) - if result.get("success") and token in created_tokens: - created_tokens.remove(token) - return result - - elif operation == "heartbeat": - if not token: - return {"success": False, "message": "Token required for heartbeat"} - return minimal_gateway.heartbeat(token) - - elif operation == "list": - return minimal_gateway.list_sessions() - - else: - return {"success": False, "message": f"Unknown operation: {operation}"} - - try: - yield _test - finally: - # Cleanup any sessions created during test, even if test raises exception - for t in created_tokens: - minimal_gateway.delete_session(t) diff --git a/tests/functional/test_git_wrappers.py b/tests/functional/test_git_wrappers.py deleted file mode 100644 index ff74e3f18a..0000000000 --- a/tests/functional/test_git_wrappers.py +++ /dev/null @@ -1,314 +0,0 @@ -""" -Functional tests for git command routing via the gateway. - -These tests verify that git commands are properly: -- Routed through the gateway API -- Validated against allowlists -- Blocked for disallowed operations -- Redirected to proper endpoints for network operations - -Focus: command routing, argument validation, error message quality. -""" - -import pytest - -from tests.functional.conftest import GitCommandResult - - -@pytest.mark.functional -class TestGitCommandRouting: - """Tests for git command interception and routing.""" - - def test_status_command_accepted(self, git_command_tester): - """git status is a valid operation (routed through gateway).""" - result: GitCommandResult = git_command_tester("status") - # May fail with 400 if repo doesn't exist, but should not be auth error - assert result.status_code not in (401, 403), ( - f"Status command should not be auth-rejected: {result.error}" - ) - - def test_status_with_porcelain_flag(self, git_command_tester): - """git status --porcelain accepts the porcelain flag.""" - result = git_command_tester("status", args=["--porcelain"]) - assert result.status_code not in (401, 403) - - def test_log_command_accepted(self, git_command_tester): - """git log is a valid operation.""" - result = git_command_tester("log", args=["--oneline", "-5"]) - assert result.status_code not in (401, 403) - - def test_diff_command_accepted(self, git_command_tester): - """git diff is a valid operation.""" - result = git_command_tester("diff") - assert result.status_code not in (401, 403) - - def test_branch_command_accepted(self, git_command_tester): - """git branch is a valid operation.""" - result = git_command_tester("branch", args=["--list"]) - assert result.status_code not in (401, 403) - - def test_rev_parse_command_accepted(self, git_command_tester): - """git rev-parse is a valid operation (used for repo detection).""" - result = git_command_tester("rev-parse", args=["--git-dir"]) - assert result.status_code not in (401, 403) - - -@pytest.mark.functional -class TestGitCommandBlocking: - """Tests for git commands that should be blocked.""" - - def test_gc_command_blocked(self, git_command_tester): - """git gc is not in the allowlist and should be blocked.""" - result = git_command_tester("gc") - assert result.status_code == 403 - assert "not allowed" in result.error.lower() or "gc" in result.error.lower() - - def test_fsck_command_blocked(self, git_command_tester): - """git fsck is not in the allowlist and should be blocked.""" - result = git_command_tester("fsck") - assert result.status_code == 403 - - def test_prune_command_blocked(self, git_command_tester): - """git prune is not in the allowlist and should be blocked.""" - result = git_command_tester("prune") - assert result.status_code == 403 - - def test_arbitrary_command_blocked(self, git_command_tester): - """Arbitrary commands that aren't real git commands are blocked.""" - result = git_command_tester("notarealcommand") - assert result.status_code == 403 - - -@pytest.mark.functional -class TestNetworkOperationsRedirect: - """Tests for network operations that should use dedicated endpoints.""" - - def test_push_via_execute_blocked(self, git_command_tester): - """git push via /git/execute should redirect to /git/push.""" - result = git_command_tester("push") - # Should be blocked (400 or 403) with message about dedicated endpoint - assert result.status_code in (400, 403) - - def test_fetch_via_execute_blocked(self, git_command_tester): - """git fetch via /git/execute should redirect to /git/fetch.""" - result = git_command_tester("fetch") - assert result.status_code in (400, 403) - - def test_ls_remote_via_execute_blocked(self, git_command_tester): - """git ls-remote via /git/execute should use dedicated endpoint.""" - result = git_command_tester("ls-remote") - assert result.status_code in (400, 403) - - -@pytest.mark.functional -class TestGitArgumentValidation: - """Tests for git argument validation and sanitization.""" - - def test_dangerous_flag_blocked(self, git_command_tester): - """Dangerous flags like --exec should be blocked.""" - # git log with --exec-path could be exploited - result = git_command_tester("log", args=["--exec-path=/tmp/malicious"]) - # Should be blocked or sanitized - assert result.status_code in (400, 403) or not result.success - - def test_add_with_normal_paths(self, git_command_tester): - """git add with normal file paths is accepted.""" - result = git_command_tester("add", args=["--dry-run", "file.txt"]) - # May fail because file doesn't exist, but should not be 403 - assert result.status_code != 403 - - def test_commit_with_message(self, git_command_tester): - """git commit with -m flag is accepted.""" - result = git_command_tester("commit", args=["-m", "test commit", "--dry-run"]) - # May fail because nothing to commit, but should not be 403 - assert result.status_code != 403 - - def test_config_local_accepted(self, git_command_tester): - """git config for local repo is accepted.""" - result = git_command_tester("config", args=["--get", "user.name"]) - # May fail if config not set, but should not be 403 - assert result.status_code != 403 - - -@pytest.mark.functional -class TestGitRepoPathValidation: - """Tests for repository path validation.""" - - def test_repos_parent_directory_rejected(self, git_command_tester): - """Running git in the repos parent directory is rejected.""" - result = git_command_tester("status", repo_path="/home/egg/repos") - # Should fail with a clear error about not being a repo - assert result.status_code == 400 - assert "not a git repository" in result.error.lower() or "directory" in result.error.lower() - - def test_path_traversal_blocked(self, git_command_tester): - """Path traversal attempts should be blocked.""" - result = git_command_tester("status", repo_path="/home/egg/repos/../../../etc") - assert result.status_code in (400, 403) - - def test_absolute_path_outside_repos_blocked(self, git_command_tester): - """Absolute paths outside allowed directories should be blocked.""" - result = git_command_tester("status", repo_path="/etc/passwd") - assert result.status_code in (400, 403) - - -@pytest.mark.functional -class TestGhCommandRouting: - """Tests for gh (GitHub CLI) command routing.""" - - def test_gh_version_works(self, gh_command_tester): - """gh --version executes successfully.""" - result = gh_command_tester(["--version"]) - # Should succeed or at least not be auth failure - assert result.status_code != 401 - if result.success: - assert "gh" in result.output.lower() - - def test_gh_help_works(self, gh_command_tester): - """gh --help executes successfully.""" - result = gh_command_tester(["--help"]) - assert result.status_code != 401 - - def test_gh_api_accessible(self, gh_command_tester): - """gh api command is accessible (may fail without real auth).""" - result = gh_command_tester(["api", "--help"]) - # Should be allowed (may fail for auth reasons but not 403) - assert result.status_code != 403 - - def test_gh_pr_list_accessible(self, gh_command_tester): - """gh pr list command is accessible.""" - result = gh_command_tester(["pr", "list", "--help"]) - assert result.status_code != 403 - - -@pytest.mark.functional -class TestGhCommandBlocking: - """Tests for gh commands that should be blocked.""" - - def test_gh_auth_token_blocked(self, gh_command_tester): - """gh auth token should not expose credentials. - - The gateway should either: - 1. Block the command entirely (403/401 status) - 2. Return a dummy/sanitized token that clearly isn't real - """ - result = gh_command_tester(["auth", "token"]) - - # Prefer explicit blocking - this is the secure path - if result.status_code in (401, 403): - # Command was blocked - this is the expected behavior - return - - # If command "succeeded", verify no real tokens leaked - # Real GitHub tokens have specific patterns we should NOT see - output = result.output.strip() - if output: - # GitHub tokens start with specific prefixes - assert not output.startswith("ghp_"), "Real GitHub personal access token exposed" - assert not output.startswith("gho_"), "Real GitHub OAuth token exposed" - assert not output.startswith("ghs_"), "Real GitHub server-to-server token exposed" - assert not output.startswith("ghu_"), "Real GitHub user-to-server token exposed" - # Should only contain dummy/test tokens - assert "dummy" in output.lower() or len(output) < 10, ( - f"Unexpected token output: {output[:50]}..." - ) - - -@pytest.mark.functional -class TestErrorMessageQuality: - """Tests for clear, helpful error messages.""" - - def test_disallowed_operation_shows_allowed_list(self, git_command_tester): - """Error for disallowed operation should list allowed operations.""" - result = git_command_tester("gc") - # Error should mention what IS allowed - assert "allowed" in result.error.lower() - - def test_network_op_error_mentions_endpoint(self, git_command_tester): - """Error for network ops should mention the correct endpoint.""" - result = git_command_tester("push") - # Should mention the dedicated endpoint - assert "endpoint" in result.error.lower() or "/git/push" in result.error - - def test_repo_path_error_is_clear(self, git_command_tester): - """Error for invalid repo path should be descriptive.""" - result = git_command_tester("status", repo_path="/home/egg/repos") - # Should explain why it failed - assert len(result.error) > 10 # Non-trivial error message - - -@pytest.mark.functional -class TestApiResponseFormat: - """Tests for API response format consistency.""" - - def test_response_is_json(self, minimal_gateway, functional_session): - """All responses should be JSON, not HTML error pages.""" - token = functional_session.get("session_token") - resp = minimal_gateway.api_request( - "POST", - "/api/v1/git/execute", - token=token, - json_data={ - "repo_path": "/home/egg/repos/test-repo", - "operation": "status", - }, - ) - content_type = resp.headers.get("Content-Type", "") - assert "json" in content_type.lower(), f"Expected JSON, got {content_type}" - # Should be valid JSON - resp.json() - - def test_error_response_has_success_field(self, git_command_tester): - """Error responses should have success=false.""" - result = git_command_tester("gc") # Blocked operation - assert result.raw_response.get("success") is False - - def test_error_response_has_message(self, git_command_tester): - """Error responses should have a message field.""" - result = git_command_tester("gc") - assert "message" in result.raw_response or "error" in result.raw_response - - -@pytest.mark.functional -class TestMissingRequestFields: - """Tests for handling missing required fields.""" - - def test_missing_operation_rejected(self, minimal_gateway, functional_session): - """Request without operation field should be rejected.""" - token = functional_session.get("session_token") - resp = minimal_gateway.api_request( - "POST", - "/api/v1/git/execute", - token=token, - json_data={ - "repo_path": "/home/egg/repos/test-repo", - # Missing "operation" - }, - ) - assert resp.status_code == 400 - - def test_missing_repo_path_rejected(self, minimal_gateway, functional_session): - """Request without repo_path field should be rejected.""" - token = functional_session.get("session_token") - resp = minimal_gateway.api_request( - "POST", - "/api/v1/git/execute", - token=token, - json_data={ - "operation": "status", - # Missing "repo_path" - }, - ) - assert resp.status_code == 400 - - def test_empty_body_rejected(self, minimal_gateway, functional_session): - """Request with empty body should be rejected.""" - token = functional_session.get("session_token") - resp = minimal_gateway.api_request( - "POST", - "/api/v1/git/execute", - token=token, - json_data=None, - ) - # 400 if parsed as empty JSON, 415 if no Content-Type header sent - assert resp.status_code in (400, 415) diff --git a/tests/functional/test_network_modes.py b/tests/functional/test_network_modes.py deleted file mode 100644 index d7e5fd44ca..0000000000 --- a/tests/functional/test_network_modes.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Functional tests for network mode behavior. - -These tests verify that private and public modes behave correctly: -- Private mode: restricted network access, proxy-routed -- Public mode: broader network access, direct connections - -Focus: mode-specific behavior, mode transitions, mode validation. -""" - -import time - -import pytest - - -@pytest.mark.functional -class TestNetworkModeCreation: - """Tests for session creation with different network modes.""" - - def test_private_mode_accepted(self, session_lifecycle_tester): - """Sessions can be created in private mode.""" - result = session_lifecycle_tester("create", mode="private") - assert result.get("success") is True - - def test_public_mode_accepted(self, session_lifecycle_tester): - """Sessions can be created in public mode.""" - result = session_lifecycle_tester("create", mode="public") - assert result.get("success") is True - - def test_invalid_mode_rejected(self, minimal_gateway): - """Invalid mode values should be rejected.""" - resp = minimal_gateway.api_request( - "POST", - "/api/v1/sessions/create", - token=minimal_gateway.launcher_secret, - json_data={ - "container_id": "test-invalid-mode", - "container_ip": "172.42.0.100", - "mode": "invalid-mode", - "repos": ["test-owner/test-repo"], - "uid": 1000, - "gid": 1000, - }, - ) - # Should be rejected as bad request - assert resp.status_code in (400, 422) - - def test_missing_mode_has_default(self, minimal_gateway): - """Sessions created without explicit mode should have a default.""" - resp = minimal_gateway.api_request( - "POST", - "/api/v1/sessions/create", - token=minimal_gateway.launcher_secret, - json_data={ - "container_id": f"test-no-mode-{time.time_ns()}", - "container_ip": "172.42.0.100", - # No "mode" field - "repos": ["test-owner/test-repo"], - "uid": 1000, - "gid": 1000, - }, - ) - if resp.status_code == 200: - body = resp.json() - data = body.get("data", body) - # Should have a mode set (either private or public as default) - assert "mode" in data - - -@pytest.mark.functional -class TestPrivateModeOperations: - """Tests for operations in private mode.""" - - def test_git_status_in_private_mode(self, git_command_tester): - """Git status works in private mode (default fixture mode).""" - result = git_command_tester("status") - # Should not be blocked by mode restrictions - assert result.status_code != 403 or "mode" not in result.error.lower() - - def test_git_log_in_private_mode(self, git_command_tester): - """Git log works in private mode.""" - result = git_command_tester("log", args=["--oneline", "-1"]) - assert result.status_code != 403 or "mode" not in result.error.lower() - - -@pytest.mark.functional -class TestPublicModeOperations: - """Tests for operations in public mode.""" - - def test_session_creation_public_mode(self, minimal_gateway): - """Can create and use a public mode session.""" - # Create public mode session - result = minimal_gateway.create_session( - container_id=f"public-test-{time.time_ns()}", - mode="public", - ) - assert result.get("success") is True - token = result.get("data", result).get("session_token") - - try: - # Use the session - resp = minimal_gateway.api_request( - "POST", - "/api/v1/git/execute", - token=token, - json_data={ - "repo_path": "/home/egg/repos/test-repo", - "operation": "status", - }, - ) - # Should work (may fail for other reasons but not mode-related) - assert resp.status_code != 403 or "mode" not in resp.text.lower() - finally: - minimal_gateway.delete_session(token) - - -@pytest.mark.functional -class TestModeInSessionInfo: - """Tests for mode information in session responses.""" - - def test_list_shows_session_modes(self, session_lifecycle_tester): - """Session list includes mode information.""" - # Create sessions in different modes - session_lifecycle_tester("create", mode="private") - session_lifecycle_tester("create", mode="public") - - # List sessions - list_result = session_lifecycle_tester("list") - sessions = list_result.get("data", list_result).get("sessions", []) - - # Each session should have a mode - for session in sessions: - assert "mode" in session - - def test_heartbeat_preserves_mode(self, session_lifecycle_tester): - """Heartbeat response maintains mode information.""" - # Create with explicit mode - create_result = session_lifecycle_tester("create", mode="private") - token = create_result.get("data", create_result).get("session_token") - - # Heartbeat - heartbeat_result = session_lifecycle_tester("heartbeat", token=token) - data = heartbeat_result.get("data", heartbeat_result) - - # Mode should be preserved if returned - if "mode" in data: - assert data["mode"] == "private" - - -@pytest.mark.functional -class TestModeIsolation: - """Tests for isolation between modes.""" - - def test_private_and_public_sessions_coexist(self, session_lifecycle_tester): - """Private and public sessions can coexist.""" - # Create both types - private_result = session_lifecycle_tester( - "create", mode="private", container_id=f"private-{time.time_ns()}" - ) - public_result = session_lifecycle_tester( - "create", mode="public", container_id=f"public-{time.time_ns()}" - ) - - assert private_result.get("success") is True - assert public_result.get("success") is True - - # Both should be in the list - list_result = session_lifecycle_tester("list") - sessions = list_result.get("data", list_result).get("sessions", []) - modes = [s.get("mode") for s in sessions] - - assert "private" in modes - assert "public" in modes - - -@pytest.mark.functional -class TestHealthEndpointModeInfo: - """Tests for mode information in health responses.""" - - def test_health_endpoint_accessible(self, minimal_gateway): - """Health endpoint is accessible regardless of mode.""" - health = minimal_gateway.health_check() - assert health.get("status") in ("healthy", "degraded") - - def test_health_shows_session_count(self, minimal_gateway, functional_session): - """Health endpoint shows active session count.""" - health = minimal_gateway.health_check() - assert "active_sessions" in health - assert isinstance(health["active_sessions"], int) - assert health["active_sessions"] >= 1 # At least the functional_session - - -@pytest.mark.functional -class TestNetworkModeValidation: - """Tests for network mode validation.""" - - def test_empty_mode_handled(self, minimal_gateway): - """Empty mode string is handled.""" - resp = minimal_gateway.api_request( - "POST", - "/api/v1/sessions/create", - token=minimal_gateway.launcher_secret, - json_data={ - "container_id": "test-empty-mode", - "container_ip": "172.42.0.100", - "mode": "", # Empty string - "repos": ["test-owner/test-repo"], - "uid": 1000, - "gid": 1000, - }, - ) - # Should be rejected or use default - if resp.status_code == 200: - body = resp.json() - data = body.get("data", body) - # If accepted, should have a valid mode - assert data.get("mode") in ("private", "public") - - def test_case_sensitivity_of_mode(self, minimal_gateway): - """Mode values are case-sensitive or normalized.""" - resp = minimal_gateway.api_request( - "POST", - "/api/v1/sessions/create", - token=minimal_gateway.launcher_secret, - json_data={ - "container_id": "test-mode-case", - "container_ip": "172.42.0.100", - "mode": "PRIVATE", # Uppercase - "repos": ["test-owner/test-repo"], - "uid": 1000, - "gid": 1000, - }, - ) - # Should either work (normalized) or be rejected - # Should not cause a server error - assert resp.status_code != 500 - - -@pytest.mark.functional -class TestGatewayNetworkState: - """Tests for gateway network state handling.""" - - def test_gateway_reports_service_name(self, minimal_gateway): - """Gateway health identifies itself.""" - health = minimal_gateway.health_check() - assert health.get("service") == "gateway" - - def test_gateway_reports_client_ip(self, minimal_gateway): - """Gateway can detect client IP.""" - health = minimal_gateway.health_check() - assert "client_ip" in health - # Should be a valid IP-like string - client_ip = health["client_ip"] - assert "." in client_ip or ":" in client_ip # IPv4 or IPv6 diff --git a/tests/functional/test_session_lifecycle.py b/tests/functional/test_session_lifecycle.py deleted file mode 100644 index 120c1efba6..0000000000 --- a/tests/functional/test_session_lifecycle.py +++ /dev/null @@ -1,345 +0,0 @@ -""" -Functional tests for session lifecycle management. - -These tests verify the full create → heartbeat → delete flow -and edge cases in session management. - -Focus: session state transitions, TTL handling, cleanup behavior. -""" - -import time - -import pytest - - -@pytest.mark.functional -class TestSessionCreation: - """Tests for session creation.""" - - def test_create_returns_token(self, session_lifecycle_tester): - """Session creation returns a session token.""" - result = session_lifecycle_tester("create") - assert result.get("success") is True, f"Creation failed: {result}" - data = result.get("data", result) - assert "session_token" in data - assert len(data["session_token"]) > 20 # Non-trivial token - - def test_create_returns_filtered_repos(self, session_lifecycle_tester): - """Session creation returns filtered repos list.""" - result = session_lifecycle_tester("create") - data = result.get("data", result) - assert "filtered_repos" in data - - def test_create_returns_worktrees(self, session_lifecycle_tester): - """Session creation returns worktrees mapping.""" - result = session_lifecycle_tester("create") - data = result.get("data", result) - assert "worktrees" in data - - def test_create_public_mode(self, session_lifecycle_tester): - """Session can be created in public mode.""" - result = session_lifecycle_tester("create", mode="public") - assert result.get("success") is True - - def test_create_with_custom_container_id(self, session_lifecycle_tester): - """Session can be created with a custom container ID.""" - custom_id = f"custom-container-{time.time_ns()}" - result = session_lifecycle_tester("create", container_id=custom_id) - assert result.get("success") is True - - -@pytest.mark.functional -class TestSessionDeletion: - """Tests for session deletion.""" - - def test_delete_existing_session(self, session_lifecycle_tester): - """Deleting an existing session succeeds.""" - # Create - create_result = session_lifecycle_tester("create") - token = create_result.get("data", create_result).get("session_token") - assert token - - # Delete - delete_result = session_lifecycle_tester("delete", token=token) - assert delete_result.get("success") is True - - def test_delete_nonexistent_session_fails(self, session_lifecycle_tester): - """Deleting a non-existent session fails gracefully.""" - result = session_lifecycle_tester("delete", token="nonexistent-token-abc123") - assert result.get("success") is False - - def test_double_delete_fails(self, session_lifecycle_tester): - """Deleting the same session twice fails on the second attempt.""" - # Create - create_result = session_lifecycle_tester("create") - token = create_result.get("data", create_result).get("session_token") - - # Delete first time - result1 = session_lifecycle_tester("delete", token=token) - assert result1.get("success") is True - - # Delete second time - result2 = session_lifecycle_tester("delete", token=token) - assert result2.get("success") is False - - def test_delete_clears_from_list(self, session_lifecycle_tester): - """Deleted session no longer appears in session list.""" - # Create with unique container ID for identification - container_id = f"delete-test-{time.time_ns()}" - create_result = session_lifecycle_tester("create", container_id=container_id) - token = create_result.get("data", create_result).get("session_token") - - # Verify it's in the list - list_before = session_lifecycle_tester("list") - sessions_before = list_before.get("data", list_before).get("sessions", []) - container_ids_before = [s.get("container_id") for s in sessions_before] - assert container_id in container_ids_before - - # Delete - session_lifecycle_tester("delete", token=token) - - # Verify it's gone - list_after = session_lifecycle_tester("list") - sessions_after = list_after.get("data", list_after).get("sessions", []) - container_ids_after = [s.get("container_id") for s in sessions_after] - assert container_id not in container_ids_after - - -@pytest.mark.functional -class TestSessionHeartbeat: - """Tests for session heartbeat and TTL extension.""" - - def test_heartbeat_succeeds(self, session_lifecycle_tester): - """Heartbeat for valid session succeeds.""" - # Create - create_result = session_lifecycle_tester("create") - token = create_result.get("data", create_result).get("session_token") - - # Heartbeat - result = session_lifecycle_tester("heartbeat", token=token) - assert result.get("success") is True - - def test_heartbeat_returns_expiration(self, session_lifecycle_tester): - """Heartbeat returns the updated expiration time.""" - # Create - create_result = session_lifecycle_tester("create") - token = create_result.get("data", create_result).get("session_token") - - # Heartbeat - result = session_lifecycle_tester("heartbeat", token=token) - data = result.get("data", result) - assert "expires_at" in data - - def test_heartbeat_extends_ttl(self, session_lifecycle_tester): - """Heartbeat extends the session TTL.""" - # Create - create_result = session_lifecycle_tester("create") - token = create_result.get("data", create_result).get("session_token") - - # First heartbeat to get initial expiry - result1 = session_lifecycle_tester("heartbeat", token=token) - initial_expiry = result1.get("data", result1).get("expires_at") - assert initial_expiry is not None - - # Wait a moment then heartbeat again - time.sleep(0.1) - result2 = session_lifecycle_tester("heartbeat", token=token) - new_expiry = result2.get("data", result2).get("expires_at") - - # TTL should be extended (or at least not decreased) - assert new_expiry >= initial_expiry - - def test_heartbeat_invalid_token_fails(self, session_lifecycle_tester): - """Heartbeat for invalid token fails.""" - result = session_lifecycle_tester("heartbeat", token="invalid-token-xyz") - assert result.get("success") is False - - def test_heartbeat_after_delete_fails(self, session_lifecycle_tester): - """Heartbeat after session deletion fails.""" - # Create and delete - create_result = session_lifecycle_tester("create") - token = create_result.get("data", create_result).get("session_token") - session_lifecycle_tester("delete", token=token) - - # Heartbeat should fail - result = session_lifecycle_tester("heartbeat", token=token) - assert result.get("success") is False - - -@pytest.mark.functional -class TestSessionListing: - """Tests for session listing.""" - - def test_list_returns_sessions(self, session_lifecycle_tester, functional_session): - """Session list includes active sessions.""" - result = session_lifecycle_tester("list") - assert result.get("success") is True - data = result.get("data", result) - sessions = data.get("sessions", []) - assert len(sessions) >= 1 # At least the functional_session - - def test_list_session_has_required_fields(self, session_lifecycle_tester, functional_session): - """Listed sessions have required fields.""" - result = session_lifecycle_tester("list") - sessions = result.get("data", result).get("sessions", []) - assert len(sessions) >= 1 - - session = sessions[0] - assert "container_id" in session - assert "mode" in session - assert "created_at" in session or "last_seen" in session - - -@pytest.mark.functional -class TestDuplicateContainerHandling: - """Tests for handling duplicate container IDs.""" - - def test_duplicate_container_id_handled(self, session_lifecycle_tester): - """Creating sessions with same container ID is handled gracefully.""" - container_id = f"duplicate-{time.time_ns()}" - - # Create first session - result1 = session_lifecycle_tester("create", container_id=container_id) - token1 = result1.get("data", result1).get("session_token") - - # Create second session with same container ID - result2 = session_lifecycle_tester("create", container_id=container_id) - - # Should either succeed (replacing) or fail gracefully - assert result2.get("success") is not None # Should have a definite answer - - # Cleanup - if token1: - session_lifecycle_tester("delete", token=token1) - if result2.get("success"): - token2 = result2.get("data", result2).get("session_token") - if token2 and token2 != token1: - session_lifecycle_tester("delete", token=token2) - - -@pytest.mark.functional -class TestSessionIsolation: - """Tests for session isolation between tests.""" - - def test_sessions_have_unique_tokens(self, session_lifecycle_tester): - """Each session gets a unique token.""" - result1 = session_lifecycle_tester("create") - result2 = session_lifecycle_tester("create") - - token1 = result1.get("data", result1).get("session_token") - token2 = result2.get("data", result2).get("session_token") - - assert token1 != token2 - - def test_session_operations_require_correct_token(self, minimal_gateway, functional_session): - """Session operations require the correct token.""" - correct_token = functional_session.get("session_token") - - # Try with wrong token - resp = minimal_gateway.api_request( - "POST", - "/api/v1/git/execute", - token="wrong-token", - json_data={ - "repo_path": "/home/egg/repos/test-repo", - "operation": "status", - }, - ) - assert resp.status_code == 401 - - # Try with correct token - resp = minimal_gateway.api_request( - "POST", - "/api/v1/git/execute", - token=correct_token, - json_data={ - "repo_path": "/home/egg/repos/test-repo", - "operation": "status", - }, - ) - assert resp.status_code != 401 - - -@pytest.mark.functional -class TestAuthenticationRequirements: - """Tests for authentication requirements on session endpoints.""" - - def test_create_requires_launcher_secret(self, minimal_gateway): - """Session creation requires the launcher secret.""" - resp = minimal_gateway.api_request( - "POST", - "/api/v1/sessions/create", - json_data={ - "container_id": "test-no-auth", - "container_ip": "172.42.0.100", - "mode": "private", - "repos": ["test-owner/test-repo"], - "uid": 1000, - "gid": 1000, - }, - ) - assert resp.status_code == 401 - - def test_list_requires_launcher_secret(self, minimal_gateway): - """Session listing requires the launcher secret.""" - resp = minimal_gateway.api_request("GET", "/api/v1/sessions") - assert resp.status_code == 401 - - def test_delete_requires_launcher_secret(self, minimal_gateway): - """Session deletion requires the launcher secret.""" - resp = minimal_gateway.api_request( - "DELETE", - "/api/v1/sessions/some-token", - ) - assert resp.status_code == 401 - - def test_health_no_auth_required(self, minimal_gateway): - """Health endpoint does not require authentication.""" - resp = minimal_gateway.api_request("GET", "/api/v1/health") - assert resp.status_code == 200 - - -@pytest.mark.functional -class TestSessionStateConsistency: - """Tests for session state consistency.""" - - def test_rapid_create_delete_cycle(self, session_lifecycle_tester): - """Rapid create/delete cycles maintain state consistency.""" - for i in range(5): - container_id = f"rapid-{i}-{time.time_ns()}" - result = session_lifecycle_tester("create", container_id=container_id) - assert result.get("success") is True - token = result.get("data", result).get("session_token") - assert token - - delete_result = session_lifecycle_tester("delete", token=token) - assert delete_result.get("success") is True - - # Verify session is fully deleted before creating next one - list_result = session_lifecycle_tester("list") - sessions = list_result.get("data", list_result).get("sessions", []) - container_ids = [s.get("container_id") for s in sessions] - assert container_id not in container_ids, ( - f"Session {container_id} still present after delete" - ) - - def test_many_concurrent_sessions(self, session_lifecycle_tester): - """Multiple concurrent sessions are tracked correctly.""" - tokens = [] - - # Create multiple sessions - for i in range(3): - result = session_lifecycle_tester( - "create", container_id=f"concurrent-{i}-{time.time_ns()}" - ) - if result.get("success"): - tokens.append(result.get("data", result).get("session_token")) - - # List should show all of them - list_result = session_lifecycle_tester("list") - sessions = list_result.get("data", list_result).get("sessions", []) - assert len(sessions) >= len(tokens) - - # Cleanup - for token in tokens: - session_lifecycle_tester("delete", token=token) diff --git a/tests/sandbox/egg_agent_tools/test_full_tool_registry.py b/tests/sandbox/egg_agent_tools/test_full_tool_registry.py index 45e3714136..5335cadb5a 100644 --- a/tests/sandbox/egg_agent_tools/test_full_tool_registry.py +++ b/tests/sandbox/egg_agent_tools/test_full_tool_registry.py @@ -127,15 +127,19 @@ class TestToolCountAndNamespaces: """Derived assertions locked here so the integration suite trips on silent drift. - Count is **29** — ``mcp__brc__wait_for_event`` and + Count is **31** — ``mcp__brc__wait_for_event`` and ``mcp__brc__wait_loop`` were removed in #2211 (long-poll waits don't fit the in-process SDK MCP transport's ~60 s tool-call cap; agents now use ``egg-orch message wait`` / ``wait-loop`` via Bash). ``mcp__brc__resolve_obligation`` was added in #2338 to mark a conditional-ACK obligation satisfied in-cycle. + ``mcp__sdlc__check_file_restriction`` and + ``mcp__sdlc__report_impasse`` were added in #2529 for the runtime + escape-hatch — the agent self-checks role/file boundaries and + emits a typed Impasse instead of inventing workarounds. """ - EXPECTED_TOOL_COUNT = 29 + EXPECTED_TOOL_COUNT = 31 def test_tool_count(self): assert len(TOOL_LIST) == self.EXPECTED_TOOL_COUNT diff --git a/tests/sandbox/egg_agent_tools/test_handlers_brc.py b/tests/sandbox/egg_agent_tools/test_handlers_brc.py index 95ad7d1ca6..af11c2ebc1 100644 --- a/tests/sandbox/egg_agent_tools/test_handlers_brc.py +++ b/tests/sandbox/egg_agent_tools/test_handlers_brc.py @@ -1070,6 +1070,209 @@ def test_docstring_mentions_no_cli_rationale(self): lower = doc.lower() assert "no cli" in lower or "no-cli" in lower + # ---- Slice-aware implement-phase reads (#2548 follow-up) ----------- + + def _make_slice_history_file(self, root, identifier: str, slice_id: str, records: list[dict]): + """Write a per-slice implement-phase brc-history file.""" + dir_ = root / ".egg-state" / "brc-history" + dir_.mkdir(parents=True, exist_ok=True) + path = dir_ / f"{identifier}-implement-{slice_id}.json" + path.write_text(json.dumps(records)) + return path + + def _make_unattributed_history_file(self, root, identifier: str, records: list[dict]): + """Write the cross-cutting `unattributed` sibling file.""" + dir_ = root / ".egg-state" / "brc-history" + dir_.mkdir(parents=True, exist_ok=True) + path = dir_ / f"{identifier}-implement-unattributed.json" + path.write_text(json.dumps(records)) + return path + + def test_slice_scoped_implement_reads_per_slice_file(self, tmp_path, monkeypatch): + """When EGG_SLICE_ID is set and phase=='implement', the handler + reads {identifier}-implement-{slice_id}.json — not the legacy + aggregate file (which the writer no longer produces in slice mode).""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.setenv("EGG_SLICE_ID", "slice-1") + # Slice-1 has its own transcript; no aggregate file exists. + self._make_slice_history_file( + tmp_path, + "1917", + "slice-1", + _records(("coder", "CONSENSUS_PROPOSE"), ("reviewer_code", "CONSENSUS_ACK")), + ) + resp = brc.brc_read_peer_artifact({"phase": "implement"}) + assert resp["ok"] is True + assert len(resp["items"]) == 2 + assert resp["total_available"] == 2 + + def test_slice_scoped_implement_no_aggregate_fallback(self, tmp_path, monkeypatch): + """A slice-scoped agent must NOT silently dead-end into the + aggregate file even if a stale {identifier}-implement.json + happens to be on disk — the slice file is the canonical path.""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.setenv("EGG_SLICE_ID", "slice-2") + # Aggregate file from a previous run (or another tool) — must + # be ignored when slice-scoped. + _make_history_file( + tmp_path, + "1917", + "implement", + _records(("coder", "CONSENSUS_PROPOSE")), + ) + # Slice-2's per-slice file does not exist. + resp = brc.brc_read_peer_artifact({"phase": "implement"}) + assert resp["items"] == [] + assert resp["total_available"] == 0 + + def test_slice_scoped_implement_merges_unattributed_sibling(self, tmp_path, monkeypatch): + """By default the slice transcript is merged with the cross- + cutting `unattributed` sibling so reviewers see OVERSEER_ALERT, + AGENT_FAILED, etc. interleaved with their slice's CONSENSUS_*.""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.setenv("EGG_SLICE_ID", "slice-1") + # Slice-1 records and unattributed records have distinct + # timestamps so we can assert chronological interleave. + slice_recs = [ + { + "id": "s1", + "from_role": "coder", + "message_type": "CONSENSUS_PROPOSE", + "body": "b1", + "timestamp": "2026-04-24T00:00:01Z", + }, + { + "id": "s2", + "from_role": "reviewer_code", + "message_type": "CONSENSUS_ACK", + "body": "b2", + "timestamp": "2026-04-24T00:00:03Z", + }, + ] + unattr_recs = [ + { + "id": "u1", + "from_role": "overseer", + "message_type": "OVERSEER_ALERT", + "body": "u1", + "timestamp": "2026-04-24T00:00:02Z", + }, + ] + self._make_slice_history_file(tmp_path, "1917", "slice-1", slice_recs) + self._make_unattributed_history_file(tmp_path, "1917", unattr_recs) + resp = brc.brc_read_peer_artifact({"phase": "implement"}) + assert len(resp["items"]) == 3 + # Re-sorted by timestamp: s1 → u1 → s2. + assert [r["id"] for r in resp["items"]] == ["s1", "u1", "s2"] + + def test_slice_scoped_implement_skip_unattributed_on_request(self, tmp_path, monkeypatch): + """``include_unattributed=False`` reads only the per-slice file.""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.setenv("EGG_SLICE_ID", "slice-1") + self._make_slice_history_file( + tmp_path, + "1917", + "slice-1", + _records(("coder", "CONSENSUS_PROPOSE")), + ) + self._make_unattributed_history_file( + tmp_path, + "1917", + _records(("overseer", "OVERSEER_ALERT")), + ) + resp = brc.brc_read_peer_artifact({"phase": "implement", "include_unattributed": False}) + assert len(resp["items"]) == 1 + assert resp["items"][0]["from_role"] == "coder" + + def test_slice_scoped_implement_unattributed_only_present(self, tmp_path, monkeypatch): + """If a slice never produced any CONSENSUS_* but unattributed + traffic exists, the handler still returns the unattributed + records rather than an empty response.""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.setenv("EGG_SLICE_ID", "slice-1") + self._make_unattributed_history_file( + tmp_path, + "1917", + _records(("overseer", "OVERSEER_ALERT")), + ) + resp = brc.brc_read_peer_artifact({"phase": "implement"}) + assert len(resp["items"]) == 1 + assert resp["items"][0]["from_role"] == "overseer" + + def test_pipeline_level_implement_reads_aggregate(self, tmp_path, monkeypatch): + """Without EGG_SLICE_ID the handler reads the aggregate + ``{identifier}-implement.json`` file (babysit_pr / non-slice + runs are unaffected by the slice-aware switch).""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.delenv("EGG_SLICE_ID", raising=False) + _make_history_file( + tmp_path, + "1917", + "implement", + _records(("coder", "CONSENSUS_PROPOSE")), + ) + resp = brc.brc_read_peer_artifact({"phase": "implement"}) + assert len(resp["items"]) == 1 + + def test_invalid_slice_id_env_rejected(self, tmp_path, monkeypatch): + """Defense-in-depth: a malformed EGG_SLICE_ID must not be + interpolated into the filename. Same regex the writer enforces + at the orchestrator seam.""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.setenv("EGG_SLICE_ID", "../etc/passwd") + with pytest.raises(HandlerError) as exc: + brc.brc_read_peer_artifact({"phase": "implement"}) + assert "slice" in str(exc.value).lower() + + def test_invalid_include_unattributed_rejected(self, tmp_path, monkeypatch): + """``include_unattributed`` must be a bool when supplied.""" + self._set_env(monkeypatch, tmp_path) + with pytest.raises(HandlerError): + brc.brc_read_peer_artifact({"phase": "plan", "include_unattributed": "yes"}) + + def test_slice_scoped_non_implement_reads_aggregate(self, tmp_path, monkeypatch): + """EGG_SLICE_ID only switches the implement phase. Refine/plan/pr + always read the aggregate file — slice-aware writers never + partition those phases.""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.setenv("EGG_SLICE_ID", "slice-1") + _make_history_file( + tmp_path, + "1917", + "plan", + _records(("coder", "CONSENSUS_PROPOSE")), + ) + resp = brc.brc_read_peer_artifact({"phase": "plan"}) + assert len(resp["items"]) == 1 + + def test_filter_by_message_type_overseer_alert_in_unattributed(self, tmp_path, monkeypatch): + """Reviewers can scan cross-cutting alerts in their slice's + transcript by filtering on a non-CONSENSUS_* type. Regression + guard: the handler's ``_BRC_HISTORY_TYPES`` whitelist must include + the same non-CONSENSUS_* types the writer emits to the + ``unattributed`` sibling, otherwise this raises ``Unknown + message_type(s)`` even though matching records exist on disk.""" + self._set_env(monkeypatch, tmp_path) + monkeypatch.setenv("EGG_SLICE_ID", "slice-1") + self._make_slice_history_file( + tmp_path, + "1917", + "slice-1", + _records(("coder", "CONSENSUS_PROPOSE")), + ) + self._make_unattributed_history_file( + tmp_path, + "1917", + _records( + ("overseer", "OVERSEER_ALERT"), + ("system", "HEARTBEAT"), + ), + ) + resp = brc.brc_read_peer_artifact({"phase": "implement", "message_type": "OVERSEER_ALERT"}) + assert len(resp["items"]) == 1 + assert resp["items"][0]["message_type"] == "OVERSEER_ALERT" + assert resp["items"][0]["from_role"] == "overseer" + class TestBrcPipelineIdValidation: """Pipeline IDs are interpolated into URL paths — format validation @@ -1218,3 +1421,54 @@ def test_orchestrator_failure_surfaces(self): "producer_role": "coder", } ) + + +class TestBrcHistoryTypesDriftGuard: + """Regression guard locking writer/reader symmetry for the *full* BRC + history type set. + + The writer (``orchestrator.routes.pipelines.BRC_HISTORY_TYPES``) and + the reader-side filter whitelist + (``egg_agent_tools.handlers.brc._BRC_HISTORY_TYPES``) must list the + same types: any type the writer emits must be filterable by the + reader, and any type the reader accepts must be one the writer + actually produces. The single-type regression test added in #2548 + locks ``OVERSEER_ALERT`` only — this test parses the writer-side + literal out of the orchestrator source and asserts membership + equality, so future drift on either side surfaces as a test failure. + + The handler module deliberately does *not* import the orchestrator + package (which pulls fastapi); the regex-extraction approach + preserves that boundary while still locking the contract. + """ + + def test_handler_whitelist_matches_writer_set(self): + import re + + pipelines_path = ROOT / "orchestrator" / "routes" / "pipelines.py" + source = pipelines_path.read_text() + match = re.search( + r"^BRC_HISTORY_TYPES\s*=\s*frozenset\s*\(\s*\{(?P.*?)\}\s*\)", + source, + re.MULTILINE | re.DOTALL, + ) + assert match is not None, ( + "Could not locate ``BRC_HISTORY_TYPES = frozenset({...})`` " + f"literal in {pipelines_path}; the drift-guard regex needs " + "updating to track the new shape." + ) + writer_types = frozenset(re.findall(r'"([A-Z_]+)"', match.group("body"))) + assert writer_types, ( + "Parsed ``BRC_HISTORY_TYPES`` literal is empty — the regex " + "did not capture any type names; check the literal shape." + ) + handler_types = brc._BRC_HISTORY_TYPES + assert handler_types == writer_types, ( + "Sandbox handler whitelist drifted from orchestrator writer " + "set. " + f"Handler-only (reader accepts but writer never emits): " + f"{sorted(handler_types - writer_types)}; " + f"Writer-only (writer emits but reader rejects): " + f"{sorted(writer_types - handler_types)}. " + "Update one or both to keep the partition symmetric." + ) diff --git a/tests/sandbox/egg_agent_tools/test_server.py b/tests/sandbox/egg_agent_tools/test_server.py index 8a0a7dc621..330ea4146d 100644 --- a/tests/sandbox/egg_agent_tools/test_server.py +++ b/tests/sandbox/egg_agent_tools/test_server.py @@ -77,6 +77,10 @@ _POST_ITER2_TOOL_NAMES = { # brc — #2338 in-cycle conditional-ACK obligation resolution. "mcp__brc__resolve_obligation", + # sdlc — #2529 runtime escape-hatch (file-restriction self-check + # + typed Impasse signal). + "mcp__sdlc__check_file_restriction", + "mcp__sdlc__report_impasse", } EXPECTED_TOOL_NAMES = _ITER1_TOOL_NAMES | _ITER2_TOOL_NAMES | _POST_ITER2_TOOL_NAMES @@ -97,11 +101,13 @@ def test_tool_count_registered(self): # (``wait_for_event`` + ``wait_loop`` removed — agents now use # the ``egg-orch message wait`` / ``wait-loop`` Bash CLI for # blocking waits per the transport-mismatch carve-out) = 28, - # then +1 in #2338 (``mcp__brc__resolve_obligation``) = 29. + # then +1 in #2338 (``mcp__brc__resolve_obligation``) = 29, + # then +2 in #2529 (``check_file_restriction`` + + # ``report_impasse`` — runtime escape hatch) = 31. # Derived assertion: trips when a future iteration drifts the # count without updating the prose verb-counts in # docs/reference/agent-tools.md. - assert len(TOOL_LIST) == 29 + assert len(TOOL_LIST) == 31 def test_expected_names_present(self): names = set(TOOL_REGISTRY.keys()) diff --git a/tests/shared/egg_contracts/test_models.py b/tests/shared/egg_contracts/test_models.py index 6191993d1d..e5d71dc985 100644 --- a/tests/shared/egg_contracts/test_models.py +++ b/tests/shared/egg_contracts/test_models.py @@ -296,7 +296,11 @@ def test_minimal_contract(self): url="https://github.com/owner/repo/issues/133", ), ) - assert contract.schemaVersion == "1.0" + # schemaVersion default bumped from "1.0" to "1.1" in #2548 to + # track the addition of the optional ``pr.context_*`` fields. + # See ``test_pr_metadata.py::test_default_schemaversion_is_1_1`` + # for the canonical pin. + assert contract.schemaVersion == "1.1" assert contract.issue.number == 133 assert contract.current_phase == PipelinePhase.REFINE assert contract.phases == [] diff --git a/tests/shared/egg_contracts/test_pr_metadata.py b/tests/shared/egg_contracts/test_pr_metadata.py new file mode 100644 index 0000000000..e83957f6b6 --- /dev/null +++ b/tests/shared/egg_contracts/test_pr_metadata.py @@ -0,0 +1,906 @@ +"""Tests for PRMetadata.context_* fields + schemaVersion 1.0→1.1 migration. + +Added in #2548 (slice-1, task-1-2). Covers the four new optional +``PRMetadata.context_*`` fields the planner emits for the doc-only +context PR (issue #2548) and the load-time migration shim that +back-fills the new fields as ``None`` when an on-disk contract +written with ``schemaVersion="1.0"`` is loaded into the post-rename +``schemaVersion="1.1"`` model. + +The acceptance criteria from the plan: + +* Round-trip a ``PRMetadata`` with all four context fields populated. +* Round-trip a ``PRMetadata`` with all four context fields omitted + (defaults must be ``None``). +* Round-trip a contract serialised with ``schemaVersion="1.0"`` and + no context fields, and confirm migration populates the defaults. +* Confirm ``context_pr_number`` validation: ``0`` and negative values + are rejected (``ge=1``); positive ``int`` values round-trip. + +The tests live at ``tests/shared/egg_contracts/`` because that is the +pytest collection root in the project's ``[tool.pytest.ini_options] +testpaths`` (the in-package path ``shared/egg_contracts/tests/`` is NOT +in ``testpaths`` / ``scripts/select_tests/_constants.TEST_ROOT_DIRS`` +and would not be discovered by ``make test`` or ``make test-all``). +The plan task-1-2 ``files_affected`` referenced the in-package path +but the canonical location of every other ``PRMetadata`` test +(``tests/shared/egg_contracts/test_models.py``) is here. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from egg_contracts.models import ( + Contract, + IssueInfo, + PRMetadata, +) +from pydantic import ValidationError + + +def _minimal_contract_payload(*, schema_version: str = "1.0") -> dict[str, Any]: + """Return a minimal contract payload at the requested schema version. + + The contract has an ``IssueInfo`` and a single ``PRMetadata`` with + the legacy required field (``title``) populated and no ``context_*`` + keys set. Used to drive the migration round-trip in + :func:`test_contract_schemaversion_1_0_loads_with_context_defaults_none`. + + The return type is ``dict[str, Any]`` rather than the more precise + ``dict[str, dict[str, str | list[Any]]]`` because callers extend + ``payload["pr"]`` with arbitrary new keys (``context_title``, + ``context_pr_number``, ``deferred_actions`` entries) — pinning a + narrower inner type only forces casts at every mutation site. + """ + return { + "schemaVersion": schema_version, + "issue": { + "number": 2548, + "title": "context PR + per-slice BRC history", + "url": "https://example.com/i/2548", + }, + "current_phase": "refine", + "slices": [], + "decisions": [], + "audit_log": [], + "pr": { + "title": "Add context PR + per-slice BRC history", + "description": "", + "test_plan": "", + "manual_steps": "", + "deferred_actions": [], + }, + } + + +class TestPRMetadataContextFields: + """The four new optional ``context_*`` fields on ``PRMetadata`` (#2548).""" + + def test_context_fields_default_to_none(self): + """Constructing without the new keys must leave them ``None``. + + Backwards-compat: a planner emitting only the legacy fields + (``title`` / ``description`` / ``test_plan`` / ``manual_steps``) + must produce a ``PRMetadata`` whose ``context_*`` fields are all + ``None`` — that is what allows ``contract.pr.context_branch or + pipeline_branch`` to fall back cleanly in slice-4. + """ + pr = PRMetadata(title="Add context PR + per-slice BRC history") + assert pr.context_title is None + assert pr.context_description is None + assert pr.context_branch is None + assert pr.context_pr_number is None + + def test_context_fields_populated_round_trip(self): + """All four ``context_*`` fields populated must round-trip via JSON. + + Asserts construction → ``model_dump()`` → ``model_validate()`` + is value-preserving for every field the orchestrator persists + (``context_branch`` and ``context_pr_number``) and every field + the planner emits (``context_title`` and ``context_description``). + """ + pr = PRMetadata( + title="Add context PR + per-slice BRC history", + description="Per-slice BRC history + context PR work.", + context_title="Strategic plan for #2548", + context_description="Refine + plan artifacts for issue 2548.", + context_branch="egg/issue-2548/context", + context_pr_number=4242, + ) + dumped = pr.model_dump() + assert dumped["context_title"] == "Strategic plan for #2548" + assert dumped["context_description"] == "Refine + plan artifacts for issue 2548." + assert dumped["context_branch"] == "egg/issue-2548/context" + assert dumped["context_pr_number"] == 4242 + + round_trip = PRMetadata.model_validate(dumped) + assert round_trip.context_title == "Strategic plan for #2548" + assert round_trip.context_description == "Refine + plan artifacts for issue 2548." + assert round_trip.context_branch == "egg/issue-2548/context" + assert round_trip.context_pr_number == 4242 + + def test_context_fields_omitted_round_trip(self): + """Omitting the new keys at construction must round-trip as ``None``. + + Mirror of ``test_context_fields_default_to_none`` but at the + JSON-round-trip boundary — confirms ``model_dump()`` does not + synthesise spurious values and ``model_validate()`` accepts the + dump as-is. + """ + pr = PRMetadata(title="Plain PR — no context fields") + dumped = pr.model_dump() + assert dumped["context_title"] is None + assert dumped["context_description"] is None + assert dumped["context_branch"] is None + assert dumped["context_pr_number"] is None + + round_trip = PRMetadata.model_validate(dumped) + assert round_trip.context_title is None + assert round_trip.context_description is None + assert round_trip.context_branch is None + assert round_trip.context_pr_number is None + + +class TestPRMetadataContextPRNumberValidator: + """``context_pr_number`` must only accept positive integers (``ge=1``). + + Mirrors the validation already on ``IssueInfo.number`` — a GitHub PR + number is always a positive integer; ``0`` and negatives indicate a + bug somewhere upstream and should be surfaced loudly. + """ + + def test_positive_pr_number_accepted(self): + pr = PRMetadata(title="t", context_pr_number=1) + assert pr.context_pr_number == 1 + pr = PRMetadata(title="t", context_pr_number=999_999) + assert pr.context_pr_number == 999_999 + + def test_zero_pr_number_rejected(self): + with pytest.raises(ValidationError): + PRMetadata(title="t", context_pr_number=0) + + def test_negative_pr_number_rejected(self): + with pytest.raises(ValidationError): + PRMetadata(title="t", context_pr_number=-1) + + def test_none_pr_number_accepted(self): + """``None`` is the sentinel for 'not yet opened' and must remain valid.""" + pr = PRMetadata(title="t", context_pr_number=None) + assert pr.context_pr_number is None + + def test_pr_number_validator_re_runs_on_assignment(self): + """``validate_assignment=True`` makes ``setattr`` re-run validation. + + Regression for the shared ``EggContractBaseModel`` config (#2490). + Setting ``context_pr_number`` to ``0`` after construction must + raise — without this guard a buggy orchestrator path could + smuggle a 0 onto a previously-valid PRMetadata. + """ + pr = PRMetadata(title="t", context_pr_number=10) + with pytest.raises(ValidationError): + pr.context_pr_number = 0 + # Original value unchanged after the failed assignment. + assert pr.context_pr_number == 10 + + +class TestPRMetadataSchemaVersionMigration: + """A ``schemaVersion=1.0`` contract must load cleanly into the 1.1 model. + + The migration shim is on ``Contract`` (model-level), not on + ``PRMetadata`` directly, but the observable behavior we lock down + here is at the ``Contract.pr.context_*`` level: a pre-#2548 contract + on disk has no ``context_*`` keys; loading it into the post-#2548 + model must: + + * succeed (no ``ValidationError``), + * leave ``context_title`` / ``context_description`` / ``context_branch`` + / ``context_pr_number`` defaulted to ``None``, + * produce a contract whose ``schemaVersion`` is the post-migration + string (``"1.1"`` per the plan). + """ + + def test_legacy_1_0_payload_loads_without_context_keys(self): + """A 1.0 payload missing the four keys parses and defaults to ``None``.""" + payload = _minimal_contract_payload(schema_version="1.0") + contract = Contract.model_validate(payload) + + assert contract.pr is not None + assert contract.pr.context_title is None + assert contract.pr.context_description is None + assert contract.pr.context_branch is None + assert contract.pr.context_pr_number is None + + def test_legacy_1_0_payload_round_trip_preserves_defaults(self): + """Load → dump → reload must not synthesise spurious context values.""" + payload = _minimal_contract_payload(schema_version="1.0") + first = Contract.model_validate(payload) + dumped = first.model_dump() + second = Contract.model_validate(dumped) + + assert second.pr is not None + assert second.pr.context_title is None + assert second.pr.context_description is None + assert second.pr.context_branch is None + assert second.pr.context_pr_number is None + + def test_default_schemaversion_is_1_1(self): + """Brand-new ``Contract`` defaults the schemaVersion to ``1.1``. + + The plan bumps the default from ``"1.0"`` to ``"1.1"``. This + test pins that default so a future revert is caught loudly. + """ + contract = Contract( + issue=IssueInfo( + number=1, + title="t", + url="https://github.com/o/r/issues/1", + ) + ) + assert contract.schemaVersion == "1.1" + + def test_explicit_1_1_payload_loads_with_context_fields(self): + """A 1.1 payload with all context fields populated round-trips.""" + payload = _minimal_contract_payload(schema_version="1.1") + payload["pr"]["context_title"] = "Strategic plan for #2548" + payload["pr"]["context_description"] = "Refine + plan artifacts." + payload["pr"]["context_branch"] = "egg/issue-2548/context" + payload["pr"]["context_pr_number"] = 4242 + contract = Contract.model_validate(payload) + + assert contract.pr is not None + assert contract.pr.context_title == "Strategic plan for #2548" + assert contract.pr.context_description == "Refine + plan artifacts." + assert contract.pr.context_branch == "egg/issue-2548/context" + assert contract.pr.context_pr_number == 4242 + + def test_legacy_1_0_payload_does_not_lose_legacy_pr_fields(self): + """Migration must not drop any legacy ``PRMetadata`` field on the way in. + + Adversarial regression: a too-eager migration that rebuilt + ``PRMetadata`` from scratch could lose ``deferred_actions`` or + ``manual_steps``. Pin the legacy fields explicitly. + """ + payload = _minimal_contract_payload(schema_version="1.0") + payload["pr"]["description"] = "legacy description" + payload["pr"]["test_plan"] = "legacy test plan" + payload["pr"]["manual_steps"] = "legacy manual steps" + payload["pr"]["deferred_actions"] = [ + { + "reviewer": "reviewer_code", + "condition": "must rename foo → bar before merge", + "resolved_in_diff": "", + } + ] + contract = Contract.model_validate(payload) + + assert contract.pr is not None + assert contract.pr.description == "legacy description" + assert contract.pr.test_plan == "legacy test plan" + assert contract.pr.manual_steps == "legacy manual steps" + assert len(contract.pr.deferred_actions) == 1 + assert contract.pr.deferred_actions[0].condition == "must rename foo → bar before merge" + + def test_legacy_1_0_promotes_schemaversion_to_1_1(self): + """Loading a 1.0 payload must promote the version to 1.1 on the loaded model. + + The plan calls for "promotion" semantics — pre-#2548 contracts + on disk are bumped to 1.1 when loaded into the new model so + downstream tooling sees a consistent value. This pins the + bump direction. + """ + payload = _minimal_contract_payload(schema_version="1.0") + contract = Contract.model_validate(payload) + assert contract.schemaVersion == "1.1" + + def test_legacy_1_0_round_trip_persists_at_1_1(self): + """After the 1.0→1.1 promotion, dump→reload must keep the version at 1.1. + + Adversarial: a faulty migration that lived on the *input* path + (e.g. wrap-mode mutation of incoming dict) could re-trigger on + the second load and silently re-bump or downgrade. The + canonical post-migration version must be stable across an + arbitrary number of round-trips. + """ + payload = _minimal_contract_payload(schema_version="1.0") + first = Contract.model_validate(payload) + assert first.schemaVersion == "1.1" + + dumped = first.model_dump() + assert dumped["schemaVersion"] == "1.1" + + second = Contract.model_validate(dumped) + assert second.schemaVersion == "1.1" + + # Third round-trip — really pin idempotency. + third = Contract.model_validate(second.model_dump()) + assert third.schemaVersion == "1.1" + + def test_unrecognized_schemaversion_not_silently_downgraded(self): + """A schemaVersion outside the migration set must NOT be rewritten. + + Adversarial: the migration shim must be selective. A future + ``2.0`` (or even an in-between ``1.2``) loading on an old + binary should keep its declared version, not get silently + downgraded to ``1.1``. The plan explicitly calls this out: + "We deliberately do NOT touch versions outside ``{1.0}``". + """ + payload = _minimal_contract_payload(schema_version="1.2") + contract = Contract.model_validate(payload) + assert contract.schemaVersion == "1.2" + + payload_v2 = _minimal_contract_payload(schema_version="2.0") + contract_v2 = Contract.model_validate(payload_v2) + assert contract_v2.schemaVersion == "2.0" + + +class TestPRMetadataContextEmptyStringSemantics: + """Empty / whitespace strings are accepted at the model layer. + + The orchestrator hook computes ``contract.pr.context_title or + contract.pr.title`` to pick the framing for the context PR — both + ``None`` and ``""`` fall back via Python truthiness, so the model + deliberately does NOT enforce a min_length on the context-string + fields. These tests pin model-layer permissiveness so a future + ``min_length=1`` regression is caught by the test suite. + + Note: the planner path (``extract_pr_context_metadata_from_yaml``) + collapses whitespace-only / empty scalars to ``None`` before they + reach the model — see + ``test_extract_normalises_whitespace_to_none``. So in practice + only hand-edited or migrated payloads can produce a ``PRMetadata`` + with ``context_description == ""``; the model layer keeps that + door open by design. + """ + + def test_empty_context_title_accepted(self): + pr = PRMetadata(title="t", context_title="") + assert pr.context_title == "" + + def test_empty_context_description_accepted(self): + pr = PRMetadata(title="t", context_description="") + assert pr.context_description == "" + + def test_empty_context_branch_accepted(self): + # ``context_branch`` carries a git ref name; an empty string is + # not a valid ref, but the model layer is permissive — the + # orchestrator gateway primitive validates the ref shape when + # it actually creates the branch (slice-3). + pr = PRMetadata(title="t", context_branch="") + assert pr.context_branch == "" + + def test_or_fallback_works_with_none_and_empty(self): + """Mirror of the orchestrator hook's runtime fallback expression. + + ``context_title or title`` must yield ``title`` for both ``None`` + and ``""``. If a future commit tightens the model to reject + ``""`` this test fails loudly because the orchestrator's + fallback semantics depend on this dual treatment. + """ + pr_none = PRMetadata(title="fallback-title") + assert (pr_none.context_title or pr_none.title) == "fallback-title" + + pr_empty = PRMetadata(title="fallback-title", context_title="") + assert (pr_empty.context_title or pr_empty.title) == "fallback-title" + + pr_set = PRMetadata(title="fallback-title", context_title="explicit-context") + assert (pr_set.context_title or pr_set.title) == "explicit-context" + + +class TestPlanParserContextFieldExtraction: + """End-to-end tests for the planner-emitted ``pr.context_*`` keys. + + Task-1-3's acceptance criteria require that planner-emitted + YAML containing ``context_title:`` and ``context_description:`` is + parsed without error and the values land on ``contract.pr.context_*``; + omitting the keys leaves them as ``None``. Live in this file + because they exercise the same surface (``PRMetadata.context_*``) + that task-1-2 owns; without these tests a regression in + ``extract_pr_context_metadata_from_yaml`` could silently drop + planner-emitted keys without breaking the model-level tests above. + """ + + @staticmethod + def _make_yaml( + *, + with_context_title: bool = False, + with_context_description: bool = False, + title_value: str = "Strategic plan for #2548", + description_value: str = "Refine + plan artifacts.", + ) -> dict[str, Any]: + """Build a yaml-tasks dict, optionally with the new context keys.""" + pr_block: dict[str, str] = { + "title": "Implement #2548", + "description": "Slice-1 stub.", + } + if with_context_title: + pr_block["context_title"] = title_value + if with_context_description: + pr_block["context_description"] = description_value + return {"pr": pr_block, "phases": []} + + def test_extract_returns_none_pair_when_pr_block_missing(self): + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + title, desc, warnings = extract_pr_context_metadata_from_yaml({"phases": []}) + assert title is None + assert desc is None + assert warnings == [] + + def test_extract_returns_none_pair_when_yaml_data_is_none(self): + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + title, desc, warnings = extract_pr_context_metadata_from_yaml(None) + assert title is None + assert desc is None + assert warnings == [] + + def test_extract_returns_none_pair_when_keys_absent(self): + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = self._make_yaml() # neither key present + title, desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert title is None + assert desc is None + assert warnings == [] + + def test_extract_returns_populated_when_keys_present(self): + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = self._make_yaml( + with_context_title=True, + with_context_description=True, + ) + title, desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert title == "Strategic plan for #2548" + assert desc == "Refine + plan artifacts." + assert warnings == [] + + def test_extract_normalises_whitespace_to_none(self): + """A planner emitting whitespace-only block scalars must collapse to None. + + The orchestrator hook's ``contract.pr.context_title or + contract.pr.title`` fallback works with both ``None`` and + ``""``; collapsing whitespace to ``None`` here keeps the + contract diff clean (no spurious whitespace strings) and + matches the existing ``_normalize_optional_string`` behavior + for legacy fields. + """ + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = self._make_yaml( + with_context_title=True, + with_context_description=True, + title_value=" ", + description_value=" ", + ) + title, desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert title is None + assert desc is None + + def test_extract_warns_on_non_string_context_title(self): + """A non-string ``context_title`` must produce a ParseWarning. + + Mirror of the existing behavior on ``pr.title`` — surfacing the + type mismatch makes planner-prompt regressions easy to spot. + """ + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = { + "pr": { + "title": "Implement #2548", + "context_title": 12345, # int — not a string + }, + "phases": [], + } + title, _desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert title is None # malformed → fall back + assert len(warnings) == 1 + assert "context_title" in warnings[0].message + assert "int" in warnings[0].message + + def test_extract_warns_on_non_string_context_description(self): + """A non-string ``context_description`` must also warn. + + Symmetric with the ``context_title`` branch above. Without an + explicit type check, ``_normalize_optional_string`` would + silently coerce non-strings via ``str(value)`` (e.g. an int + ``12345`` becomes ``"12345"``, a dict ``{a: b}`` becomes + ``"{'a': 'b'}"``) and a planner-prompt regression that started + emitting structured values would land quietly on the contract. + """ + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = { + "pr": { + "title": "Implement #2548", + "context_description": {"unexpected": "mapping"}, + }, + "phases": [], + } + _title, desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert desc is None # malformed → fall back + assert len(warnings) == 1 + assert "context_description" in warnings[0].message + assert "dict" in warnings[0].message + + def test_extract_warns_on_int_context_description(self): + """Integer scalars on ``context_description`` warn rather than coerce.""" + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = { + "pr": { + "title": "Implement #2548", + "context_description": 12345, + }, + "phases": [], + } + _title, desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert desc is None + assert len(warnings) == 1 + assert "context_description" in warnings[0].message + assert "int" in warnings[0].message + + def test_parse_plan_threads_context_into_parse_result(self): + """End-to-end: ``parse_plan`` must populate ``ParseResult.pr_context_*``.""" + from egg_contracts.plan_parser import parse_plan + + plan_md = ( + "# Plan\n\n" + "```yaml\n" + "# yaml-tasks\n" + "pr:\n" + ' title: "Implement #2548"\n' + ' description: "Slice-1 stub."\n' + ' context_title: "Strategic plan for #2548"\n' + " context_description: |\n" + " Refine + plan artifacts for issue 2548.\n" + "phases:\n" + " - id: 1\n" + " name: slice-1\n" + " tasks: []\n" + "```\n" + ) + result = parse_plan(plan_md) + assert result.success is True + assert result.pr_context_title == "Strategic plan for #2548" + assert result.pr_context_description == "Refine + plan artifacts for issue 2548." + + def test_parse_plan_defaults_context_to_none_when_keys_omitted(self): + from egg_contracts.plan_parser import parse_plan + + plan_md = ( + "# Plan\n\n" + "```yaml\n" + "# yaml-tasks\n" + "pr:\n" + ' title: "Implement #2548"\n' + ' description: "Slice-1 stub."\n' + "phases:\n" + " - id: 1\n" + " name: slice-1\n" + " tasks: []\n" + "```\n" + ) + result = parse_plan(plan_md) + assert result.success is True + assert result.pr_context_title is None + assert result.pr_context_description is None + + +class TestPRMetadataAdversarial: + """Adversarial probes added by the tester role (#2548 task-1-2). + + The classes above pin the happy paths and the symmetric warning + branches. The tests below try to break the implementation in ways + a planner-prompt regression, a hand-edited contract, or a future + refactor of ``_migrate_schema_version_to_1_1`` could plausibly + expose. + """ + + def test_model_dump_json_round_trip_preserves_all_context_fields(self): + """``model_dump_json()`` is the on-disk path; round-trip must be + value-preserving for every ``context_*`` field. + + Adversarial: ``model_dump()`` returns Python objects, but the + contract is persisted via JSON. A future custom serializer that + treated ``None`` as "omit from output" would silently drop the + absent-context distinction; this test fails loudly if that + happens. + """ + import json + + pr = PRMetadata( + title="Implement #2548", + context_title="Strategic plan for #2548", + context_description="Refine + plan artifacts.", + context_branch="egg/issue-2548/context", + context_pr_number=4242, + ) + as_json = pr.model_dump_json() + # Survives a JSON round-trip — no lossy custom encoder. + decoded = json.loads(as_json) + assert decoded["context_title"] == "Strategic plan for #2548" + assert decoded["context_description"] == "Refine + plan artifacts." + assert decoded["context_branch"] == "egg/issue-2548/context" + assert decoded["context_pr_number"] == 4242 + + round_trip = PRMetadata.model_validate_json(as_json) + assert round_trip.context_title == "Strategic plan for #2548" + assert round_trip.context_description == "Refine + plan artifacts." + assert round_trip.context_branch == "egg/issue-2548/context" + assert round_trip.context_pr_number == 4242 + + def test_model_dump_json_preserves_null_context_fields(self): + """A ``None`` context value must serialise as JSON ``null``, not omitted. + + Adversarial: ``model_dump_json(exclude_none=True)`` is a one-line + change away in the future. Pin the explicit-null behavior so an + accidental ``exclude_none`` regression breaks the test rather + than silently changing the on-disk shape (round-trips would + still work but external readers would see schema drift). + """ + import json + + pr = PRMetadata(title="Plain PR — no context fields") + as_json = pr.model_dump_json() + decoded = json.loads(as_json) + assert decoded["context_title"] is None + assert decoded["context_description"] is None + assert decoded["context_branch"] is None + assert decoded["context_pr_number"] is None + + def test_combined_phases_and_schemaversion_migration(self): + """A legacy contract with both ``phases:`` (pre-#2137) AND ``schemaVersion=1.0`` + (pre-#2548) must be migrated correctly by both validators. + + Adversarial: both migrations live on the same model. + ``_migrate_phases_to_slices`` runs in ``mode="wrap"`` and + rewrites the input dict; ``_migrate_schema_version_to_1_1`` + runs in ``mode="after"`` on the constructed instance. A bug in + the wrap-mode validator could swallow the schemaVersion field; + a bug in the after-mode validator could fire before the wrap + completes. Pin the combined invariant: legacy keys re-map AND + the schemaVersion bumps to 1.1 in the same load. + """ + payload = _minimal_contract_payload(schema_version="1.0") + # Reshape the payload to exercise the legacy phases-key path. + del payload["slices"] + payload["phases"] = [ + {"id": "phase-1", "name": "first", "tasks": []}, + { + "id": "phase-2", + "name": "second", + "tasks": [], + "dependencies": ["phase-1"], + }, + ] + contract = Contract.model_validate(payload) + # schemaVersion was bumped to the post-#2548 version. + assert contract.schemaVersion == "1.1" + # phases-key was migrated to slices, and the slice-N IDs were + # canonicalised (the dependency edge too). + assert len(contract.slices) == 2 + assert contract.slices[0].id == "slice-1" + assert contract.slices[1].id == "slice-2" + assert contract.slices[1].dependencies == ["slice-1"] + # Context fields default to None on the bumped contract. + assert contract.pr is not None + assert contract.pr.context_title is None + assert contract.pr.context_pr_number is None + + def test_yaml_null_for_context_fields_yields_none(self): + """A planner emitting ``context_title: ~`` (YAML null) must thread + through as ``None``, not the string ``"~"`` or ``"None"``. + + Adversarial: a fragile parser that did ``str(value)`` on raw + YAML scalars would silently coerce a YAML null to ``"None"``, + which the orchestrator's ``or pr.title`` fallback would happily + accept as a non-empty string and use as the context-PR title. + Lock this down at the parse-plan boundary. + """ + from egg_contracts.plan_parser import parse_plan + + plan_md = ( + "# Plan\n\n" + "```yaml\n" + "# yaml-tasks\n" + "pr:\n" + ' title: "Implement #2548"\n' + ' description: "Slice-1 stub."\n' + " context_title: ~\n" + " context_description: null\n" + "phases:\n" + " - id: 1\n" + " name: slice-1\n" + " tasks: []\n" + "```\n" + ) + result = parse_plan(plan_md) + assert result.success is True + assert result.pr_context_title is None + assert result.pr_context_description is None + + def test_parse_plan_markdown_only_yields_none_context(self): + """A plan document with no yaml-tasks fence (markdown-regex + fallback path) must still produce ``pr_context_*`` as ``None``. + + Adversarial: parse_plan's third-priority fallback bypasses + ``extract_pr_context_metadata_from_yaml`` because there is no + YAML to extract from. The ``ParseResult`` defaults must keep + the context fields ``None`` — without this guard a regression + that initialised them to ``""`` would leak into the contract + and the orchestrator's truthiness fallback would still work, + masking the bug. + """ + from egg_contracts.plan_parser import parse_plan + + plan_md = ( + "# Plan\n\n" + "## Phase 1: Setup\n" + "**Goal**: foo\n\n" + "- [TASK-1-1] Do thing — Acceptance: it works\n" + ) + result = parse_plan(plan_md) + assert result.success is True + assert result.pr_context_title is None + assert result.pr_context_description is None + + def test_extract_warns_on_list_typed_context_title(self): + """A list value for ``context_title`` must warn — not coerce. + + Adversarial: the existing tests cover ``int`` and ``dict`` for + the description branch and ``int`` for the title branch. A + ``list`` (e.g. a planner that confused ``context_title`` with + ``files_affected``) would round-trip through + ``_normalize_optional_string`` as ``"['a', 'b']"`` if the + ``isinstance(raw_title, str)`` guard in + ``extract_pr_context_metadata_from_yaml`` regressed. The check + fires in the parser before the value reaches ``PRMetadata``, so + pydantic is not involved in this code path; pin the + parser-layer warning path explicitly. + """ + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = { + "pr": { + "title": "Implement #2548", + "context_title": ["a", "b"], + }, + "phases": [], + } + title, _desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert title is None + assert len(warnings) == 1 + assert "context_title" in warnings[0].message + assert "list" in warnings[0].message + + def test_extract_warns_on_list_typed_context_description(self): + """Symmetric with ``test_extract_warns_on_list_typed_context_title``. + + Adversarial: the description branch's existing coverage is + ``dict`` and ``int``. A ``list`` would round-trip through + ``_normalize_optional_string`` as ``"[a, b]"`` if the type + guard regressed; lock the warning path down so a planner + emitting an accidental list of strings is caught. + """ + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = { + "pr": { + "title": "Implement #2548", + "context_description": ["line one", "line two"], + }, + "phases": [], + } + _title, desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert desc is None + assert len(warnings) == 1 + assert "context_description" in warnings[0].message + assert "list" in warnings[0].message + + def test_extract_handles_crlf_whitespace_in_context_fields(self): + """A planner emitting CRLF / mixed whitespace must still strip cleanly. + + Adversarial: agents writing on Windows-line-ending hosts (or a + planner whose prompt template has CRLF) could emit + ``" Strategic plan \\r\\n"`` for ``context_title``. The + existing ``_normalize_optional_string`` uses ``.strip()`` which + handles CRLF; pin the behavior so a future hand-rolled + replacement that only stripped ``\\n`` would catch the gap. + """ + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + yaml_data = { + "pr": { + "title": "Implement #2548", + "context_title": " Strategic plan for #2548 \r\n", + "context_description": "\r\n multi-line \n body \r\n", + }, + "phases": [], + } + title, desc, warnings = extract_pr_context_metadata_from_yaml(yaml_data) + assert title == "Strategic plan for #2548" + # Internal newlines preserved; only leading/trailing stripped. + assert desc == "multi-line \n body" + assert warnings == [] + + def test_context_pr_number_accepts_large_int(self): + """A high GitHub PR number (six- or seven-digit) must round-trip. + + Adversarial: a future ``int32``-style validator (``le=2**31-1``) + added without thought would clip realistic PR numbers on a + long-lived monorepo. Pin a generous ceiling so the validator + stays scoped to the ``ge=1`` lower bound documented in the model. + """ + # GitHub doesn't publish a hard cap; common monorepos already + # exceed 100k PRs. 10_000_000 is comfortably above any + # plausible repo for the foreseeable future. + pr = PRMetadata(title="t", context_pr_number=10_000_000) + assert pr.context_pr_number == 10_000_000 + # And it round-trips through model_validate without loss. + round_trip = PRMetadata.model_validate(pr.model_dump()) + assert round_trip.context_pr_number == 10_000_000 + + def test_invalid_schemaversion_format_rejected(self): + """``schemaVersion`` must match the ``M.N`` regex — a freeform + string like ``"1.0-rc1"`` or ``"v1.0"`` must raise. + + Adversarial: a future migration that emitted ``"1.1-#2548"`` or + ``"v1.1"`` would silently land on disk if the regex were + relaxed; pin the strict format so any drift fails fast. + """ + payload = _minimal_contract_payload(schema_version="1.0-rc1") + with pytest.raises(ValidationError): + Contract.model_validate(payload) + + payload = _minimal_contract_payload(schema_version="v1.0") + with pytest.raises(ValidationError): + Contract.model_validate(payload) + + def test_legacy_1_0_with_explicit_context_fields_loads(self): + """A 1.0 payload that ALREADY carries the new ``context_*`` keys + (e.g., a hand-edited contract or a partial mid-flight migration) + must load cleanly: the schemaVersion bumps, the explicit context + values are preserved. + + Adversarial: the migration shim only runs when schemaVersion is + exactly ``"1.0"``. Pin that the bump does NOT erase explicit + context values — the validator must be additive, not corrective. + """ + payload = _minimal_contract_payload(schema_version="1.0") + payload["pr"]["context_title"] = "Strategic plan for #2548" + payload["pr"]["context_description"] = "Refine + plan artifacts." + payload["pr"]["context_branch"] = "egg/issue-2548/context" + payload["pr"]["context_pr_number"] = 4242 + + contract = Contract.model_validate(payload) + assert contract.schemaVersion == "1.1" + assert contract.pr is not None + assert contract.pr.context_title == "Strategic plan for #2548" + assert contract.pr.context_description == "Refine + plan artifacts." + assert contract.pr.context_branch == "egg/issue-2548/context" + assert contract.pr.context_pr_number == 4242 + + def test_extract_returns_none_when_pr_block_is_non_dict(self): + """A malformed ``pr:`` block (e.g. a list) must not crash the + extractor. Returns ``(None, None, [])`` — the warning is + already produced by ``extract_pr_metadata_from_yaml`` so we do + not duplicate it here, but the extractor must short-circuit + rather than ``AttributeError`` on ``.get``. + + Adversarial: a planner that confused YAML mapping syntax could + emit ``pr: [title, body]``. The legacy ``extract_pr_metadata_from_yaml`` + produces a structural warning for that case; the new context + extractor must align with that contract (silent short-circuit + when its sibling already warned) rather than raising. + """ + from egg_contracts.plan_parser import extract_pr_context_metadata_from_yaml + + title, desc, warnings = extract_pr_context_metadata_from_yaml( + {"pr": ["title-as-list-item", "body-as-list-item"]} + ) + assert title is None + assert desc is None + assert warnings == [] diff --git a/tests/tools/test_mcp_cli_drift.py b/tests/tools/test_mcp_cli_drift.py index 7da1b052bc..0391245f62 100644 --- a/tests/tools/test_mcp_cli_drift.py +++ b/tests/tools/test_mcp_cli_drift.py @@ -274,6 +274,13 @@ def test_cli_less_tools_are_documented_gaps(): # Post-iter-2 (#2338): in-cycle conditional-ACK obligation # resolution — net-new capability with no CLI counterpart. "mcp__brc__resolve_obligation", + # #2529 runtime escape hatch: pure-local file-restriction read + # and the typed Impasse signal. Both bypass the gateway round + # trip — the agent is mid-execution and has not yet committed + # anything, so a CLI fallback would just shell out and re-do + # what these tools already do directly. + "mcp__sdlc__check_file_restriction", + "mcp__sdlc__report_impasse", } actual_gaps = {name for name, reg in TOOL_REGISTRY.items() if reg.cli_command is None} assert actual_gaps == expected_gaps, ( diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 51f5375ab7..12c35f10c3 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -20,7 +20,6 @@ ) from tests.utils.gateway_client import ( GatewayClientMixin, - docker_available, wait_for_healthy, ) @@ -38,6 +37,5 @@ "assert_session_valid", # Gateway client utilities "GatewayClientMixin", - "docker_available", "wait_for_healthy", ] diff --git a/tests/utils/gateway_client.py b/tests/utils/gateway_client.py index effbd36b57..00657c8d26 100644 --- a/tests/utils/gateway_client.py +++ b/tests/utils/gateway_client.py @@ -7,7 +7,6 @@ """ import os -import subprocess import time from typing import Any @@ -193,20 +192,6 @@ def api_request( ) -def docker_available() -> bool: - """Check if Docker is available and running.""" - try: - result = subprocess.run( - ["docker", "info"], - capture_output=True, - timeout=10, - check=False, - ) - return result.returncode == 0 - except FileNotFoundError, subprocess.TimeoutExpired: - return False - - def wait_for_healthy(url: str, timeout: int = 60) -> bool: """Wait for the gateway health endpoint to return 200.""" deadline = time.time() + timeout