diff --git a/memory/PLAN.md b/memory/PLAN.md index aa96eb87..34de72c3 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -43,13 +43,14 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr ### orchestrator-cutover — ◐ active -- **Goal:** re-grow the old `main` cook orchestrator natively on alpha's CODE/executor substrate (D101-L), layer by layer: projection seam → descriptive lifecycle shape → real runnable sandbox → real change-producing agent → real promotion/land. Split by capability layer + risk + reversibility so each layer is independently reviewable and the hard-to-reverse git seam lands last. +- **Goal:** re-grow the old `main` cook orchestrator natively on alpha's CODE/executor substrate (D101-L), layer by layer: projection seam → descriptive lifecycle shape → real runnable sandbox → real change-producing agent → real promotion/land → driven end-to-end (orchestrate loop). Split by capability layer + risk + reversibility so each layer is independently reviewable and the hard-to-reverse git seam lands last. - **Members:** - `orchestrator-alpha-cutover` (FE-1089) ✓ done — `ExecutionSpecSnapshot` projection seam + the descriptive `fs`-only cook lifecycle scaffold (`execute_plan_file` → … → `execute_promotion_prepare`). Proved the lifecycle shape + thin-adapter/one-side-effect-per-tool pattern with zero real execution. - `executor-sandbox` (FE-1109) ✓ built — `GitWorktreePort` + `TestRunnerPort`: a run becomes a real, runnable, verifiable git workspace (no LLM, subprocess only). - `executor-agent-runner` (FE-1111) → active — `AgentRunnerPort` reusing the D90-L–D93-L sealed subagent substrate: a run actually produces real changes via a code-owned write-capable CODE worker. - `executor-promotion` → last — `GitLandPort`: a run's real changes get promoted (run-local promotion first, host promotion later); the only externally-visible, hard-to-reverse seam. -- **Done-definition:** a selected-spec cook run can be planned, executed against a real git worktree by a real CODE worker that produces real diffs, verified by real tests, and promoted — each layer behind the established injected-capability-port seam (SPEC D101-L executor cutover), no faked side effects, topology immutable in execution, and `execute_status` `pendingTools` empty. Open follow-ups (adaptive replan, real Petri-net execution) ride their own horizon items, not arc blockers. + - `executor-orchestrate-loop` (FE-1125) ✓ built — a single `execute_orchestrate` driver composes the lifecycle rungs behind a pure, Petri-ready `RunScheduler` seam (ready-set return; readiness from slice-completion facts, not the global status enum); drives created → run-local `promotion_prepared`. Real Petri scheduler deferred to `geolog-and-petri-execution`. +- **Done-definition:** a selected-spec cook run can be planned, executed against a real git worktree by a real CODE worker that produces real diffs, verified by real tests, and promoted — each layer behind the established injected-capability-port seam (SPEC D101-L executor cutover), no faked side effects, topology immutable in execution, `execute_status` `pendingTools` empty, and the run driven end-to-end by a single `execute_orchestrate` entry behind a swappable scheduler seam (FE-1125). Open follow-ups (adaptive replan, real Petri-net execution) ride their own horizon items, not arc blockers. - **Anchors:** D39-L, D40-L, D52-L, D90-L–D93-L, D98-L, D101-L / I49-L, I56-L. ## Sequencing @@ -57,6 +58,7 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr ### Active - `executor-host-promotion` (FE-1118, `orchestrator-cutover` arc) — **built; ready for tie-off.** Apply a verified run-local executor promotion back to the host project branch through an explicit host-promotion path. Preflight validates the promoted SHA and computes/reports the host diff without mutation; accepted apply mutates host files only after accepted SHA confirmation; CODE-mode Pi tools expose both surfaces with side-effect details. Review hardening added real git apply/conflict oracles and tightened the apply result shape. Stacks on `ka/fe-1112-executor-promotion`. +- `executor-orchestrate-loop` (FE-1125, `orchestrator-cutover` arc) — **built; ready to tie off.** `execute_orchestrate` drives a run end-to-end (created → run-local `promotion_prepared`) behind a pure `RunScheduler` seam (ready-set return; readiness from slice-completion facts; scheduler in core), instead of cranking tool-by-tool. Petri stays an export artifact; the real Petri scheduler is the parked `geolog-and-petri-execution` successor. Host promotion stays off the driven chain. Stacks on `ka/fe-1118-executor-host-promotion`. - `elicitation-gap-guidance` — **proving frontier.** Generate "what next?" gap guidance from graph shape/readiness, distinct from ranking already-registered gaps. ### Recently Completed @@ -206,6 +208,26 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - Done: Review hardening proves the real `git diff --binary` / `git apply --check` / `git apply` path against temp repos, narrows apply result states, and removes the dead report-path alias. - **Traceability:** D52-L, D99-L / I52-L; depends on `executor-promotion`; `src/executor/TOPOLOGY.md`. +### executor-orchestrate-loop + +- **Name:** Executor orchestrate loop — driven run over a scheduler seam +- **Linear:** [FE-1125](https://linear.app/hash/issue/FE-1125/reconcile-executor-run-driver) — reconcile executor run driver +- **Branch:** `ka/fe-1125-executor-orchestrate-loop` (stacked on `ka/fe-1118-executor-host-promotion`) +- **Kind:** structural / execute-mode run driver (`orchestrator-cutover` arc) +- **Status:** built — `execute_orchestrate` drives a run end-to-end (created → run-local `promotion_prepared`) behind the pure `RunScheduler` seam; host promotion stays off-chain. Real-provider end-to-end (real LLM + git) is unexercised — unit slices use fake ports. Ready to tie off. +- **Certainty:** proving. +- **Why now / unlocks:** the cutover arc built every lifecycle rung (real worktree, agent, test, promotion, host-files apply) but a run is still cranked one `execute_*` tool-call at a time; nothing drives the composition end-to-end. This is the `orchestrate` tool D101-L names but that does not yet exist, and the first unattended end-to-end run — it converts the parked follow-ups (recovery, replan, containment) from speculation into observed pressure. +- **Design verdict (chosen, via `ln-design`):** a generic `drive()` loop parameterized by a pure `RunScheduler.ready(state, plan): ReadyStep[]`. `ready()` returns a **set** (length-1 under today's `LinearScheduler`; a future `PetriScheduler` returns N for parallel epics); readiness is derived from per-slice completion facts (`run.json` completedSlices + `reports.jsonl`), not the global `status` enum; the scheduler is pure and lives in executor core, not in the `ExecutionPorts` capability bag. Petri stays an export artifact — real Petri-net execution remains the parked `geolog-and-petri-execution` item. See SPEC D102-L. +- **Objective:** Compose the real `execute_*` lifecycle steps into a single `execute_orchestrate`-driven run behind the scheduler seam, so a run advances itself instead of being cranked tool-by-tool. +- **Acceptance (slice 1; refine via `ln-scope`):** + - `execute_orchestrate` drives a created run + multi-slice plan to `run_completed` in one call, with each slice completed. + - `RunScheduler.ready()` returns a length-1 array each turn and `[]` at completion (locks the set-return contract). + - Readiness selects the pending slice's next step from completion facts, not the global `status` string. + - A failed injected-port step halts the run, leaves status unadvanced, and returns a halt outcome. + - Each `execute_*` step fn is invoked exactly once per run step; driven-run terminal metadata equals the hand-cranked sequence's (no new side effects). + - Promotion/land out of scope this slice (stops at `run_completed`). +- **Traceability:** D101-L, D102-L, D52-L / I56-L; depends on `executor-host-promotion`; optional successor `geolog-and-petri-execution` (real Petri scheduler); `src/executor/TOPOLOGY.md`. + ### elicitor-project - **Name:** Elicitor `project` capability — cross-plane derivation @@ -273,6 +295,13 @@ frontiers: depends_on: executor-promotion, D101-L executor cutover, D52-L, I56-L stacks_on: ka/fe-1112-executor-promotion + executor-orchestrate-loop (FE-1125, orchestrator-cutover arc) + status: built; drives created -> run-local promotion_prepared; host promotion off-chain + depends_on: executor-host-promotion (FE-1118), D101-L executor cutover, D102-L scheduler seam, D52-L, I56-L + provides: RunScheduler seam + execute_orchestrate driver + optional_successor: geolog-and-petri-execution (PetriScheduler, real Petri-net execution) + stacks_on: ka/fe-1118-executor-host-promotion + executor-agent-runner (FE-1111, orchestrator-cutover arc) status: done; sealed worker runner and faux-provider witness built depends_on: executor-sandbox (FE-1109), D90-L..D93-L, D52-L, I49-L, I56-L diff --git a/memory/SPEC.md b/memory/SPEC.md index cc2a312a..8f63e4e2 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -260,6 +260,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | D98-L | Operational mode only: suspend strategy/lens/method runtime axes; target product modes are SPEC and CODE. The architectural correction is that the `strategy` / `lens` / `method` model is not yet proven as the right product/runtime abstraction. It may still organize prompt-resource files and concise agent-readable references, but it must not be a user-facing TUI picker, ... | See archive snapshot for full rationale. | active | | D100-L | `project` is a distinct first-level live SPEC-mode skill home for cross-plane derivation, not a `generate` sub-mode. `generate` fans out alternatives within a target plane from context; `project` starts from accepted upstream graph anchors and derives downstream plane candidates/drafts plus connecting edge intent. It uses the existing structured-exchange triad (`present_candidates`, `request_response`, `present_review_set`) and hands exact graph expression back to `map` / review-set commitment; it adds no product tool, exchange schema family, or direct graph-write path. Depends on: D95-L, D96-L, D97-L, I51-L. | [`src/agents/skills/TOPOLOGY.md`](src/agents/skills/TOPOLOGY.md), [`src/agents/subagents/TOPOLOGY.md`](src/agents/subagents/TOPOLOGY.md) | active | | D101-L | Execute orchestration cutover starts with bounded native executor tools, not a revived orchestrator role or shelling out to the old CLI. FE-1089 grows the old cook lifecycle back on alpha as `execute_*` Pi tools over product-core contracts: side-effect-free projection/check/outline/draft/preview tools are active in CODE mode; bounded artifact writers under `.brunch/execution-reports` / `.brunch/cook` plus descriptive run/worktree/source/report/slice/result/Petri/promotion-preparation tools are registered and test-covered but intentionally inactive until the real-execution stack lands, so placeholders are not independently reachable. No real agent/test execution or host git mutation occurs until a later accepted boundary. Depends on: D39-L, D40-L, D58-L, D90-L, D93-L, D98-L. | [`src/executor/TOPOLOGY.md`](src/executor/TOPOLOGY.md), [`src/.pi/extensions/TOPOLOGY.md`](src/.pi/extensions/TOPOLOGY.md) | active | +| D102-L | Executor run driving is a driver over a **scheduler seam**, not baked control flow. A single CODE-mode `execute_orchestrate` tool drives a run through the existing `execute_*` lifecycle steps by repeatedly asking a pure `RunScheduler.ready(state, plan)` for the ready step-set and executing it, folding results into run metadata; the steps and their injected `ExecutionPorts` are unchanged. The scheduler is pure (a decision, not a side effect) and lives in executor core, not in the `ExecutionPorts` bag. Two forward-compat invariants: `ready()` returns a **set** (`ReadyStep[]`, length-1 under today's `LinearScheduler`), and readiness is derived from per-slice completion facts (`run.json` completedSlices + `reports.jsonl`), not the global linear `status` enum — so a later `PetriScheduler` (real Petri-net execution, parked `geolog-and-petri-execution`) drops in without reshaping the loop. Extends the D101-L `orchestrate` tool intent. Depends on: D101-L, D52-L. | [`src/executor/TOPOLOGY.md`](src/executor/TOPOLOGY.md) | active | ### Critical Invariants diff --git a/src/.pi/extensions/__tests__/registry.test.ts b/src/.pi/extensions/__tests__/registry.test.ts index 3f4aba38..344f5c21 100644 --- a/src/.pi/extensions/__tests__/registry.test.ts +++ b/src/.pi/extensions/__tests__/registry.test.ts @@ -27,6 +27,7 @@ import { BRUNCH_EXECUTE_HOST_PROMOTION_PREFLIGHT_TOOL, } from '../agent-runtime/execute-host-promotion/index.js'; import { BRUNCH_EXECUTE_LAUNCH_TOOL } from '../agent-runtime/execute-launch/index.js'; +import { BRUNCH_EXECUTE_ORCHESTRATE_TOOL } from '../agent-runtime/execute-orchestrate/index.js'; import { BRUNCH_EXECUTE_PETRI_EXPORT_TOOL } from '../agent-runtime/execute-petri-export/index.js'; import { BRUNCH_EXECUTE_PLAN_CHECK_TOOL } from '../agent-runtime/execute-plan-check/index.js'; import { BRUNCH_EXECUTE_PLAN_DRAFT_ARTIFACT_TOOL } from '../agent-runtime/execute-plan-draft-artifact/index.js'; @@ -168,6 +169,7 @@ describe('Brunch explicit Pi extension registry', () => { 'web_fetch', 'web_search', BRUNCH_EXECUTE_STATUS_TOOL, + BRUNCH_EXECUTE_ORCHESTRATE_TOOL, BRUNCH_EXECUTE_AGENT_RESULT_TOOL, BRUNCH_EXECUTE_PETRI_EXPORT_TOOL, BRUNCH_EXECUTE_PROMOTION_PREPARE_TOOL, @@ -1702,7 +1704,7 @@ describe('Brunch explicit Pi extension registry', () => { // EXECUTOR_ALLOWED_TOOL_NAMES; the registered-but-unadmitted plan artifact // tools are intentionally excluded, and text and details must agree. expect(result.content[0]?.text).toContain( - 'ported tools: execute_status, execute_snapshot, execute_plan_check, execute_plan_outline, execute_plan_draft, execute_plan_preview, execute_plan_file, execute_launch, execute_run_create, execute_worktree_create, execute_populate, execute_source_policy, execute_source_copy, execute_report_init, execute_slice_start, execute_slice_execute, execute_agent_result, execute_test_result, execute_slice_complete, execute_run_complete, execute_petri_export, execute_promotion_prepare, execute_host_promotion_preflight, execute_host_promotion_apply', + 'ported tools: execute_status, execute_orchestrate, execute_snapshot, execute_plan_check, execute_plan_outline, execute_plan_draft, execute_plan_preview, execute_plan_file, execute_launch, execute_run_create, execute_worktree_create, execute_populate, execute_source_policy, execute_source_copy, execute_report_init, execute_slice_start, execute_slice_execute, execute_agent_result, execute_test_result, execute_slice_complete, execute_run_complete, execute_petri_export, execute_promotion_prepare, execute_host_promotion_preflight, execute_host_promotion_apply', ); expect(result.content[0]?.text).toContain('pending tools: none'); expect(result.content[0]?.text).toContain( @@ -1713,6 +1715,7 @@ describe('Brunch explicit Pi extension registry', () => { availableDisciplines: ['strict', 'interpretive'], portedTools: [ 'execute_status', + 'execute_orchestrate', 'execute_snapshot', 'execute_plan_check', 'execute_plan_outline', diff --git a/src/.pi/extensions/agent-runtime/execute-orchestrate/index.ts b/src/.pi/extensions/agent-runtime/execute-orchestrate/index.ts new file mode 100644 index 00000000..977250bf --- /dev/null +++ b/src/.pi/extensions/agent-runtime/execute-orchestrate/index.ts @@ -0,0 +1,67 @@ +import type { ExtensionAPI, ToolDefinition } from '@earendil-works/pi-coding-agent'; +import { Type, type Static } from 'typebox'; + +import type { ExecutionPorts } from '../../../../executor/execution-ports.js'; +import { drive, type DriveOutcome } from '../../../../executor/orchestrate.js'; +import { BRUNCH_EXECUTE_ORCHESTRATE_TOOL } from '../../../../session/schema/tool-names.js'; + +export { BRUNCH_EXECUTE_ORCHESTRATE_TOOL } from '../../../../session/schema/tool-names.js'; + +const ExecuteOrchestrateParams = Type.Object({ + runId: Type.String({ description: 'Run id to drive to completion.' }), +}); + +type ExecuteOrchestrateParams = Static; + +interface ExecuteOrchestrateDetails { + readonly outcome: DriveOutcome; +} + +export function createExecuteOrchestrateTool( + ports: ExecutionPorts, +): ToolDefinition { + return { + name: BRUNCH_EXECUTE_ORCHESTRATE_TOOL, + label: 'execute_orchestrate', + description: + 'Drive an executor run end-to-end to promotion_prepared (run-local land) by advancing each lifecycle step the scheduler reports ready. Halts without advancing if a step cannot execute. Does not perform host promotion/land.', + parameters: ExecuteOrchestrateParams, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const cwd = ctx?.cwd; + if (typeof cwd !== 'string' || cwd.trim().length === 0) { + throw new Error('execute_orchestrate requires an active cwd'); + } + const outcome = await drive({ + cwd, + runId: params.runId, + ports, + runtime: { + ...(ctx.modelRegistry ? { modelRegistry: ctx.modelRegistry } : {}), + ...(ctx.model ? { model: ctx.model } : {}), + ...(_signal ? { signal: _signal } : {}), + }, + ...(_signal ? { signal: _signal } : {}), + }); + return { + content: [ + { + type: 'text' as const, + text: [ + `execute_orchestrate: ${outcome.status}`, + `run status: ${'runStatus' in outcome ? outcome.runStatus : 'not_started'}`, + `run id: ${params.runId}`, + ...(outcome.status === 'halted' ? [`halted at: ${outcome.step} (${outcome.reason})`] : []), + ].join('\n'), + }, + ], + details: { outcome }, + }; + }, + }; +} + +export function registerBrunchExecuteOrchestrate(pi: ExtensionAPI, ports: ExecutionPorts): void { + pi.registerTool(createExecuteOrchestrateTool(ports) as never); +} + +export default registerBrunchExecuteOrchestrate; diff --git a/src/.pi/extensions/agent-runtime/index.ts b/src/.pi/extensions/agent-runtime/index.ts index 970beeef..2d49a56e 100644 --- a/src/.pi/extensions/agent-runtime/index.ts +++ b/src/.pi/extensions/agent-runtime/index.ts @@ -1,4 +1,5 @@ export * from './execute-agent-result/index.js'; +export * from './execute-orchestrate/index.js'; export * from './execute-host-promotion/index.js'; export * from './execute-launch/index.js'; export * from './execute-plan-file/index.js'; diff --git a/src/agents/runtime/executor/active-tools.ts b/src/agents/runtime/executor/active-tools.ts index 67a10291..3a0c7799 100644 --- a/src/agents/runtime/executor/active-tools.ts +++ b/src/agents/runtime/executor/active-tools.ts @@ -15,6 +15,8 @@ export const EXECUTOR_ALLOWED_TOOL_NAMES = [ // Execute-mode orchestration footholds (FE-1089). Registered-but-inactive // unless the executor admits them; side-effect-bounded per I52-L. 'execute_status', + // Run driver over the lifecycle steps (FE-1125, D102-L). + 'execute_orchestrate', 'execute_snapshot', 'execute_plan_check', 'execute_plan_outline', diff --git a/src/app/__tests__/agent-runner-port.test.ts b/src/app/__tests__/agent-runner-port.test.ts index 62555b9b..b00f723e 100644 --- a/src/app/__tests__/agent-runner-port.test.ts +++ b/src/app/__tests__/agent-runner-port.test.ts @@ -52,7 +52,8 @@ describe('createAgentRunnerPort', () => { }), ).resolves.toEqual({ status: 'failed', - message: 'AgentRunnerPort is not implemented yet; inject sealed subagent deps to execute agent slices.', + message: + 'AgentRunnerPort has no subagent deps injected in this launch, so the sealed worker cannot run. Compose subagents (execute mode or --dev-tools).', }); }); diff --git a/src/app/__tests__/brunch-tui.test.ts b/src/app/__tests__/brunch-tui.test.ts index c4ca32de..1c28e8d3 100644 --- a/src/app/__tests__/brunch-tui.test.ts +++ b/src/app/__tests__/brunch-tui.test.ts @@ -56,6 +56,7 @@ import { BRUNCH_EXECUTE_PLAN_OUTLINE_TOOL, BRUNCH_EXECUTE_SNAPSHOT_TOOL, BRUNCH_EXECUTE_STATUS_TOOL, + BRUNCH_EXECUTE_ORCHESTRATE_TOOL, BRUNCH_INTROSPECTION_COMMAND, BRUNCH_MODE_COMMAND, BRUNCH_SWITCH_COMMAND, @@ -674,6 +675,7 @@ describe('Brunch TUI boot', () => { 'web_fetch', 'web_search', BRUNCH_EXECUTE_STATUS_TOOL, + BRUNCH_EXECUTE_ORCHESTRATE_TOOL, BRUNCH_EXECUTE_AGENT_RESULT_TOOL, BRUNCH_EXECUTE_PETRI_EXPORT_TOOL, BRUNCH_EXECUTE_PROMOTION_PREPARE_TOOL, diff --git a/src/app/agent-runner-port.ts b/src/app/agent-runner-port.ts index 959d0aa4..2196f488 100644 --- a/src/app/agent-runner-port.ts +++ b/src/app/agent-runner-port.ts @@ -20,7 +20,7 @@ export function createAgentRunnerPort(options: AgentRunnerPortOptions = {}): Age return { status: 'failed', message: - 'AgentRunnerPort is not implemented yet; inject sealed subagent deps to execute agent slices.', + 'AgentRunnerPort has no subagent deps injected in this launch, so the sealed worker cannot run. Compose subagents (execute mode or --dev-tools).', }; } const worker = subagents.definitions.get('worker'); diff --git a/src/app/pi-extensions.ts b/src/app/pi-extensions.ts index 79a54c88..8938c2b9 100644 --- a/src/app/pi-extensions.ts +++ b/src/app/pi-extensions.ts @@ -6,6 +6,7 @@ import { import { registerBrunchAlternatives } from '../.pi/components/alternatives.js'; import { registerBrunchExecuteAgentResult } from '../.pi/extensions/agent-runtime/index.js'; +import { registerBrunchExecuteOrchestrate } from '../.pi/extensions/agent-runtime/index.js'; import { registerBrunchExecuteHostPromotion } from '../.pi/extensions/agent-runtime/index.js'; import { registerBrunchExecuteLaunch } from '../.pi/extensions/agent-runtime/index.js'; import { registerBrunchExecutePlanFile } from '../.pi/extensions/agent-runtime/index.js'; @@ -137,6 +138,9 @@ export { BRUNCH_EXECUTE_AGENT_RESULT_TOOL, createExecuteAgentResultTool, registerBrunchExecuteAgentResult, + BRUNCH_EXECUTE_ORCHESTRATE_TOOL, + createExecuteOrchestrateTool, + registerBrunchExecuteOrchestrate, BRUNCH_EXECUTE_HOST_PROMOTION_APPLY_TOOL, BRUNCH_EXECUTE_HOST_PROMOTION_PREFLIGHT_TOOL, createExecuteHostPromotionApplyTool, @@ -342,6 +346,7 @@ export function createBrunchPiExtensions( registerBrunchContext, registerBrunchWebTools, registerBrunchExecuteStatus, + (api) => registerBrunchExecuteOrchestrate(api, executionPorts), (api) => registerBrunchExecuteAgentResult(api, executionPorts.agentRunner), ...(graph ? [(api: ExtensionAPI) => registerBrunchExecuteLaunch(api, graph)] : []), ...(graph ? [(api: ExtensionAPI) => registerBrunchExecutePlanFile(api, graph)] : []), diff --git a/src/executor/TOPOLOGY.md b/src/executor/TOPOLOGY.md index e88c2fb1..0f24a5fb 100644 --- a/src/executor/TOPOLOGY.md +++ b/src/executor/TOPOLOGY.md @@ -1,6 +1,6 @@ # executor/ — execute-mode projection contracts -SPEC decisions: FE-1089 cutover frontier; `brunch-orchestrator-cutover-to-next.md` Arc 1 data bridge. +SPEC decisions: FE-1089 cutover frontier; FE-1125 run driver (D102-L); `brunch-orchestrator-cutover-to-next.md` Arc 1 data bridge. ## Owns @@ -10,6 +10,7 @@ Pure contracts and projection helpers that turn `next` graph facts into execute- executor/ ├── TOPOLOGY.md ├── agent-result.ts AgentRunnerPort -> slice result report +├── orchestrate.ts run facts + RunScheduler -> drive() over the lifecycle steps ├── plan-file.ts old cook-compatible DTO preview -> spec-scoped plan.yaml ├── launch.ts spec-scoped plan.yaml -> non-running launch readiness ├── plan-preview.ts executable-plan draft -> old cook-compatible DTO preview @@ -94,3 +95,5 @@ rules: `promotion.ts` is the first land/promotion boundary: for a `petri_exported` run with a worktree it invokes the injected `GitLandPort`, then writes `.brunch/cook/runs//promotion/promotion.json` (runId, specId, petriPath, reportsPath, completedSliceIds, run-local commit SHA) and records `status:"promotion_prepared"`. `GitLandPort` failure or no changes leaves metadata unchanged. This is run-local only: host branch/ref promotion remains out of scope, and actual host land remains pending. `host-promotion.ts` is the host-promotion preflight/apply boundary. Preflight validates that `run.json.promotionCommitSha` agrees with `promotion/promotion.json` and delegates read-only promoted-commit diff inspection to `GitHostPromotionPort`, returning changed files and patch summary with `sideEffects: []`. Apply requires an accepted commit SHA, reruns preflight, and delegates bounded host worktree patch application to the same port; it reports `host_worktree_apply` and still does not commit, create refs, switch branches, or stage the host index. + +`orchestrate.ts` is the run driver (FE-1125, D102-L): a generic `drive()` loop advances a run by repeatedly asking a pure `RunScheduler` for the ready step and calling the matching lifecycle step function with the injected `ExecutionPorts`. It owns no side effects of its own — `run.json` status is the loop state, and per-slice-frontier readiness derives from completion facts, not the status enum. `linearScheduler` returns a single ready step and drives a run end-to-end from `created` to `promotion_prepared` (run-local land via `GitLandPort`); the set-returning `ready()` contract leaves room for a future `PetriScheduler` (real Petri-net execution) without reshaping the loop. Host promotion/land stays off the driven chain (a separate, explicitly-accepted surface) — the scheduler reports no ready step at `promotion_prepared`. diff --git a/src/executor/__tests__/orchestrate.test.ts b/src/executor/__tests__/orchestrate.test.ts new file mode 100644 index 00000000..a9f1040d --- /dev/null +++ b/src/executor/__tests__/orchestrate.test.ts @@ -0,0 +1,266 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { ingestAgentResult } from '../agent-result.js'; +import type { AgentRunnerPort, ExecutionPorts, TestRunnerPort } from '../execution-ports.js'; +import { drive, linearScheduler, type ReadyStep } from '../orchestrate.js'; +import { exportPetri } from '../petri.js'; +import { planFilePath } from '../plan-file.js'; +import { populateWorktree } from '../populate.js'; +import { preparePromotion } from '../promotion.js'; +import { initializeReports, reportsPath } from '../report.js'; +import { completeRun } from '../run-complete.js'; +import { createRun, readRunMetadata, runMetadataPath, type RunMetadata } from '../run.js'; +import { completeSlice } from '../slice-complete.js'; +import { requestSliceExecution } from '../slice-execute.js'; +import { startSlice } from '../slice-start.js'; +import { copyHostSource } from '../source-copy.js'; +import { selectSourcePolicy } from '../source-policy.js'; +import { ingestTestResult } from '../test-result.js'; +import { createWorktree } from '../worktree.js'; +import { + createFakeGitHostPromotionPort, + createFakeGitLandPort, + createFakeGitWorktreePort, + createFakeTestRunnerPort, +} from './fake-ports.js'; + +const completedAgentRunner: AgentRunnerPort = { + async run() { + return { status: 'completed' }; + }, +}; + +function fakePorts(overrides: Partial = {}): ExecutionPorts { + return { + gitWorktree: createFakeGitWorktreePort(), + agentRunner: completedAgentRunner, + testRunner: createFakeTestRunnerPort(), + gitLand: createFakeGitLandPort(), + gitHostPromotion: createFakeGitHostPromotionPort({}), + ...overrides, + }; +} + +function planJson(sliceIds: readonly string[]): string { + return JSON.stringify({ + mode: 'greenfield', + epics: [{ id: 'frontier-1', summary: 'Build feature', depends_on: [], verification: [] }], + slices: sliceIds.map((id) => ({ + id, + epic_id: 'frontier-1', + definition: `${id}.`, + depends_on: [], + verification: [], + })), + }); +} + +async function createRunAtCreated( + cwd: string, + sliceIds: readonly string[] = ['task-1', 'task-2'], +): Promise { + await mkdir(join(cwd, 'src'), { recursive: true }); + await writeFile(join(cwd, 'src', 'app.ts'), 'export const app = true;\n', 'utf8'); + await mkdir(join(cwd, '.brunch', 'cook', 'specs', '42'), { recursive: true }); + await writeFile(planFilePath(cwd, '42'), planJson(sliceIds), 'utf8'); + await createRun({ cwd, specId: '42', runId: 'run-1' }); +} + +function metadata(status: RunMetadata['status'], extra: Partial = {}): RunMetadata { + return { runId: 'run-1', specId: '42', planPath: 'plan.yaml', status, ...extra }; +} + +async function readReportEvents(cwd: string): Promise { + const raw = await readFile(reportsPath(cwd, 'run-1'), 'utf8'); + return raw + .trim() + .split('\n') + .map((line) => JSON.parse(line) as unknown); +} + +// The differential baseline for the parity oracle: crank the same lifecycle steps +// by hand, exactly as drive() composes them. +async function crankManually(cwd: string, ports: ExecutionPorts): Promise { + await createWorktree({ cwd, runId: 'run-1', gitWorktree: ports.gitWorktree }); + await populateWorktree({ cwd, runId: 'run-1' }); + await selectSourcePolicy({ cwd, runId: 'run-1', policy: 'host_source_deferred' }); + await copyHostSource({ cwd, runId: 'run-1' }); + await initializeReports({ cwd, runId: 'run-1' }); + for (;;) { + const started = await startSlice({ cwd, runId: 'run-1' }); + if (started.status !== 'slice_started') break; + await requestSliceExecution({ cwd, runId: 'run-1' }); + await ingestAgentResult({ cwd, runId: 'run-1', agentRunner: ports.agentRunner }); + await ingestTestResult({ cwd, runId: 'run-1', testRunner: ports.testRunner }); + await completeSlice({ cwd, runId: 'run-1' }); + } + await completeRun({ cwd, runId: 'run-1' }); + await exportPetri({ cwd, runId: 'run-1' }); + await preparePromotion({ cwd, runId: 'run-1', gitLand: ports.gitLand }); +} + +describe('drive', () => { + it('drives a created run through to run-local promotion', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-drive-complete-')); + await createRunAtCreated(cwd, ['task-1', 'task-2']); + + const outcome = await drive({ cwd, runId: 'run-1', ports: fakePorts() }); + + expect(outcome).toEqual({ status: 'completed', runStatus: 'promotion_prepared' }); + const meta = await readRunMetadata(runMetadataPath(cwd, 'run-1')); + expect(meta?.status).toBe('promotion_prepared'); + expect(meta?.completedSliceIds).toEqual(['task-1', 'task-2']); + expect(meta?.promotionCommitSha).toBe('abc123'); + }); + + it('produces the same reports and terminal metadata as hand-cranking the steps', async () => { + const driven = await mkdtemp(join(tmpdir(), 'brunch-drive-parity-driven-')); + await createRunAtCreated(driven, ['task-1', 'task-2']); + await drive({ cwd: driven, runId: 'run-1', ports: fakePorts() }); + + const cranked = await mkdtemp(join(tmpdir(), 'brunch-drive-parity-cranked-')); + await createRunAtCreated(cranked, ['task-1', 'task-2']); + await crankManually(cranked, fakePorts()); + + expect(await readReportEvents(driven)).toEqual(await readReportEvents(cranked)); + + const drivenMeta = await readRunMetadata(runMetadataPath(driven, 'run-1')); + const crankedMeta = await readRunMetadata(runMetadataPath(cranked, 'run-1')); + expect(drivenMeta?.status).toBe(crankedMeta?.status); + expect(drivenMeta?.completedSliceIds).toEqual(crankedMeta?.completedSliceIds); + expect(drivenMeta?.promotionCommitSha).toBe(crankedMeta?.promotionCommitSha); + }); + + it('halts without advancing when a step cannot execute', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-drive-halt-')); + await createRunAtCreated(cwd, ['task-1']); + + const outcome = await drive({ + cwd, + runId: 'run-1', + ports: fakePorts({ + testRunner: createFakeTestRunnerPort({ status: 'failed', message: 'runner exploded' }), + }), + }); + + expect(outcome).toEqual({ + status: 'halted', + step: 'test_result', + runStatus: 'agent_result_ingested', + reason: 'test_run_failed', + }); + const meta = await readRunMetadata(runMetadataPath(cwd, 'run-1')); + expect(meta?.status).toBe('agent_result_ingested'); + }); + + it('invokes the agent and test runner exactly once per slice', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-drive-once-')); + await createRunAtCreated(cwd, ['task-1', 'task-2']); + + let agentRuns = 0; + let testRuns = 0; + const agentRunner: AgentRunnerPort = { + async run() { + agentRuns += 1; + return { status: 'completed' }; + }, + }; + const testRunner: TestRunnerPort = { + async run() { + testRuns += 1; + return { status: 'completed', verdict: 'passed', exitCode: 0, target: 'npm run verify' }; + }, + }; + + await drive({ cwd, runId: 'run-1', ports: fakePorts({ agentRunner, testRunner }) }); + + expect(agentRuns).toBe(2); + expect(testRuns).toBe(2); + }); + + it('halts at promotion when the run-local land fails', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-drive-land-fail-')); + await createRunAtCreated(cwd, ['task-1']); + + const outcome = await drive({ + cwd, + runId: 'run-1', + ports: fakePorts({ + gitLand: createFakeGitLandPort({ status: 'failed', message: 'land boom', sideEffects: [] }), + }), + }); + + expect(outcome).toEqual({ + status: 'halted', + step: 'promotion', + runStatus: 'petri_exported', + reason: 'promotion_failed', + }); + const meta = await readRunMetadata(runMetadataPath(cwd, 'run-1')); + expect(meta?.status).toBe('petri_exported'); + }); + + it('halts at promotion when the land reports no changes', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-drive-land-none-')); + await createRunAtCreated(cwd, ['task-1']); + + const outcome = await drive({ + cwd, + runId: 'run-1', + ports: fakePorts({ + gitLand: createFakeGitLandPort({ status: 'no_changes', message: 'nothing to land', sideEffects: [] }), + }), + }); + + expect(outcome).toEqual({ + status: 'halted', + step: 'promotion', + runStatus: 'petri_exported', + reason: 'promotion_no_changes', + }); + const meta = await readRunMetadata(runMetadataPath(cwd, 'run-1')); + expect(meta?.status).toBe('petri_exported'); + }); +}); + +describe('linearScheduler', () => { + it('returns exactly one ready step per turn and none once completed', () => { + const cases: { readonly status: RunMetadata['status']; readonly expected: readonly ReadyStep[] }[] = [ + { status: 'created', expected: [{ kind: 'worktree_create' }] }, + { status: 'worktree_created', expected: [{ kind: 'populate' }] }, + { status: 'worktree_populated', expected: [{ kind: 'source_policy' }] }, + { status: 'source_policy_selected', expected: [{ kind: 'source_copy' }] }, + { status: 'source_copied', expected: [{ kind: 'report_init' }] }, + { status: 'slice_started', expected: [{ kind: 'slice_execute' }] }, + { status: 'slice_execution_requested', expected: [{ kind: 'agent_result' }] }, + { status: 'agent_result_ingested', expected: [{ kind: 'test_result' }] }, + { status: 'test_result_ingested', expected: [{ kind: 'slice_complete' }] }, + { status: 'run_completed', expected: [{ kind: 'petri_export' }] }, + { status: 'petri_exported', expected: [{ kind: 'promotion' }] }, + { status: 'promotion_prepared', expected: [] }, + ]; + for (const { status, expected } of cases) { + expect(linearScheduler.ready(metadata(status), undefined)).toEqual(expected); + } + }); + + it('selects the next incomplete slice from completion facts, not the status', () => { + const plan = { slices: [{ id: 'task-1' }, { id: 'task-2' }] }; + + expect( + linearScheduler.ready(metadata('slice_completed', { completedSliceIds: ['task-1'] }), plan), + ).toEqual([{ kind: 'slice_start', sliceId: 'task-2' }]); + + expect(linearScheduler.ready(metadata('reports_initialized'), plan)).toEqual([ + { kind: 'slice_start', sliceId: 'task-1' }, + ]); + + expect( + linearScheduler.ready(metadata('slice_completed', { completedSliceIds: ['task-1', 'task-2'] }), plan), + ).toEqual([{ kind: 'run_complete' }]); + }); +}); diff --git a/src/executor/orchestrate.ts b/src/executor/orchestrate.ts new file mode 100644 index 00000000..1ede119a --- /dev/null +++ b/src/executor/orchestrate.ts @@ -0,0 +1,193 @@ +import { readFile } from 'node:fs/promises'; + +import { ingestAgentResult } from './agent-result.js'; +import type { AgentRunnerRuntime, ExecutionPorts } from './execution-ports.js'; +import { exportPetri } from './petri.js'; +import { populatedPlanPath, populateWorktree } from './populate.js'; +import { preparePromotion } from './promotion.js'; +import { initializeReports } from './report.js'; +import { completeRun } from './run-complete.js'; +import { readRunMetadata, runMetadataPath, type RunMetadata } from './run.js'; +import { completeSlice } from './slice-complete.js'; +import { requestSliceExecution } from './slice-execute.js'; +import { startSlice } from './slice-start.js'; +import { copyHostSource } from './source-copy.js'; +import { selectSourcePolicy, type SourcePolicyKind } from './source-policy.js'; +import { ingestTestResult } from './test-result.js'; +import { createWorktree } from './worktree.js'; + +// The driver composes the existing `execute_*` lifecycle steps into a single +// self-advancing run. It owns no side effects of its own: each ReadyStep maps +// to one step function, and the run.json status IS the loop state (D102-L). + +export type ReadyStep = + | { readonly kind: 'worktree_create' } + | { readonly kind: 'populate' } + | { readonly kind: 'source_policy' } + | { readonly kind: 'source_copy' } + | { readonly kind: 'report_init' } + | { readonly kind: 'slice_start'; readonly sliceId: string } + | { readonly kind: 'slice_execute' } + | { readonly kind: 'agent_result' } + | { readonly kind: 'test_result' } + | { readonly kind: 'slice_complete' } + | { readonly kind: 'run_complete' } + | { readonly kind: 'petri_export' } + | { readonly kind: 'promotion' }; + +/** The minimal plan projection the scheduler needs to resolve the slice frontier. */ +export interface SchedulerPlan { + readonly slices?: readonly { readonly id: string }[]; +} + +export interface RunScheduler { + /** Pure: given current run facts, return the ready step frontier (`[]` when done). */ + ready(state: RunMetadata, plan: SchedulerPlan | undefined): readonly ReadyStep[]; +} + +// A set-returning scheduler (length-1 today) leaves room for a future +// PetriScheduler that fires several enabled transitions at once (D102-L, +// geolog-and-petri-execution) without reshaping the driver loop. +export const linearScheduler: RunScheduler = { + ready(state, plan) { + switch (state.status) { + case 'created': + return [{ kind: 'worktree_create' }]; + case 'worktree_created': + return [{ kind: 'populate' }]; + case 'worktree_populated': + return [{ kind: 'source_policy' }]; + case 'source_policy_selected': + return [{ kind: 'source_copy' }]; + case 'source_copied': + return [{ kind: 'report_init' }]; + case 'reports_initialized': + case 'slice_completed': { + // Slice-frontier readiness derives from completion facts, not the coarse + // status: the next step is the first plan slice not yet completed. + const completed = new Set(state.completedSliceIds ?? []); + const next = plan?.slices?.find((slice) => !completed.has(slice.id)); + return next ? [{ kind: 'slice_start', sliceId: next.id }] : [{ kind: 'run_complete' }]; + } + case 'slice_started': + return [{ kind: 'slice_execute' }]; + case 'slice_execution_requested': + return [{ kind: 'agent_result' }]; + case 'agent_result_ingested': + return [{ kind: 'test_result' }]; + case 'test_result_ingested': + return [{ kind: 'slice_complete' }]; + case 'run_completed': + return [{ kind: 'petri_export' }]; + case 'petri_exported': + return [{ kind: 'promotion' }]; + case 'promotion_prepared': + return []; + } + }, +}; + +export interface DriveContext { + readonly cwd: string; + readonly runId: string; + readonly ports: ExecutionPorts; + readonly sourcePolicy?: SourcePolicyKind; + readonly runtime?: AgentRunnerRuntime; + readonly signal?: AbortSignal; +} + +export type DriveOutcome = + | { readonly status: 'completed'; readonly runStatus: RunMetadata['status'] } + | { + readonly status: 'halted'; + readonly step: ReadyStep['kind']; + readonly runStatus: RunMetadata['status']; + readonly reason: string; + } + | { readonly status: 'missing_run'; readonly runId: string }; + +interface StepResult { + readonly status: string; + readonly runStatus: RunMetadata['status'] | 'not_started'; +} + +/** + * Drive a run to `promotion_prepared` (run-local land) by repeatedly executing the + * scheduler's ready step. Host promotion/land stays out of scope: the scheduler + * reports no ready step once the run reaches `promotion_prepared`. + */ +export async function drive( + ctx: DriveContext, + scheduler: RunScheduler = linearScheduler, +): Promise { + const metadataPath = runMetadataPath(ctx.cwd, ctx.runId); + // ceiling: coarse halt detection — a step that leaves run.json's status + // unchanged is treated as stuck. Replace with per-step outcome classification + // if steps gain retry/abort semantics beyond advance-or-hold. + for (;;) { + const state = await readRunMetadata(metadataPath); + if (!state) return { status: 'missing_run', runId: ctx.runId }; + + const plan = await planForScheduler(ctx.cwd, state); + const [next] = scheduler.ready(state, plan); + if (!next) return { status: 'completed', runStatus: state.status }; + + const result = await runStep(next, ctx); + if (result.runStatus === state.status) { + return { status: 'halted', step: next.kind, runStatus: state.status, reason: result.status }; + } + } +} + +async function planForScheduler(cwd: string, state: RunMetadata): Promise { + if (state.status !== 'reports_initialized' && state.status !== 'slice_completed') return undefined; + const path = state.populatedPlanPath ?? populatedPlanPath(cwd, state.runId); + return JSON.parse(await readFile(path, 'utf8')) as SchedulerPlan; +} + +async function runStep(step: ReadyStep, ctx: DriveContext): Promise { + const { cwd, runId, ports } = ctx; + switch (step.kind) { + case 'worktree_create': + return createWorktree({ + cwd, + runId, + gitWorktree: ports.gitWorktree, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + case 'populate': + return populateWorktree({ cwd, runId }); + case 'source_policy': + return selectSourcePolicy({ cwd, runId, policy: ctx.sourcePolicy ?? 'host_source_deferred' }); + case 'source_copy': + return copyHostSource({ cwd, runId }); + case 'report_init': + return initializeReports({ cwd, runId }); + case 'slice_start': + return startSlice({ cwd, runId, sliceId: step.sliceId }); + case 'slice_execute': + return requestSliceExecution({ cwd, runId }); + case 'agent_result': + return ingestAgentResult({ + cwd, + runId, + agentRunner: ports.agentRunner, + ...(ctx.runtime ? { runtime: ctx.runtime } : {}), + }); + case 'test_result': + return ingestTestResult({ + cwd, + runId, + testRunner: ports.testRunner, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + case 'slice_complete': + return completeSlice({ cwd, runId }); + case 'run_complete': + return completeRun({ cwd, runId }); + case 'petri_export': + return exportPetri({ cwd, runId }); + case 'promotion': + return preparePromotion({ cwd, runId, gitLand: ports.gitLand }); + } +} diff --git a/src/session/schema/tool-names.ts b/src/session/schema/tool-names.ts index c148cd3c..744b5d39 100644 --- a/src/session/schema/tool-names.ts +++ b/src/session/schema/tool-names.ts @@ -1,4 +1,5 @@ export const BRUNCH_ORCHESTRATOR_STUB_TOOL = 'orchestrator_stub'; +export const BRUNCH_EXECUTE_ORCHESTRATE_TOOL = 'execute_orchestrate'; export const BRUNCH_EXECUTE_AGENT_RESULT_TOOL = 'execute_agent_result'; export const BRUNCH_EXECUTE_LAUNCH_TOOL = 'execute_launch'; export const BRUNCH_EXECUTE_PLAN_FILE_TOOL = 'execute_plan_file';