phase-loop-runtime 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phase_loop_runtime/__init__.py +20 -0
- phase_loop_runtime/_contract_docs/phase-loop/extraction-readiness.md +90 -0
- phase_loop_runtime/_contract_docs/phase-loop/granular-execution-policy.md +137 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-capability-matrix.md +510 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-skill-matrix.md +125 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-substrate-manifest.md +232 -0
- phase_loop_runtime/_contract_docs/phase-loop/pi-loop-control.md +247 -0
- phase_loop_runtime/_contract_docs/phase-loop/protocol.md +2759 -0
- phase_loop_runtime/_contract_docs/phase-loop/runtime-boundary.md +289 -0
- phase_loop_runtime/_contract_docs/phase-loop/spec-discovery-roots.md +47 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-governed-pipeline.md +42 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-portal-projection.md +34 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-regenesis.md +29 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-report.md +32 -0
- phase_loop_runtime/_contract_docs/runtime/verification-evidence-contract.md +52 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/commands.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/mixed.json +5 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/timeout-note.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/redacted-summary.json +6 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/sample.json +7 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/edge.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-a.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-b.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-c.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/claimed-results.json +5 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/metrics.json +9 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/findings.md +10 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/scores.json +4 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/summary.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/bundle-check.json +8 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/operator-note.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/events-sample.jsonl +3 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/receipt.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/evidence.json +13 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/summary.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/matrix.json +9 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/notes.md +4 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/console-summary.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/screenshot-standin.ppm +5 -0
- phase_loop_runtime/adoption_bundle.py +273 -0
- phase_loop_runtime/advisor_board/CONTRACTS.md +257 -0
- phase_loop_runtime/advisor_board/__init__.py +288 -0
- phase_loop_runtime/advisor_board/backing.py +137 -0
- phase_loop_runtime/advisor_board/backing_omnigent.py +498 -0
- phase_loop_runtime/advisor_board/config.py +225 -0
- phase_loop_runtime/advisor_board/events.py +115 -0
- phase_loop_runtime/advisor_board/fixtures/advisor-boards.example.toml +59 -0
- phase_loop_runtime/advisor_board/fixtures.py +91 -0
- phase_loop_runtime/advisor_board/harness_mapping.py +125 -0
- phase_loop_runtime/advisor_board/matrix.py +146 -0
- phase_loop_runtime/advisor_board/observability.py +481 -0
- phase_loop_runtime/advisor_board/presets.py +188 -0
- phase_loop_runtime/advisor_board/registries.py +309 -0
- phase_loop_runtime/advisor_board/resolver.py +253 -0
- phase_loop_runtime/advisor_board/schema.py +269 -0
- phase_loop_runtime/advisor_board/standin.py +166 -0
- phase_loop_runtime/advisor_board/validation.py +159 -0
- phase_loop_runtime/agent_runtime_provider.py +676 -0
- phase_loop_runtime/baml_modular.py +656 -0
- phase_loop_runtime/baml_src/dotfiles_adoption_manifest.baml +37 -0
- phase_loop_runtime/baml_src/dotfiles_c4_document.baml +14 -0
- phase_loop_runtime/baml_src/dotfiles_plan_manifest.baml +50 -0
- phase_loop_runtime/baml_src/dotfiles_runtime_projection.baml +16 -0
- phase_loop_runtime/baml_src/dotfiles_task_catalog.baml +20 -0
- phase_loop_runtime/baml_src/emit_phase_closeout.baml +86 -0
- phase_loop_runtime/baml_src/evaluate_suspected_fake_evidence.baml +36 -0
- phase_loop_runtime/baml_src/verification_evidence.baml +40 -0
- phase_loop_runtime/broker.py +181 -0
- phase_loop_runtime/build_bundle.py +294 -0
- phase_loop_runtime/capability_registry.py +835 -0
- phase_loop_runtime/classifier.py +87 -0
- phase_loop_runtime/claude_agent_view.py +625 -0
- phase_loop_runtime/claude_channel_sidecar.py +1086 -0
- phase_loop_runtime/cli.py +2599 -0
- phase_loop_runtime/closeout.py +865 -0
- phase_loop_runtime/closeout_classifier.py +120 -0
- phase_loop_runtime/closeout_evidence_audit.py +112 -0
- phase_loop_runtime/closeout_validation.py +144 -0
- phase_loop_runtime/closeout_validators.py +203 -0
- phase_loop_runtime/consiliency_gates.py +374 -0
- phase_loop_runtime/consiliency_ingest.py +399 -0
- phase_loop_runtime/consiliency_layout.py +176 -0
- phase_loop_runtime/consiliency_scaffold.py +305 -0
- phase_loop_runtime/cross_repo_channel.py +331 -0
- phase_loop_runtime/discovery.py +2368 -0
- phase_loop_runtime/dispatch_lock.py +167 -0
- phase_loop_runtime/doc_delta_validator.py +98 -0
- phase_loop_runtime/docs_audit.py +285 -0
- phase_loop_runtime/docs_freshness.py +468 -0
- phase_loop_runtime/docs_surfaces.py +150 -0
- phase_loop_runtime/dotfiles_profile_plugin.py +96 -0
- phase_loop_runtime/events.py +154 -0
- phase_loop_runtime/events_migration.py +96 -0
- phase_loop_runtime/evidence_audit.py +1082 -0
- phase_loop_runtime/evidence_audit_config.py +172 -0
- phase_loop_runtime/fleet_map.py +426 -0
- phase_loop_runtime/fleet_metrics.py +298 -0
- phase_loop_runtime/fleet_metrics_export.py +196 -0
- phase_loop_runtime/git_discipline.py +479 -0
- phase_loop_runtime/git_ops.py +122 -0
- phase_loop_runtime/git_topology.py +252 -0
- phase_loop_runtime/governed_bundle.py +137 -0
- phase_loop_runtime/governed_premerge.py +219 -0
- phase_loop_runtime/governed_review.py +264 -0
- phase_loop_runtime/handoff.py +932 -0
- phase_loop_runtime/handoff_path.py +14 -0
- phase_loop_runtime/injection.py +740 -0
- phase_loop_runtime/install_status.py +88 -0
- phase_loop_runtime/lane_scheduler.py +317 -0
- phase_loop_runtime/launcher.py +2760 -0
- phase_loop_runtime/lease_store.py +553 -0
- phase_loop_runtime/maintenance.py +401 -0
- phase_loop_runtime/migrate_handoffs.py +201 -0
- phase_loop_runtime/models.py +2144 -0
- phase_loop_runtime/observability.py +1290 -0
- phase_loop_runtime/panel_invoker.py +1638 -0
- phase_loop_runtime/phase_loop_drift_audit.py +388 -0
- phase_loop_runtime/phase_worktree_executor.py +433 -0
- phase_loop_runtime/pipeline_adapter/__init__.py +12 -0
- phase_loop_runtime/pipeline_adapter/branch_ops.py +274 -0
- phase_loop_runtime/pipeline_adapter/flag.py +55 -0
- phase_loop_runtime/pipeline_adapter/markers.py +13 -0
- phase_loop_runtime/pipeline_adapter/merge_policy.py +72 -0
- phase_loop_runtime/pipeline_adapter/ratification.py +100 -0
- phase_loop_runtime/pipeline_adapter/sibling_matcher.py +123 -0
- phase_loop_runtime/plan_ir.py +523 -0
- phase_loop_runtime/plan_manifest.py +415 -0
- phase_loop_runtime/planner_validation.py +176 -0
- phase_loop_runtime/profiles.py +462 -0
- phase_loop_runtime/prompts.py +365 -0
- phase_loop_runtime/provenance.py +165 -0
- phase_loop_runtime/publishing.py +339 -0
- phase_loop_runtime/py.typed +1 -0
- phase_loop_runtime/reconcile.py +1550 -0
- phase_loop_runtime/redaction.py +184 -0
- phase_loop_runtime/reflection_sync.py +293 -0
- phase_loop_runtime/release_guard.py +186 -0
- phase_loop_runtime/render.py +432 -0
- phase_loop_runtime/repo_validation.py +427 -0
- phase_loop_runtime/review_summary.py +150 -0
- phase_loop_runtime/roadmap_lint.py +425 -0
- phase_loop_runtime/route_log.py +59 -0
- phase_loop_runtime/route_policy/__init__.py +11 -0
- phase_loop_runtime/route_policy/fixtures/route_selection.golden.json +263 -0
- phase_loop_runtime/runner.py +8400 -0
- phase_loop_runtime/runtime_paths.py +225 -0
- phase_loop_runtime/runtime_projection.py +113 -0
- phase_loop_runtime/runtime_resources.py +36 -0
- phase_loop_runtime/schema_export.py +292 -0
- phase_loop_runtime/schemas/phase_loop_closeout.schema.json +366 -0
- phase_loop_runtime/skill_install.py +176 -0
- phase_loop_runtime/skill_inventory.py +662 -0
- phase_loop_runtime/skill_paths.py +42 -0
- phase_loop_runtime/skill_sources_plugin.py +37 -0
- phase_loop_runtime/skills_bundle/claude-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/claude-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/claude-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-execute-detailed/SKILL.md +152 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/SKILL.md +745 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/claude-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-phase-loop/SKILL.md +115 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/SKILL.md +439 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/SKILL.md +334 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/SKILL.md +776 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/claude-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/SKILL.md +243 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/SKILL.md +249 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/SKILL.md +74 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/codex-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/codex-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/codex-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-execute-detailed/SKILL.md +154 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/SKILL.md +231 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/codex-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-phase-loop/SKILL.md +248 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/SKILL.md +150 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/SKILL.md +307 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/codex-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/SKILL.md +94 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/SKILL.md +114 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/SKILL.md +102 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/gemini-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-execute-detailed/SKILL.md +144 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/SKILL.md +198 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/gemini-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-phase-loop/SKILL.md +111 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/SKILL.md +147 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/SKILL.md +217 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/gemini-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/SKILL.md +87 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/SKILL.md +109 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/SKILL.md +98 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/opencode-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-execute-detailed/SKILL.md +144 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/SKILL.md +196 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/opencode-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-phase-loop/SKILL.md +113 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/SKILL.md +147 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/SKILL.md +211 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/opencode-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/SKILL.md +87 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/SKILL.md +109 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/SKILL.md +98 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/state.py +156 -0
- phase_loop_runtime/state_degradation.py +136 -0
- phase_loop_runtime/state_ops.py +676 -0
- phase_loop_runtime/train_ledger.py +215 -0
- phase_loop_runtime/train_roadmap.py +339 -0
- phase_loop_runtime/train_runner.py +1197 -0
- phase_loop_runtime/verification_evidence.py +650 -0
- phase_loop_runtime/verification_evidence_validator.py +75 -0
- phase_loop_runtime/visual_evidence_validator.py +36 -0
- phase_loop_runtime/worker_pool.py +123 -0
- phase_loop_runtime/worktree_index.py +270 -0
- phase_loop_runtime-0.2.0.data/data/share/phase-loop-runtime/protocol/protocol.md +2475 -0
- phase_loop_runtime-0.2.0.dist-info/METADATA +106 -0
- phase_loop_runtime-0.2.0.dist-info/RECORD +370 -0
- phase_loop_runtime-0.2.0.dist-info/WHEEL +5 -0
- phase_loop_runtime-0.2.0.dist-info/entry_points.txt +9 -0
- phase_loop_runtime-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1197 @@
|
|
|
1
|
+
"""Cross-repo release-train coordinator (P3 + P4).
|
|
2
|
+
|
|
3
|
+
P3: Serial draft-PR execution: topo-sort the train, preflight ALL repos, then
|
|
4
|
+
per node (in topo order): inject upstream draft ref via set_upstream_ref →
|
|
5
|
+
invoke the unchanged per-repo run_loop → publish a draft PR → append to ledger.
|
|
6
|
+
|
|
7
|
+
P4 (``_merge_phase_enabled=True``): After all draft PRs are open, hold for a
|
|
8
|
+
train-level governed review (one round, reusing ``run_governed_premerge_loop``).
|
|
9
|
+
On panel approval, merge sequentially in topo order. Before merging each
|
|
10
|
+
downstream node, re-resolve its channel to the upstream MERGED SHA and re-verify
|
|
11
|
+
(the false-green killer — NEVER merge a downstream that was only green against
|
|
12
|
+
the draft ref). Forward-only: a downstream failure does NOT revert merged
|
|
13
|
+
upstream nodes; use expand/contract upstream contracts to keep sequential merges
|
|
14
|
+
safe.
|
|
15
|
+
|
|
16
|
+
Safety invariants (enforced structurally, asserted in tests):
|
|
17
|
+
1. **Zero-PRs-on-preflight-failure**: preflight runs on ALL repos before the
|
|
18
|
+
per-node loop is entered. If any check fails, ``run_train`` returns
|
|
19
|
+
immediately with ``status="preflight_failed"`` and zero publish calls.
|
|
20
|
+
Train-schema validation (T-A/B/C/D via ``validate_train_loud``) runs as
|
|
21
|
+
part of this gate — a malformed train (e.g. a ``none``-channel dependency
|
|
22
|
+
edge) opens zero PRs.
|
|
23
|
+
2. **Draft-only** (P3): every ``publish_from_worktree`` call uses
|
|
24
|
+
``draft=True``. P3 never merges.
|
|
25
|
+
3. **Train state off .phase-loop/**: ledger_path is caller-supplied and must
|
|
26
|
+
pass ``_assert_not_phase_loop``; the coordinator never touches any repo's
|
|
27
|
+
``.phase-loop/`` directory.
|
|
28
|
+
4. **Resumable with upstream-change detection**: a partial run leaves prior
|
|
29
|
+
nodes' draft PRs open and the failed node ``blocked`` in the ledger.
|
|
30
|
+
Re-running re-reads both the ledger and live PR state; confirmed-open
|
|
31
|
+
nodes are skipped unless an upstream changed (rebuilt this run, or its live
|
|
32
|
+
head SHA diverged from the ledger — out-of-band push). When an upstream
|
|
33
|
+
changed and the downstream's PR is already open, the downstream is
|
|
34
|
+
**blocked with a clear reason** (``upstream_changed_downstream_pr_open``)
|
|
35
|
+
so the user can close the stale PR and re-run.
|
|
36
|
+
NOTE: automatic downstream rebuild when an upstream changes requires an
|
|
37
|
+
update-existing-PR primitive and is deferred to a future release.
|
|
38
|
+
5. **Exception safety**: if inject or run_loop raises, the node is marked
|
|
39
|
+
``blocked`` in the ledger (never left stuck at ``running``).
|
|
40
|
+
6. **Autonomy boundary** (P4): cross-repo merges are NEVER auto-merged. In
|
|
41
|
+
autonomous mode with ``_merge_phase_enabled=True`` the coordinator stops at
|
|
42
|
+
``status="drafts_open"`` so the operator can review and re-run with
|
|
43
|
+
``--governed``. Only ``run_mode="governed"`` proceeds to review + merge.
|
|
44
|
+
7. **False-green killer** (P4): before merging each downstream node,
|
|
45
|
+
``set_upstream_ref`` is called with the upstream MERGED SHA (not the draft
|
|
46
|
+
SHA) and ``_reverify_fn`` re-verifies the downstream. A downstream that
|
|
47
|
+
was green only against the draft ref is blocked (``merge_halted``).
|
|
48
|
+
8. **Idempotent resume** (P4): ledger ``merged`` records carry
|
|
49
|
+
``upstream_merge_sha`` (the real merge-commit SHA) plus ``branch``,
|
|
50
|
+
``pr_url``, and ``head_sha`` so the resumed run can skip already-merged
|
|
51
|
+
nodes without double-merging and can still inject the correct draft SHA
|
|
52
|
+
for any remaining downstream nodes.
|
|
53
|
+
|
|
54
|
+
All git/gh/run_loop/publish/review boundaries are injectable seams so the
|
|
55
|
+
module is fully testable without live network access.
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import subprocess
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import Callable, Dict, List, Optional, Sequence, Set
|
|
62
|
+
|
|
63
|
+
from .cross_repo_channel import ChannelDescriptor, set_upstream_ref
|
|
64
|
+
from .train_ledger import LedgerRecord, append_record, read_ledger
|
|
65
|
+
from .train_roadmap import TrainEdge, TrainNode, TrainRoadmap
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Types
|
|
69
|
+
|
|
70
|
+
ResolveWorkspace = Callable[[TrainNode], Path]
|
|
71
|
+
ResolveOwnedPaths = Callable[[TrainNode], Sequence[str]]
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Preflight check functions
|
|
75
|
+
# Each is a module-level function so tests can patch it individually.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_gh_auth() -> Optional[str]:
|
|
79
|
+
"""Return an error string if gh auth is not valid, else None.
|
|
80
|
+
|
|
81
|
+
Stubbable seam: ``patch("phase_loop_runtime.train_runner._check_gh_auth")``.
|
|
82
|
+
"""
|
|
83
|
+
completed = subprocess.run(
|
|
84
|
+
["gh", "auth", "status"],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
timeout=15,
|
|
88
|
+
)
|
|
89
|
+
if completed.returncode != 0:
|
|
90
|
+
return f"gh auth status failed: {completed.stderr.strip() or 'not authenticated'}"
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_repo_clean(workspace: Path, node_id: str) -> Optional[str]:
|
|
95
|
+
"""Return an error string if the workspace has uncommitted changes, else None."""
|
|
96
|
+
completed = subprocess.run(
|
|
97
|
+
["git", "-C", str(workspace), "status", "--short"],
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
timeout=15,
|
|
101
|
+
)
|
|
102
|
+
if completed.returncode != 0:
|
|
103
|
+
return (
|
|
104
|
+
f"[{node_id}] git status failed "
|
|
105
|
+
f"(workspace may not be a git repo): {completed.stderr.strip()}"
|
|
106
|
+
)
|
|
107
|
+
if completed.stdout.strip():
|
|
108
|
+
return f"[{node_id}] workspace '{workspace}' has uncommitted changes — preflight failed"
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_remote_reachable(workspace: Path, node_id: str, remote: str = "origin") -> Optional[str]:
|
|
113
|
+
"""Return an error string if the remote is not reachable, else None."""
|
|
114
|
+
completed = subprocess.run(
|
|
115
|
+
["git", "-C", str(workspace), "ls-remote", "--exit-code", remote, "HEAD"],
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
timeout=30,
|
|
119
|
+
)
|
|
120
|
+
if completed.returncode != 0:
|
|
121
|
+
return (
|
|
122
|
+
f"[{node_id}] remote '{remote}' is not reachable: "
|
|
123
|
+
f"{completed.stderr.strip() or 'ls-remote failed'}"
|
|
124
|
+
)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_base_branch_exists(
|
|
129
|
+
workspace: Path, node_id: str, base: str = "main"
|
|
130
|
+
) -> Optional[str]:
|
|
131
|
+
"""Return an error string if origin/<base> does not exist, else None."""
|
|
132
|
+
completed = subprocess.run(
|
|
133
|
+
["git", "-C", str(workspace), "rev-parse", "--verify", f"origin/{base}"],
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
timeout=10,
|
|
137
|
+
)
|
|
138
|
+
if completed.returncode != 0:
|
|
139
|
+
return f"[{node_id}] base branch 'origin/{base}' does not exist"
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _default_preflight(
|
|
144
|
+
nodes: List[TrainNode],
|
|
145
|
+
resolve_workspace: ResolveWorkspace,
|
|
146
|
+
) -> List[str]:
|
|
147
|
+
"""Run all preflight checks across all nodes; return list of errors (empty = pass).
|
|
148
|
+
|
|
149
|
+
Checks (in order):
|
|
150
|
+
1. ``gh auth status`` — once, globally.
|
|
151
|
+
2. Per-repo: workspace clean (no uncommitted changes).
|
|
152
|
+
3. Per-repo: remote ``origin`` is reachable.
|
|
153
|
+
4. Per-repo: base branch ``origin/main`` exists.
|
|
154
|
+
|
|
155
|
+
A non-empty return means the entry gate is closed; zero PRs must be opened.
|
|
156
|
+
"""
|
|
157
|
+
errors: List[str] = []
|
|
158
|
+
|
|
159
|
+
# gh auth — once globally, before touching any repo
|
|
160
|
+
auth_err = _check_gh_auth()
|
|
161
|
+
if auth_err:
|
|
162
|
+
errors.append(auth_err)
|
|
163
|
+
|
|
164
|
+
for node in nodes:
|
|
165
|
+
workspace = resolve_workspace(node)
|
|
166
|
+
for check_fn in (
|
|
167
|
+
_check_repo_clean,
|
|
168
|
+
_check_remote_reachable,
|
|
169
|
+
_check_base_branch_exists,
|
|
170
|
+
):
|
|
171
|
+
err = check_fn(workspace, node.node_id)
|
|
172
|
+
if err:
|
|
173
|
+
errors.append(err)
|
|
174
|
+
|
|
175
|
+
return errors
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Live PR state seams
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _live_pr_is_open(workspace: Path, branch: str) -> bool:
|
|
183
|
+
"""Return True if ``branch`` has an open PR on the remote.
|
|
184
|
+
|
|
185
|
+
Stubbable seam for tests. Uses ``_gh_pr_metadata`` from ``git_topology``
|
|
186
|
+
(already reused by the P1 publish primitive for the same reason).
|
|
187
|
+
"""
|
|
188
|
+
from .git_topology import _gh_pr_metadata
|
|
189
|
+
|
|
190
|
+
meta = _gh_pr_metadata(workspace, branch)
|
|
191
|
+
return bool(meta.get("pr_url"))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _live_pr_head_sha(workspace: Path, branch: str) -> Optional[str]:
|
|
195
|
+
"""Return the live PR head commit SHA for ``branch``, or None if unavailable.
|
|
196
|
+
|
|
197
|
+
Queries ``gh pr list`` (the same endpoint as ``_gh_pr_metadata``, which uses
|
|
198
|
+
the proven ``--head <branch>`` flag) and extracts ``headRefOid`` — the commit
|
|
199
|
+
SHA at the PR's head, which may differ from the ledger-recorded value if the
|
|
200
|
+
branch was force-pushed since the last run.
|
|
201
|
+
|
|
202
|
+
Uses ``gh pr list --head <branch>`` (not ``gh pr view``, which takes a PR
|
|
203
|
+
number, not a branch ref).
|
|
204
|
+
|
|
205
|
+
Stubbable seam: inject ``_live_pr_head_sha_fn`` into :func:`run_train`.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
completed = subprocess.run(
|
|
209
|
+
[
|
|
210
|
+
"gh", "pr", "list",
|
|
211
|
+
"--head", branch,
|
|
212
|
+
"--state", "open",
|
|
213
|
+
"--limit", "1",
|
|
214
|
+
"--json", "headRefOid",
|
|
215
|
+
"--jq", ".[0].headRefOid",
|
|
216
|
+
],
|
|
217
|
+
capture_output=True,
|
|
218
|
+
text=True,
|
|
219
|
+
timeout=15,
|
|
220
|
+
cwd=str(workspace),
|
|
221
|
+
)
|
|
222
|
+
sha = completed.stdout.strip() if completed.returncode == 0 else ""
|
|
223
|
+
return sha or None
|
|
224
|
+
except Exception:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# P4 merge seams — stubbed in tests; live implementations below.
|
|
230
|
+
|
|
231
|
+
#: Synthetic ledger node_id used to record the train-level review approval.
|
|
232
|
+
#: Not a real roadmap node — only stored in the coordinator ledger.
|
|
233
|
+
_TRAIN_REVIEW_NODE_ID: str = "_train_review_"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _live_merge_pr(workspace: Path, branch: str) -> str:
|
|
237
|
+
"""Merge the PR for ``branch`` via the GitHub CLI; return the merge commit SHA.
|
|
238
|
+
|
|
239
|
+
Idempotent: checks ``_live_pr_merged_sha`` before issuing the merge command.
|
|
240
|
+
Already-merged PRs return the existing SHA without error.
|
|
241
|
+
|
|
242
|
+
Stubbable seam: inject ``_merge_pr_fn`` into :func:`run_train`.
|
|
243
|
+
"""
|
|
244
|
+
# Idempotent guard: if already merged, return the existing SHA.
|
|
245
|
+
existing = _live_pr_merged_sha(workspace, branch)
|
|
246
|
+
if existing:
|
|
247
|
+
return existing
|
|
248
|
+
|
|
249
|
+
subprocess.run(
|
|
250
|
+
["gh", "pr", "merge", branch, "--merge", "--delete-branch", "--yes"],
|
|
251
|
+
cwd=str(workspace),
|
|
252
|
+
check=True,
|
|
253
|
+
capture_output=True,
|
|
254
|
+
text=True,
|
|
255
|
+
timeout=120,
|
|
256
|
+
)
|
|
257
|
+
result = subprocess.run(
|
|
258
|
+
[
|
|
259
|
+
"gh", "pr", "view", branch,
|
|
260
|
+
"--json", "mergeCommit",
|
|
261
|
+
"--jq", ".mergeCommit.oid",
|
|
262
|
+
],
|
|
263
|
+
cwd=str(workspace),
|
|
264
|
+
capture_output=True,
|
|
265
|
+
text=True,
|
|
266
|
+
timeout=30,
|
|
267
|
+
)
|
|
268
|
+
sha = result.stdout.strip()
|
|
269
|
+
if not sha or sha == "null":
|
|
270
|
+
raise RuntimeError(
|
|
271
|
+
f"could not determine merge commit SHA for branch '{branch}' in "
|
|
272
|
+
f"'{workspace}'; gh pr view returned no mergeCommit.oid after merge"
|
|
273
|
+
)
|
|
274
|
+
return sha
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _train_reverify_enforcement_mode() -> str:
|
|
278
|
+
import os
|
|
279
|
+
|
|
280
|
+
value = os.environ.get("PHASE_LOOP_VERIFY_ENFORCE", "warn").strip().lower()
|
|
281
|
+
return "hard" if value == "hard" else "warn"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _live_reverify(workspace: Path, roadmap_path: Path, run_mode: str) -> bool:
|
|
285
|
+
"""Re-verify a downstream node against the injected upstream merged pin.
|
|
286
|
+
|
|
287
|
+
Called after ``set_upstream_ref`` writes the merged SHA into the downstream
|
|
288
|
+
workspace. Runs the downstream's verification commands (from its plan file)
|
|
289
|
+
directly against that workspace and returns True only when they all pass.
|
|
290
|
+
|
|
291
|
+
This is the live default for ``_reverify_fn``. Tests stub this seam.
|
|
292
|
+
|
|
293
|
+
NOTE: Does NOT re-publish or open a new PR — only re-verifies. The
|
|
294
|
+
existing draft PR from P3 remains open. The merged-pin file that
|
|
295
|
+
``set_upstream_ref`` wrote is read by whatever commands the plan declares
|
|
296
|
+
in its ``## Verification`` section.
|
|
297
|
+
|
|
298
|
+
Fail-closed: if the plan cannot be located or verification cannot be run
|
|
299
|
+
(for any reason), returns False — never silently green.
|
|
300
|
+
"""
|
|
301
|
+
import os
|
|
302
|
+
|
|
303
|
+
from .discovery import find_plan_artifact, resolve_suite_command_doc, verification_commands_from_plan
|
|
304
|
+
from .reconcile import reconcile
|
|
305
|
+
from .verification_evidence import (
|
|
306
|
+
ARTIFACT_NAME,
|
|
307
|
+
detect_changed_dependency_manifests,
|
|
308
|
+
resolve_install_command,
|
|
309
|
+
run_verification,
|
|
310
|
+
validate_verification_artifact,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
# 1. Find the current phase from the workspace state. The node was
|
|
315
|
+
# left at awaiting_phase_closeout by the P3 run_loop call; reconcile
|
|
316
|
+
# reads the persisted state and event log to reconstruct that status.
|
|
317
|
+
snapshot = reconcile(workspace, roadmap_path)
|
|
318
|
+
phase = snapshot.current_phase
|
|
319
|
+
if phase is None:
|
|
320
|
+
# Fallback: scan for any phase still at awaiting_phase_closeout.
|
|
321
|
+
for ph, status in snapshot.phases.items():
|
|
322
|
+
if status == "awaiting_phase_closeout":
|
|
323
|
+
phase = ph
|
|
324
|
+
break
|
|
325
|
+
if phase is None:
|
|
326
|
+
# Cannot determine which phase to verify — fail closed.
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
# 2. Locate the plan file (same resolver the closeout path uses).
|
|
330
|
+
plan = find_plan_artifact(workspace, phase, roadmap=roadmap_path)
|
|
331
|
+
if plan is None:
|
|
332
|
+
# No plan = cannot verify → fail closed.
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
# 3. Extract verification commands from the plan.
|
|
336
|
+
commands, operational_exemptions = verification_commands_from_plan(plan)
|
|
337
|
+
suite_command, suite_findings = resolve_suite_command_doc(workspace, roadmap_path, plan)
|
|
338
|
+
if suite_findings:
|
|
339
|
+
# Malformed suite command — fail closed.
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
# 4. If the plan declares no verification at all:
|
|
343
|
+
# - Under PHASE_LOOP_VERIFY_ENFORCE=hard, the re-verify gate cannot
|
|
344
|
+
# prove the downstream still survives the upstream's MERGED-pin
|
|
345
|
+
# contract, so it must fail-closed (return False -> the train halts
|
|
346
|
+
# at merge_halted). This is a NON-HUMAN terminal -- never adds
|
|
347
|
+
# human_required -- preserving autonomy-first. Mirrors the single-repo
|
|
348
|
+
# execute preflight (runner.py `_verification_enforcement_mode` /
|
|
349
|
+
# `_execute_verification_preflight_blocker`, which blocks on a missing
|
|
350
|
+
# suite command under hard enforce). [#39]
|
|
351
|
+
# - Under warn (the default), treat as a trivial pass (the plan author
|
|
352
|
+
# deliberately chose not to add verification).
|
|
353
|
+
if not commands and suite_command is None:
|
|
354
|
+
return _train_reverify_enforcement_mode() != "hard"
|
|
355
|
+
|
|
356
|
+
# 5. Run verification against the workspace. set_upstream_ref has
|
|
357
|
+
# already written the merged-pin file, so commands that read the
|
|
358
|
+
# pin file will see the merged SHA.
|
|
359
|
+
manifests = detect_changed_dependency_manifests(workspace, "HEAD")
|
|
360
|
+
install_argv = resolve_install_command(workspace, manifests) if manifests else None
|
|
361
|
+
env_refresh = (
|
|
362
|
+
{
|
|
363
|
+
"triggered": True,
|
|
364
|
+
"manifests": manifests,
|
|
365
|
+
"install_argv": install_argv or [],
|
|
366
|
+
"exit_code": 127,
|
|
367
|
+
}
|
|
368
|
+
if manifests and install_argv is None
|
|
369
|
+
else (
|
|
370
|
+
{"triggered": True, "manifests": manifests, "install_argv": install_argv}
|
|
371
|
+
if manifests
|
|
372
|
+
else None
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
timeout_s = float(os.environ.get("PHASE_LOOP_VERIFY_TIMEOUT_SECONDS", "1200"))
|
|
376
|
+
# run_verification requires run_dir inside the workspace (the same
|
|
377
|
+
# constraint run_artifacts enforces). Use a timestamped subdirectory
|
|
378
|
+
# under .phase-loop/runs/ so the artifact is discoverable.
|
|
379
|
+
from .models import utc_now
|
|
380
|
+
run_id = f"{utc_now().replace(':', '').replace('-', '').replace('Z', 'Z')}-reverify"
|
|
381
|
+
run_dir = workspace / ".phase-loop" / "runs" / run_id
|
|
382
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
run_verification(
|
|
384
|
+
workspace,
|
|
385
|
+
run_dir,
|
|
386
|
+
commands,
|
|
387
|
+
suite_command,
|
|
388
|
+
env_refresh,
|
|
389
|
+
timeout_s,
|
|
390
|
+
operational_exemptions=operational_exemptions,
|
|
391
|
+
)
|
|
392
|
+
artifact_path = run_dir / ARTIFACT_NAME
|
|
393
|
+
validation = validate_verification_artifact(artifact_path)
|
|
394
|
+
return validation.ok
|
|
395
|
+
except Exception:
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _live_pr_merged_sha(workspace: Path, branch: str) -> Optional[str]:
|
|
400
|
+
"""Return the merge-commit SHA if the PR for ``branch`` is already merged, else None.
|
|
401
|
+
|
|
402
|
+
Used for idempotent resume: the merge loop skips nodes whose PR has already
|
|
403
|
+
landed on main (covers crash-between-merge-and-ledger-write).
|
|
404
|
+
|
|
405
|
+
Stubbable seam: inject ``_pr_merged_sha_fn`` into :func:`run_train`.
|
|
406
|
+
"""
|
|
407
|
+
try:
|
|
408
|
+
result = subprocess.run(
|
|
409
|
+
[
|
|
410
|
+
"gh", "pr", "view", branch,
|
|
411
|
+
"--json", "state,mergeCommit",
|
|
412
|
+
"--jq", 'if .state == "MERGED" then .mergeCommit.oid else null end',
|
|
413
|
+
],
|
|
414
|
+
cwd=str(workspace),
|
|
415
|
+
capture_output=True,
|
|
416
|
+
text=True,
|
|
417
|
+
timeout=15,
|
|
418
|
+
)
|
|
419
|
+
sha = result.stdout.strip()
|
|
420
|
+
return sha if (result.returncode == 0 and sha and sha not in {"null", ""}) else None
|
|
421
|
+
except Exception:
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _default_train_review(artifact: str, run_mode: str) -> "LoopResult":
|
|
426
|
+
"""Train-level governed review: one-round bounded panel review.
|
|
427
|
+
|
|
428
|
+
Returns a :class:`LoopResult` with ``mergeable=True`` on approval or a
|
|
429
|
+
non-human terminal blocker (``human_required=False``) on rejection.
|
|
430
|
+
|
|
431
|
+
In ``autonomous`` mode ``run_governed_premerge_loop`` short-circuits to
|
|
432
|
+
``mergeable=True`` without spawning a panel — callers should gate on
|
|
433
|
+
``run_mode == "governed"`` before reaching here (P4 enforces this).
|
|
434
|
+
|
|
435
|
+
Stubbable seam: inject ``_train_review_fn`` into :func:`run_train`.
|
|
436
|
+
"""
|
|
437
|
+
from .governed_premerge import LoopResult, run_governed_premerge_loop
|
|
438
|
+
|
|
439
|
+
return run_governed_premerge_loop(
|
|
440
|
+
artifact=artifact,
|
|
441
|
+
author_executor="train-coordinator",
|
|
442
|
+
run_mode=run_mode,
|
|
443
|
+
max_rounds=1,
|
|
444
|
+
apply_fix=None,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _build_train_review_bundle(
|
|
449
|
+
roadmap: "TrainRoadmap",
|
|
450
|
+
completed_nodes: Dict[str, Dict],
|
|
451
|
+
topo_order: "List[TrainNode]",
|
|
452
|
+
) -> str:
|
|
453
|
+
"""Build the artifact text for the train-level review panel.
|
|
454
|
+
|
|
455
|
+
Summarises all draft PRs in merge order so the panel can review the
|
|
456
|
+
cross-repo change as one logical unit.
|
|
457
|
+
"""
|
|
458
|
+
lines: List[str] = [
|
|
459
|
+
"# Train-level bundle review\n\n",
|
|
460
|
+
f"**Train:** `{roadmap.title}`\n\n",
|
|
461
|
+
"## Draft PRs (merge order)\n\n",
|
|
462
|
+
"Review the following PRs as **one logical cross-repo change**.\n",
|
|
463
|
+
"Approve (AGREE) only if the change is correct as a unit.\n\n",
|
|
464
|
+
]
|
|
465
|
+
for i, node in enumerate(topo_order, 1):
|
|
466
|
+
nid = node.node_id
|
|
467
|
+
info = completed_nodes.get(nid, {})
|
|
468
|
+
pr_url = info.get("pr_url", "(unknown)")
|
|
469
|
+
head_sha = info.get("head_sha") or "?"
|
|
470
|
+
short_sha = head_sha[:8] if len(head_sha) >= 8 else head_sha
|
|
471
|
+
lines.append(f"{i}. **`{nid}`** — [PR]({pr_url}) (draft `{short_sha}`)\n")
|
|
472
|
+
lines.append(
|
|
473
|
+
"\n---\n"
|
|
474
|
+
"Reject (DISAGREE) with specific blocking concerns if not ready.\n"
|
|
475
|
+
)
|
|
476
|
+
return "".join(lines)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
# PR body builder
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _build_pr_body(
|
|
484
|
+
node: TrainNode,
|
|
485
|
+
topo_order: List[TrainNode],
|
|
486
|
+
upstream_results: Dict[str, Dict],
|
|
487
|
+
upstream_edges: List[TrainEdge],
|
|
488
|
+
) -> str:
|
|
489
|
+
"""Build the PR body with cross-repo dependency links and merge order.
|
|
490
|
+
|
|
491
|
+
At creation time, upstream PRs are already open (topo order guarantees
|
|
492
|
+
it). Downstream PRs are not yet open, so only backward-links are included.
|
|
493
|
+
"""
|
|
494
|
+
lines: List[str] = [
|
|
495
|
+
f"## Cross-repo release train\n\n",
|
|
496
|
+
f"**Node:** `{node.node_id}`\n\n",
|
|
497
|
+
]
|
|
498
|
+
|
|
499
|
+
if upstream_edges:
|
|
500
|
+
lines.append("### Upstream dependencies (must merge first)\n\n")
|
|
501
|
+
for edge in upstream_edges:
|
|
502
|
+
result = upstream_results.get(edge.upstream.node_id, {})
|
|
503
|
+
pr_url = result.get("pr_url", "(not yet open)")
|
|
504
|
+
lines.append(f"- [{edge.upstream.node_id}]({pr_url})\n")
|
|
505
|
+
lines.append("\n")
|
|
506
|
+
|
|
507
|
+
lines.append("### Train merge order\n\n")
|
|
508
|
+
for i, n in enumerate(topo_order, 1):
|
|
509
|
+
marker = " **(this PR)**" if n.node_id == node.node_id else ""
|
|
510
|
+
lines.append(f"{i}. `{n.node_id}`{marker}\n")
|
|
511
|
+
|
|
512
|
+
return "".join(lines)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
# Main coordinator
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def run_train(
|
|
520
|
+
roadmap: TrainRoadmap,
|
|
521
|
+
ledger_path: Path,
|
|
522
|
+
*,
|
|
523
|
+
run_mode: str = "autonomous",
|
|
524
|
+
resolve_workspace: ResolveWorkspace,
|
|
525
|
+
resolve_owned_paths: Optional[ResolveOwnedPaths] = None,
|
|
526
|
+
# Injectable seams — default to the live implementations; tests override.
|
|
527
|
+
_run_loop: Optional[Callable] = None,
|
|
528
|
+
_publish: Optional[Callable] = None,
|
|
529
|
+
_set_upstream_ref_fn: Optional[Callable] = None,
|
|
530
|
+
_pr_is_open: Optional[Callable] = None,
|
|
531
|
+
_live_pr_head_sha_fn: Optional[Callable] = None,
|
|
532
|
+
_preflight_fn: Optional[Callable] = None,
|
|
533
|
+
# P4 gate: False (default) preserves P3 behavior for all existing callers.
|
|
534
|
+
# The CLI sets this True; run_mode then determines autonomous vs governed.
|
|
535
|
+
_merge_phase_enabled: bool = False,
|
|
536
|
+
# P4 seams — unused when _merge_phase_enabled is False.
|
|
537
|
+
_merge_pr_fn: Optional[Callable] = None, # (workspace, branch) → merged_sha
|
|
538
|
+
_reverify_fn: Optional[Callable] = None, # (workspace, roadmap_path, run_mode) → bool
|
|
539
|
+
_train_review_fn: Optional[Callable] = None, # (artifact, run_mode) → LoopResult
|
|
540
|
+
_pr_merged_sha_fn: Optional[Callable] = None, # (workspace, branch) → Optional[str]
|
|
541
|
+
) -> Dict:
|
|
542
|
+
"""Coordinate a cross-repo release train: preflight, topo-sort, draft-PR open [+ merge].
|
|
543
|
+
|
|
544
|
+
Parameters
|
|
545
|
+
----------
|
|
546
|
+
roadmap:
|
|
547
|
+
Parsed ``TrainRoadmap`` (P2 schema).
|
|
548
|
+
ledger_path:
|
|
549
|
+
Path to the coordinator-side ledger file. Must not be inside any
|
|
550
|
+
repo's ``.phase-loop/`` (enforced by ``append_record``).
|
|
551
|
+
run_mode:
|
|
552
|
+
``"autonomous"`` or ``"governed"``. Passed unchanged to each
|
|
553
|
+
per-repo ``run_loop`` call and to the P4 review/merge gate.
|
|
554
|
+
resolve_workspace:
|
|
555
|
+
Maps a ``TrainNode`` to its workspace ``Path`` on disk.
|
|
556
|
+
resolve_owned_paths:
|
|
557
|
+
Maps a ``TrainNode`` to the list of paths the publish primitive
|
|
558
|
+
should stage. When ``None`` (the default for real end-to-end runs),
|
|
559
|
+
the coordinator uses the paths produced by ``run_loop`` itself:
|
|
560
|
+
``StateSnapshot.phase_owned_dirty_paths`` (or ``dirty_paths`` as
|
|
561
|
+
fallback). Callers may pass an explicit resolver to override this
|
|
562
|
+
(e.g. tests, or callers that know the paths ahead of time).
|
|
563
|
+
_run_loop, _publish, _set_upstream_ref_fn, _pr_is_open,
|
|
564
|
+
_live_pr_head_sha_fn, _preflight_fn:
|
|
565
|
+
P3 injectable seams for testing. Each defaults to the corresponding
|
|
566
|
+
live implementation.
|
|
567
|
+
_merge_phase_enabled:
|
|
568
|
+
When ``False`` (default): P3 behavior only — returns
|
|
569
|
+
``{"status": "completed"}`` once all draft PRs are open. This
|
|
570
|
+
preserves backward compatibility with all P3 callers and tests.
|
|
571
|
+
When ``True``: P4 merge logic activates. Autonomous mode stops at
|
|
572
|
+
``{"status": "drafts_open"}``; governed mode proceeds to review+merge.
|
|
573
|
+
_merge_pr_fn, _reverify_fn, _train_review_fn, _pr_merged_sha_fn:
|
|
574
|
+
P4 injectable seams. Each defaults to the corresponding live
|
|
575
|
+
implementation. Unused when ``_merge_phase_enabled=False``.
|
|
576
|
+
|
|
577
|
+
Returns
|
|
578
|
+
-------
|
|
579
|
+
dict
|
|
580
|
+
P3 statuses:
|
|
581
|
+
``{"status": "completed", "nodes": {…}}`` — all draft PRs open
|
|
582
|
+
(``_merge_phase_enabled=False``);
|
|
583
|
+
``{"status": "blocked", "node_id": …, "detail": …}`` — P3 node
|
|
584
|
+
failed; prior PRs remain open (resumable);
|
|
585
|
+
``{"status": "preflight_failed", "errors": […]}`` — zero PRs opened.
|
|
586
|
+
P4 statuses (``_merge_phase_enabled=True``):
|
|
587
|
+
``{"status": "drafts_open", "nodes": {…}}`` — all draft PRs open;
|
|
588
|
+
autonomous mode terminal (no merge);
|
|
589
|
+
``{"status": "review_halted", …}`` — panel rejected; ZERO merges
|
|
590
|
+
(``terminal_blocker`` carries ``human_required=False``);
|
|
591
|
+
``{"status": "merged", "nodes": {nid: {branch, merged_sha}}}`` —
|
|
592
|
+
all nodes merged in topo order;
|
|
593
|
+
``{"status": "merge_halted", "node_id": …, "reason": …}`` —
|
|
594
|
+
downstream re-verify failed; upstream stays merged (forward-only).
|
|
595
|
+
"""
|
|
596
|
+
# Resolve seams
|
|
597
|
+
from .publishing import publish_from_worktree as _default_publish
|
|
598
|
+
from .runner import run_loop as _default_run_loop
|
|
599
|
+
|
|
600
|
+
run_loop_fn = _run_loop if _run_loop is not None else _default_run_loop
|
|
601
|
+
publish_fn = _publish if _publish is not None else _default_publish
|
|
602
|
+
set_upstream_ref_fn = (
|
|
603
|
+
_set_upstream_ref_fn if _set_upstream_ref_fn is not None else set_upstream_ref
|
|
604
|
+
)
|
|
605
|
+
pr_is_open_fn = _pr_is_open if _pr_is_open is not None else _live_pr_is_open
|
|
606
|
+
live_pr_head_sha_fn = (
|
|
607
|
+
_live_pr_head_sha_fn if _live_pr_head_sha_fn is not None else _live_pr_head_sha
|
|
608
|
+
)
|
|
609
|
+
preflight_fn = _preflight_fn if _preflight_fn is not None else _default_preflight
|
|
610
|
+
|
|
611
|
+
# Track whether caller supplied an explicit owned-paths resolver so we know
|
|
612
|
+
# whether to fall back to the run_loop-produced snapshot paths (Finding #1).
|
|
613
|
+
_explicit_owned_paths = resolve_owned_paths is not None
|
|
614
|
+
|
|
615
|
+
# --- Step 0: Train-schema validation (T-A/B/C/D) — BEFORE any PR ------
|
|
616
|
+
# A malformed train (e.g. a none-channel dependency edge) must open ZERO
|
|
617
|
+
# PRs. validate_train_loud raises ValueError on any violation.
|
|
618
|
+
from .train_roadmap import validate_train_loud
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
validate_train_loud(roadmap)
|
|
622
|
+
except ValueError as exc:
|
|
623
|
+
return {
|
|
624
|
+
"status": "preflight_failed",
|
|
625
|
+
"errors": [f"train validation failed: {exc}"],
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
# --- Step 1: Topo-sort (raises ValueError on cycle) --------------------
|
|
629
|
+
topo_order = roadmap.topo_order()
|
|
630
|
+
|
|
631
|
+
# --- Step 2: Train-level preflight — ALL repos, BEFORE any PR ----------
|
|
632
|
+
# This is the structural guarantee that preflight failure → zero PRs:
|
|
633
|
+
# we return immediately here, before the per-node loop is entered.
|
|
634
|
+
preflight_errors = preflight_fn(topo_order, resolve_workspace)
|
|
635
|
+
if preflight_errors:
|
|
636
|
+
return {
|
|
637
|
+
"status": "preflight_failed",
|
|
638
|
+
"errors": preflight_errors,
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
# --- Step 3: Re-read ledger + live PR state (resume support) -----------
|
|
642
|
+
ledger_state = read_ledger(ledger_path)
|
|
643
|
+
# completed_nodes: node_id → {branch, head_sha, pr_url}
|
|
644
|
+
# These are the upstream refs the coordinator can inject into downstream
|
|
645
|
+
# nodes via set_upstream_ref (IF-0-P2-2).
|
|
646
|
+
completed_nodes: Dict[str, Dict] = {}
|
|
647
|
+
# out_of_band_upstreams: nodes whose live PR head SHA differs from the
|
|
648
|
+
# ledger-recorded head_sha — an out-of-band push since the last run.
|
|
649
|
+
out_of_band_upstreams: Set[str] = set()
|
|
650
|
+
|
|
651
|
+
for node in topo_order:
|
|
652
|
+
nid = node.node_id
|
|
653
|
+
rec = ledger_state.get(nid)
|
|
654
|
+
if rec and rec.status in ("pr_open", "merged") and rec.branch and rec.pr_url:
|
|
655
|
+
workspace = resolve_workspace(node)
|
|
656
|
+
if rec.status == "pr_open":
|
|
657
|
+
if not pr_is_open_fn(workspace, rec.branch):
|
|
658
|
+
# In P4 mode: before dropping, check whether this PR was
|
|
659
|
+
# already merged on GitHub (crash window between the merge
|
|
660
|
+
# call and the ledger write). If merged, recover the SHA
|
|
661
|
+
# and add to completed_nodes so downstream injection works
|
|
662
|
+
# correctly; write a merged record so the P4 merge loop
|
|
663
|
+
# skips it (idempotent, forward-only).
|
|
664
|
+
if _merge_phase_enabled:
|
|
665
|
+
_step3_merged_sha_fn = (
|
|
666
|
+
_pr_merged_sha_fn if _pr_merged_sha_fn is not None
|
|
667
|
+
else _live_pr_merged_sha
|
|
668
|
+
)
|
|
669
|
+
_recovered_sha = _step3_merged_sha_fn(workspace, rec.branch)
|
|
670
|
+
if _recovered_sha:
|
|
671
|
+
_recovered_rec = LedgerRecord(
|
|
672
|
+
node_id=nid,
|
|
673
|
+
status="merged",
|
|
674
|
+
branch=rec.branch,
|
|
675
|
+
pr_url=rec.pr_url,
|
|
676
|
+
head_sha=rec.head_sha,
|
|
677
|
+
upstream_merge_sha=_recovered_sha,
|
|
678
|
+
)
|
|
679
|
+
append_record(ledger_path, _recovered_rec)
|
|
680
|
+
# Update in-memory view so Step 4's merged-node skip
|
|
681
|
+
# (nid_rec.status == "merged" → continue) fires correctly.
|
|
682
|
+
ledger_state[nid] = _recovered_rec
|
|
683
|
+
completed_nodes[nid] = {
|
|
684
|
+
"branch": rec.branch,
|
|
685
|
+
"head_sha": rec.head_sha,
|
|
686
|
+
"pr_url": rec.pr_url,
|
|
687
|
+
}
|
|
688
|
+
continue # not open: recovered-as-merged or dropped
|
|
689
|
+
# Prefer the live PR head SHA (the branch may have been updated
|
|
690
|
+
# since the last run); fall back to the ledger-recorded head_sha.
|
|
691
|
+
live_sha = live_pr_head_sha_fn(workspace, rec.branch)
|
|
692
|
+
head_sha = live_sha or rec.head_sha
|
|
693
|
+
# Detect out-of-band push: live SHA exists and differs from ledger.
|
|
694
|
+
if live_sha and rec.head_sha and live_sha != rec.head_sha:
|
|
695
|
+
out_of_band_upstreams.add(nid)
|
|
696
|
+
else:
|
|
697
|
+
# status == "merged" — P4 resume: PR is already merged, no live check.
|
|
698
|
+
# head_sha carries the draft SHA (written to the merged record by P4
|
|
699
|
+
# so this node can still serve as the injection ref for any P3 nodes
|
|
700
|
+
# that were not yet processed before the crash — see merged-record write).
|
|
701
|
+
head_sha = rec.head_sha
|
|
702
|
+
completed_nodes[nid] = {
|
|
703
|
+
"branch": rec.branch,
|
|
704
|
+
"head_sha": head_sha,
|
|
705
|
+
"pr_url": rec.pr_url,
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
# --- Step 4: Execute in topo order ------------------------------------
|
|
709
|
+
# rebuilt_this_run tracks nodes where run_loop was actually invoked during
|
|
710
|
+
# this execution. Used to detect when a downstream's confirmed-open PR
|
|
711
|
+
# is stale because its upstream was rebuilt (Finding #4).
|
|
712
|
+
rebuilt_this_run: Set[str] = set()
|
|
713
|
+
|
|
714
|
+
for i, node in enumerate(topo_order):
|
|
715
|
+
nid = node.node_id
|
|
716
|
+
|
|
717
|
+
# Resume: skip nodes already confirmed pr_open (live PR check passed) OR
|
|
718
|
+
# already merged (P4 resume — merged nodes cannot be rebuilt or stale).
|
|
719
|
+
# For pr_open nodes: check stale-upstream (an upstream rebuilt this run
|
|
720
|
+
# or received an out-of-band push) and block with a clear reason so the
|
|
721
|
+
# user can close the stale PR and re-run.
|
|
722
|
+
#
|
|
723
|
+
# NOTE: automatic downstream rebuild when an upstream changes requires an
|
|
724
|
+
# update-existing-PR primitive and is deferred to a future release.
|
|
725
|
+
if nid in completed_nodes:
|
|
726
|
+
# Merged nodes: skip unconditionally — no stale-upstream check applies.
|
|
727
|
+
nid_rec = ledger_state.get(nid)
|
|
728
|
+
if nid_rec and nid_rec.status == "merged":
|
|
729
|
+
continue
|
|
730
|
+
|
|
731
|
+
upstream_edges = roadmap.edges_for_downstream(node)
|
|
732
|
+
changed_upstreams = [
|
|
733
|
+
edge for edge in upstream_edges
|
|
734
|
+
# #47: an order-only edge injects nothing — the downstream never
|
|
735
|
+
# consumed the upstream, so an upstream change does NOT make the
|
|
736
|
+
# downstream stale. Exclude order-only edges from stale-detection
|
|
737
|
+
# to avoid a spurious 'upstream_changed_downstream_pr_open' block.
|
|
738
|
+
if edge.channel.kind != "order-only"
|
|
739
|
+
and (
|
|
740
|
+
edge.upstream.node_id in rebuilt_this_run
|
|
741
|
+
or edge.upstream.node_id in out_of_band_upstreams
|
|
742
|
+
)
|
|
743
|
+
]
|
|
744
|
+
if not changed_upstreams:
|
|
745
|
+
continue
|
|
746
|
+
# An upstream changed and this node's draft PR is still open.
|
|
747
|
+
# Block so the user can close the stale PR and re-run.
|
|
748
|
+
change_reasons: List[str] = []
|
|
749
|
+
for edge in changed_upstreams:
|
|
750
|
+
uid = edge.upstream.node_id
|
|
751
|
+
if uid in rebuilt_this_run:
|
|
752
|
+
change_reasons.append(f"upstream {uid!r} was rebuilt this run")
|
|
753
|
+
else:
|
|
754
|
+
new_sha = completed_nodes.get(uid, {}).get("head_sha", "<unknown>")
|
|
755
|
+
change_reasons.append(
|
|
756
|
+
f"upstream {uid!r} advanced to {new_sha!r} (out-of-band push)"
|
|
757
|
+
)
|
|
758
|
+
detail_msg = (
|
|
759
|
+
"; ".join(change_reasons)
|
|
760
|
+
+ f"; close/supersede the stale downstream PR and re-run"
|
|
761
|
+
)
|
|
762
|
+
append_record(
|
|
763
|
+
ledger_path,
|
|
764
|
+
LedgerRecord(
|
|
765
|
+
node_id=nid,
|
|
766
|
+
status="blocked",
|
|
767
|
+
branch=completed_nodes[nid].get("branch"),
|
|
768
|
+
),
|
|
769
|
+
)
|
|
770
|
+
return {
|
|
771
|
+
"status": "blocked",
|
|
772
|
+
"node_id": nid,
|
|
773
|
+
"detail": {
|
|
774
|
+
"reason": "upstream_changed_downstream_pr_open",
|
|
775
|
+
"message": detail_msg,
|
|
776
|
+
},
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
workspace = resolve_workspace(node)
|
|
780
|
+
upstream_edges = roadmap.edges_for_downstream(node)
|
|
781
|
+
|
|
782
|
+
# Mark as running (durable breadcrumb for diagnostics)
|
|
783
|
+
append_record(ledger_path, LedgerRecord(node_id=nid, status="running"))
|
|
784
|
+
|
|
785
|
+
try:
|
|
786
|
+
# (i) Inject upstream draft refs (IF-0-P2-2) BEFORE run_loop.
|
|
787
|
+
# Collect injected paths to union into owned_paths after run_loop.
|
|
788
|
+
#
|
|
789
|
+
# The guard below is a defensive invariant. It should be unreachable
|
|
790
|
+
# in a well-formed train: validate_train_loud (T-B) ensures every
|
|
791
|
+
# upstream is a declared node, and topo-sort guarantees we processed
|
|
792
|
+
# it before this node. If the upstream failed/was blocked, run_train
|
|
793
|
+
# returns immediately and never reaches this downstream. Kept here to
|
|
794
|
+
# make the "no silent skip" contract explicit and catch future refactors.
|
|
795
|
+
injected_channel_paths: List[str] = []
|
|
796
|
+
for edge in upstream_edges:
|
|
797
|
+
if edge.channel.kind == "order-only":
|
|
798
|
+
# #47: an order-only edge enforces merge ORDER (via the topo
|
|
799
|
+
# sort + sequential per-node execution) but carries no physical
|
|
800
|
+
# channel — the downstream does not consume the upstream, so
|
|
801
|
+
# there is nothing to inject. Skip; do not resolve a ref.
|
|
802
|
+
continue
|
|
803
|
+
upstream_result = completed_nodes.get(edge.upstream.node_id)
|
|
804
|
+
if upstream_result is None:
|
|
805
|
+
# Defensive: topo-order + T-B validation make this
|
|
806
|
+
# unreachable; kept as an explicit fail-loud guard.
|
|
807
|
+
raise RuntimeError(
|
|
808
|
+
f"upstream ref for '{edge.upstream.node_id}' is not resolved "
|
|
809
|
+
f"(not in completed_nodes) — cannot inject into "
|
|
810
|
+
f"'{nid}'; the upstream must be built and published first"
|
|
811
|
+
)
|
|
812
|
+
ref = upstream_result.get("head_sha")
|
|
813
|
+
if not ref:
|
|
814
|
+
# Block: do NOT fall back to injecting a moving branch name.
|
|
815
|
+
# A missing SHA means neither the live query nor the ledger
|
|
816
|
+
# have a pinnable ref — injecting a branch name would build
|
|
817
|
+
# the downstream against a moving target.
|
|
818
|
+
raise RuntimeError(
|
|
819
|
+
f"no resolvable SHA for upstream '{edge.upstream.node_id}' "
|
|
820
|
+
f"(live head SHA query returned None and ledger head_sha is "
|
|
821
|
+
f"None); cannot inject a moving branch name for channel "
|
|
822
|
+
f"{edge.channel.kind!r} — resolve the upstream SHA and re-run"
|
|
823
|
+
)
|
|
824
|
+
injected = set_upstream_ref_fn(workspace, edge.channel, ref)
|
|
825
|
+
if injected:
|
|
826
|
+
injected_channel_paths.extend(injected)
|
|
827
|
+
|
|
828
|
+
# (ii) Invoke the unchanged per-repo run_loop.
|
|
829
|
+
# The real run_loop returns (StateSnapshot, list[LaunchResult]).
|
|
830
|
+
result_tuple = run_loop_fn(
|
|
831
|
+
workspace, workspace / node.roadmap, run_mode=run_mode
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# SHOULD-FIX 3: Guard against partial multi-phase nodes.
|
|
835
|
+
#
|
|
836
|
+
# run_loop defaults to max_phases=1, so a node with a >1-phase
|
|
837
|
+
# roadmap stops after the first phase. If any phase is still
|
|
838
|
+
# "planned" the node is incomplete — publishing it ships a partial
|
|
839
|
+
# draft PR. Block loudly instead.
|
|
840
|
+
#
|
|
841
|
+
# Use getattr so we're forward-compatible with test fixtures that
|
|
842
|
+
# return lightweight SimpleNamespace objects lacking a phases field.
|
|
843
|
+
_node_snapshot = result_tuple[0] if isinstance(result_tuple, tuple) else None
|
|
844
|
+
_node_phases = getattr(_node_snapshot, "phases", None)
|
|
845
|
+
if _node_phases is not None:
|
|
846
|
+
# A node may publish a draft PR only when EVERY phase reached a
|
|
847
|
+
# clean green terminal — "complete" or "awaiting_phase_closeout"
|
|
848
|
+
# (the post-run_loop success state under manual closeout). ANY
|
|
849
|
+
# other state (planned/blocked/failed_verification/executing/
|
|
850
|
+
# human_required/unknown) means the node is incomplete or broken.
|
|
851
|
+
# Blocking only "planned" (the prior narrow guard) let a
|
|
852
|
+
# *failed*-phase node publish a draft that could later trivial-pass
|
|
853
|
+
# P4 re-verify on a no-verification plan — a combined false-green.
|
|
854
|
+
# Block loudly on any non-green phase instead.
|
|
855
|
+
_GREEN_PHASE_STATES = {"complete", "awaiting_phase_closeout"}
|
|
856
|
+
_not_green = sorted(
|
|
857
|
+
ph for ph, st in _node_phases.items() if st not in _GREEN_PHASE_STATES
|
|
858
|
+
)
|
|
859
|
+
if _not_green:
|
|
860
|
+
raise RuntimeError(
|
|
861
|
+
f"node '{nid}' has phases not in a green state after run_loop "
|
|
862
|
+
f"({', '.join(_not_green)}); refusing to publish a partial or "
|
|
863
|
+
f"failed draft PR — every phase must reach complete/"
|
|
864
|
+
f"awaiting_phase_closeout before publishing"
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
# (iii) Determine owned paths (Finding #1).
|
|
868
|
+
# If the caller supplied an explicit resolver, honour it.
|
|
869
|
+
# Otherwise use the snapshot's produced/owned paths so the
|
|
870
|
+
# published PR contains the actual implementation, not just
|
|
871
|
+
# the roadmap file.
|
|
872
|
+
if _explicit_owned_paths:
|
|
873
|
+
owned_paths = list(resolve_owned_paths(node)) # type: ignore[arg-type]
|
|
874
|
+
else:
|
|
875
|
+
snapshot = result_tuple[0] if isinstance(result_tuple, tuple) else None
|
|
876
|
+
if snapshot is not None:
|
|
877
|
+
produced = (
|
|
878
|
+
getattr(snapshot, "phase_owned_dirty_paths", None)
|
|
879
|
+
or getattr(snapshot, "dirty_paths", None)
|
|
880
|
+
or ()
|
|
881
|
+
)
|
|
882
|
+
owned_paths = list(produced)
|
|
883
|
+
else:
|
|
884
|
+
owned_paths = []
|
|
885
|
+
|
|
886
|
+
# Union the coordinator-injected channel paths into owned_paths so
|
|
887
|
+
# the pin/submodule change always ships in the PR even if run_loop's
|
|
888
|
+
# snapshot doesn't include the injected file (Finding #6 / union fix).
|
|
889
|
+
# de-duplicate while preserving order (snapshot paths first).
|
|
890
|
+
if injected_channel_paths:
|
|
891
|
+
seen = set(owned_paths)
|
|
892
|
+
for p in injected_channel_paths:
|
|
893
|
+
if p not in seen:
|
|
894
|
+
owned_paths.append(p)
|
|
895
|
+
seen.add(p)
|
|
896
|
+
|
|
897
|
+
# (iv) Publish as draft PR via the P1 runtime primitive.
|
|
898
|
+
# draft=True is structural — P3 never merges.
|
|
899
|
+
pr_body = _build_pr_body(node, topo_order, completed_nodes, upstream_edges)
|
|
900
|
+
publish_result = publish_fn(
|
|
901
|
+
workspace,
|
|
902
|
+
owned_paths,
|
|
903
|
+
draft=True, # P3 invariant: draft-only, never merge
|
|
904
|
+
pr_body=pr_body,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
except Exception as exc:
|
|
908
|
+
# Inject or run_loop or publish raised — mark blocked so the node
|
|
909
|
+
# is never left stuck at "running" (Finding #3 / exception safety).
|
|
910
|
+
append_record(
|
|
911
|
+
ledger_path,
|
|
912
|
+
LedgerRecord(
|
|
913
|
+
node_id=nid,
|
|
914
|
+
status="blocked",
|
|
915
|
+
branch=None,
|
|
916
|
+
),
|
|
917
|
+
)
|
|
918
|
+
return {
|
|
919
|
+
"status": "blocked",
|
|
920
|
+
"node_id": nid,
|
|
921
|
+
"detail": {"reason": str(exc)},
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if publish_result.get("status") != "published":
|
|
925
|
+
# Node blocked by the publish primitive (e.g. push rejected, dirty
|
|
926
|
+
# worktree, publication_blocked). Record in ledger and halt.
|
|
927
|
+
# Prior nodes' draft PRs remain open; the train is resumable.
|
|
928
|
+
append_record(
|
|
929
|
+
ledger_path,
|
|
930
|
+
LedgerRecord(
|
|
931
|
+
node_id=nid,
|
|
932
|
+
status="blocked",
|
|
933
|
+
branch=publish_result.get("branch"),
|
|
934
|
+
),
|
|
935
|
+
)
|
|
936
|
+
return {
|
|
937
|
+
"status": "blocked",
|
|
938
|
+
"node_id": nid,
|
|
939
|
+
"detail": publish_result,
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
# Record success — store draft head in ``head_sha``; leave
|
|
943
|
+
# ``upstream_merge_sha`` for P4's merged-SHA (Finding #5).
|
|
944
|
+
branch = publish_result["branch"]
|
|
945
|
+
head_sha = publish_result["head_sha"]
|
|
946
|
+
pr_url = publish_result["pr_url"]
|
|
947
|
+
|
|
948
|
+
completed_nodes[nid] = {
|
|
949
|
+
"branch": branch,
|
|
950
|
+
"head_sha": head_sha,
|
|
951
|
+
"pr_url": pr_url,
|
|
952
|
+
}
|
|
953
|
+
rebuilt_this_run.add(nid)
|
|
954
|
+
|
|
955
|
+
append_record(
|
|
956
|
+
ledger_path,
|
|
957
|
+
LedgerRecord(
|
|
958
|
+
node_id=nid,
|
|
959
|
+
status="pr_open",
|
|
960
|
+
branch=branch,
|
|
961
|
+
pr_url=pr_url,
|
|
962
|
+
head_sha=head_sha, # draft branch HEAD SHA
|
|
963
|
+
upstream_merge_sha=None, # reserved for P4 (merge SHA only)
|
|
964
|
+
merge_order=i,
|
|
965
|
+
),
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
# --- P3 complete: all draft PRs open ------------------------------------
|
|
969
|
+
|
|
970
|
+
if not _merge_phase_enabled:
|
|
971
|
+
# P3 behavior (default): done once draft PRs are open. Cross-repo
|
|
972
|
+
# merges are a P4 concern. All existing P3 callers and tests hit this
|
|
973
|
+
# path because they never set _merge_phase_enabled=True.
|
|
974
|
+
return {"status": "completed", "nodes": completed_nodes}
|
|
975
|
+
|
|
976
|
+
# --- P4: train-level governed review + sequential merge with re-verify ---
|
|
977
|
+
#
|
|
978
|
+
# Autonomy boundary: cross-repo merges are NEVER auto-merged. In
|
|
979
|
+
# autonomous mode (the default) the coordinator stops here at a
|
|
980
|
+
# ``drafts_open`` terminal. Only ``run_mode="governed"`` (opt-in via
|
|
981
|
+
# ``--governed`` CLI flag) proceeds to the review + merge loop.
|
|
982
|
+
if run_mode != "governed":
|
|
983
|
+
return {"status": "drafts_open", "nodes": completed_nodes}
|
|
984
|
+
|
|
985
|
+
# Resolve P4 seams (defaults to live; tests inject stubs).
|
|
986
|
+
merge_pr_fn = _merge_pr_fn if _merge_pr_fn is not None else _live_merge_pr
|
|
987
|
+
reverify_fn = _reverify_fn if _reverify_fn is not None else _live_reverify
|
|
988
|
+
train_review_fn = (
|
|
989
|
+
_train_review_fn if _train_review_fn is not None else _default_train_review
|
|
990
|
+
)
|
|
991
|
+
pr_merged_sha_fn = (
|
|
992
|
+
_pr_merged_sha_fn if _pr_merged_sha_fn is not None else _live_pr_merged_sha
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
# Re-read ledger to recover P4 state from a previous partial run
|
|
996
|
+
# (idempotent resume: skip already-merged nodes, recover their SHA).
|
|
997
|
+
p4_ledger_state = read_ledger(ledger_path)
|
|
998
|
+
|
|
999
|
+
# Build merged_shas: node_id → actual merge-commit SHA. The field
|
|
1000
|
+
# ``upstream_merge_sha`` on a ``merged`` record holds the real SHA
|
|
1001
|
+
# (NOT the draft head_sha — ledger schema keeps these distinct).
|
|
1002
|
+
merged_shas: Dict[str, str] = {}
|
|
1003
|
+
for _nid_r, _rec_r in p4_ledger_state.items():
|
|
1004
|
+
if _rec_r.status == "merged" and _rec_r.upstream_merge_sha:
|
|
1005
|
+
merged_shas[_nid_r] = _rec_r.upstream_merge_sha
|
|
1006
|
+
|
|
1007
|
+
# Cross-check with live GitHub state: covers crash-between-merge-and-
|
|
1008
|
+
# ledger-write (the PR landed on main but the ledger write didn't happen).
|
|
1009
|
+
for _node_r in topo_order:
|
|
1010
|
+
_nid_r = _node_r.node_id
|
|
1011
|
+
if _nid_r in merged_shas:
|
|
1012
|
+
continue # already recovered from ledger
|
|
1013
|
+
_pr_branch_r = completed_nodes.get(_nid_r, {}).get("branch")
|
|
1014
|
+
if _pr_branch_r:
|
|
1015
|
+
_ws_r = resolve_workspace(_node_r)
|
|
1016
|
+
_live_sha_r = pr_merged_sha_fn(_ws_r, _pr_branch_r)
|
|
1017
|
+
if _live_sha_r:
|
|
1018
|
+
merged_shas[_nid_r] = _live_sha_r
|
|
1019
|
+
|
|
1020
|
+
# --- Train-level review (one-round bounded panel) ----------------------
|
|
1021
|
+
# Idempotent resume: skip review if already approved in a previous run.
|
|
1022
|
+
train_review_rec = p4_ledger_state.get(_TRAIN_REVIEW_NODE_ID)
|
|
1023
|
+
already_approved = (
|
|
1024
|
+
train_review_rec is not None and train_review_rec.status == "approved"
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
if not already_approved:
|
|
1028
|
+
bundle_text = _build_train_review_bundle(roadmap, completed_nodes, topo_order)
|
|
1029
|
+
review_result = train_review_fn(bundle_text, run_mode)
|
|
1030
|
+
|
|
1031
|
+
if not review_result.mergeable:
|
|
1032
|
+
# Non-approval → NON-HUMAN terminal, ZERO merges.
|
|
1033
|
+
# The partial-merge-disaster guard: no node is merged if the panel
|
|
1034
|
+
# rejects the train. terminal_blocker carries human_required=False.
|
|
1035
|
+
return {
|
|
1036
|
+
"status": "review_halted",
|
|
1037
|
+
"nodes": completed_nodes,
|
|
1038
|
+
"terminal_blocker": review_result.terminal_blocker,
|
|
1039
|
+
"reason": review_result.reason or "train_review_rejected",
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
# Record approval (with synthetic node_id — never a real roadmap node).
|
|
1043
|
+
append_record(
|
|
1044
|
+
ledger_path,
|
|
1045
|
+
LedgerRecord(
|
|
1046
|
+
node_id=_TRAIN_REVIEW_NODE_ID,
|
|
1047
|
+
status="approved",
|
|
1048
|
+
),
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
# --- Sequential merge in topo order with downstream re-verify -----------
|
|
1052
|
+
#
|
|
1053
|
+
# False-green killer: before merging each downstream node, re-resolve its
|
|
1054
|
+
# channel to the upstream MERGED SHA and re-verify. NEVER merge a
|
|
1055
|
+
# downstream that was only green against the draft (unmerged) upstream ref.
|
|
1056
|
+
#
|
|
1057
|
+
# Forward-only: a downstream re-verify failure does NOT revert merged
|
|
1058
|
+
# upstream nodes. Recommendation: use expand/contract (backward-compatible)
|
|
1059
|
+
# upstream contracts so sequential merges remain safe even when a downstream
|
|
1060
|
+
# fails post-merge of its upstream.
|
|
1061
|
+
for _i_m, _node_m in enumerate(topo_order):
|
|
1062
|
+
_nid_m = _node_m.node_id
|
|
1063
|
+
|
|
1064
|
+
# Idempotent: skip already-merged nodes (never double-merge).
|
|
1065
|
+
if _nid_m in merged_shas:
|
|
1066
|
+
continue
|
|
1067
|
+
|
|
1068
|
+
_ws_m = resolve_workspace(_node_m)
|
|
1069
|
+
_upstream_edges_m = roadmap.edges_for_downstream(_node_m)
|
|
1070
|
+
|
|
1071
|
+
if _upstream_edges_m:
|
|
1072
|
+
# Re-resolve every upstream channel to its MERGED SHA (not the
|
|
1073
|
+
# draft SHA from P3). This is the call the test asserts on:
|
|
1074
|
+
# set_upstream_ref must be called with the merged SHA and must
|
|
1075
|
+
# appear in the call log BEFORE the re-verify call.
|
|
1076
|
+
#
|
|
1077
|
+
# Wrap inject + reverify: either can raise (e.g. fs error from
|
|
1078
|
+
# set_upstream_ref_fn, or unexpected exception from reverify_fn).
|
|
1079
|
+
# An unguarded exception here would escape run_train as a traceback
|
|
1080
|
+
# instead of returning merge_halted, leaving already-merged upstreams
|
|
1081
|
+
# with no record. Mirror the same try/except pattern used for the
|
|
1082
|
+
# merge call below (forward-only: already-merged upstreams stay merged).
|
|
1083
|
+
try:
|
|
1084
|
+
for _edge_m in _upstream_edges_m:
|
|
1085
|
+
if _edge_m.channel.kind == "order-only":
|
|
1086
|
+
# #47: order-only edges carry no channel — the merged
|
|
1087
|
+
# upstream is not injected/re-resolved. Merge order is
|
|
1088
|
+
# already enforced by the sequential topo-order merge loop.
|
|
1089
|
+
continue
|
|
1090
|
+
_upstream_merged_sha = merged_shas.get(_edge_m.upstream.node_id)
|
|
1091
|
+
if _upstream_merged_sha is None:
|
|
1092
|
+
# Defensive: topo-order ensures upstream is processed first.
|
|
1093
|
+
raise RuntimeError(
|
|
1094
|
+
f"upstream '{_edge_m.upstream.node_id}' is not yet merged "
|
|
1095
|
+
f"— cannot re-verify downstream '{_nid_m}'; "
|
|
1096
|
+
f"check topo order and that the upstream merge succeeded"
|
|
1097
|
+
)
|
|
1098
|
+
set_upstream_ref_fn(_ws_m, _edge_m.channel, _upstream_merged_sha)
|
|
1099
|
+
|
|
1100
|
+
# Re-verify the downstream against the merged upstream contracts.
|
|
1101
|
+
# Failure means the downstream was only green against the draft ref.
|
|
1102
|
+
_reverify_ok = reverify_fn(_ws_m, _ws_m / _node_m.roadmap, run_mode)
|
|
1103
|
+
except Exception as _inject_exc_m:
|
|
1104
|
+
# Inject or reverify raised — record blocked + return merge_halted
|
|
1105
|
+
# so the status-dict contract is preserved and no traceback escapes.
|
|
1106
|
+
append_record(
|
|
1107
|
+
ledger_path,
|
|
1108
|
+
LedgerRecord(
|
|
1109
|
+
node_id=_nid_m,
|
|
1110
|
+
status="blocked",
|
|
1111
|
+
branch=completed_nodes.get(_nid_m, {}).get("branch"),
|
|
1112
|
+
),
|
|
1113
|
+
)
|
|
1114
|
+
return {
|
|
1115
|
+
"status": "merge_halted",
|
|
1116
|
+
"node_id": _nid_m,
|
|
1117
|
+
"reason": "reverify_failed",
|
|
1118
|
+
"detail": str(_inject_exc_m),
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if not _reverify_ok:
|
|
1122
|
+
append_record(
|
|
1123
|
+
ledger_path,
|
|
1124
|
+
LedgerRecord(
|
|
1125
|
+
node_id=_nid_m,
|
|
1126
|
+
status="blocked",
|
|
1127
|
+
branch=completed_nodes.get(_nid_m, {}).get("branch"),
|
|
1128
|
+
),
|
|
1129
|
+
)
|
|
1130
|
+
# Forward-only: DO NOT revert the already-merged upstream nodes.
|
|
1131
|
+
# Use expand/contract upstream contracts to prevent this situation.
|
|
1132
|
+
return {
|
|
1133
|
+
"status": "merge_halted",
|
|
1134
|
+
"node_id": _nid_m,
|
|
1135
|
+
"reason": "downstream_reverify_failed",
|
|
1136
|
+
"detail": (
|
|
1137
|
+
f"node '{_nid_m}' failed re-verification against upstream "
|
|
1138
|
+
f"merged SHA(s). Upstream nodes remain merged (forward-only). "
|
|
1139
|
+
f"Recommendation: use expand/contract upstream contracts so "
|
|
1140
|
+
f"sequential merges are safe even when a downstream fails."
|
|
1141
|
+
),
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
# Merge the PR. Wrap the call: a real gh pr merge failure (branch
|
|
1145
|
+
# protection, conflict, required checks) raises CalledProcessError
|
|
1146
|
+
# from the live default (subprocess check=True). Record blocked +
|
|
1147
|
+
# return merge_halted so no uncaught exception escapes run_train and
|
|
1148
|
+
# already-merged upstream nodes remain recorded (forward-only).
|
|
1149
|
+
_pr_branch_m = completed_nodes[_nid_m]["branch"]
|
|
1150
|
+
try:
|
|
1151
|
+
_merged_sha_m = merge_pr_fn(_ws_m, _pr_branch_m)
|
|
1152
|
+
except Exception as _merge_exc_m:
|
|
1153
|
+
append_record(
|
|
1154
|
+
ledger_path,
|
|
1155
|
+
LedgerRecord(
|
|
1156
|
+
node_id=_nid_m,
|
|
1157
|
+
status="blocked",
|
|
1158
|
+
branch=_pr_branch_m,
|
|
1159
|
+
),
|
|
1160
|
+
)
|
|
1161
|
+
return {
|
|
1162
|
+
"status": "merge_halted",
|
|
1163
|
+
"node_id": _nid_m,
|
|
1164
|
+
"reason": "merge_failed",
|
|
1165
|
+
"detail": str(_merge_exc_m),
|
|
1166
|
+
}
|
|
1167
|
+
merged_shas[_nid_m] = _merged_sha_m
|
|
1168
|
+
|
|
1169
|
+
# Record the merge. Carry branch/pr_url/head_sha forward from
|
|
1170
|
+
# completed_nodes so the merged record is self-sufficient for P4 resume
|
|
1171
|
+
# (last-wins overwrites the pr_open record; the resumed run reads branch
|
|
1172
|
+
# and head_sha from this merged record to inject downstream nodes).
|
|
1173
|
+
_node_info_m = completed_nodes[_nid_m]
|
|
1174
|
+
append_record(
|
|
1175
|
+
ledger_path,
|
|
1176
|
+
LedgerRecord(
|
|
1177
|
+
node_id=_nid_m,
|
|
1178
|
+
status="merged",
|
|
1179
|
+
branch=_node_info_m.get("branch"),
|
|
1180
|
+
pr_url=_node_info_m.get("pr_url"),
|
|
1181
|
+
head_sha=_node_info_m.get("head_sha"), # draft SHA for downstream injection
|
|
1182
|
+
upstream_merge_sha=_merged_sha_m, # actual merge-commit SHA (P4)
|
|
1183
|
+
merge_order=_i_m,
|
|
1184
|
+
),
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
return {
|
|
1188
|
+
"status": "merged",
|
|
1189
|
+
"nodes": {
|
|
1190
|
+
_nid_out: {
|
|
1191
|
+
"branch": completed_nodes[_nid_out]["branch"],
|
|
1192
|
+
"merged_sha": _sha_out,
|
|
1193
|
+
}
|
|
1194
|
+
for _nid_out, _sha_out in merged_shas.items()
|
|
1195
|
+
if _nid_out in completed_nodes # exclude _train_review_ synthetic node
|
|
1196
|
+
},
|
|
1197
|
+
}
|