diff --git a/memory/cards/executor-promotion--promotion-metadata-recovery.md b/memory/cards/executor-promotion--promotion-metadata-recovery.md new file mode 100644 index 000000000..77f6de0ab --- /dev/null +++ b/memory/cards/executor-promotion--promotion-metadata-recovery.md @@ -0,0 +1,47 @@ +# executor-promotion — promotion metadata recovery + +## Orientation + +- Containing seam: `executor-promotion` (FE-1112), after run-local `GitLandPort` landed. +- Review finding: `preparePromotion` performs the run-local git commit before writing `promotion.json` and updating `run.json`. +- Main risk: if `promotion.json` or `run.json` persistence fails after the git commit, retry sees a clean worktree and returns `promotion_no_changes`, leaving the run stuck at `petri_exported` despite a promoted commit existing. + +## Scope Weight + +Full scope card. This fixes a failure-mode invariant at the first real git mutation seam. + +## Target Behavior + +`execute_promotion_prepare` can recover or idempotently complete promotion metadata after a prior successful run-local git commit. + +## Boundary Crossings + +```text +execute_promotion_prepare Pi tool +→ src/executor/promotion.ts +→ GitLandPort result/recovery contract +→ promotion.json / run.json persistence +``` + +## Risks and Assumptions + +- RISK: no-change retry hides a prior successful promotion commit. → MITIGATION: teach the promotion path to distinguish “no changes because already promoted” from “nothing was ever promoted,” using durable promotion metadata or a port-reported current commit. +- RISK: recovery logic re-derives run topology. → MITIGATION: recovery may use only existing run metadata, worktree commit identity, and promotion artifact paths; it must not inspect or rewrite plan/Petri topology. +- ASSUMPTION: recording the promoted commit SHA before or during report persistence is enough to make retry safe. → VALIDATE: focused test simulates commit success followed by persistence failure, then reruns promotion and observes `promotion_prepared` with the same SHA. + +## Acceptance Criteria + +✓ `src/executor/__tests__/promotion.test.ts` — simulates successful `GitLandPort` commit followed by failed promotion metadata persistence; retry completes `promotion.json` / `run.json` instead of returning `promotion_no_changes`. + +✓ `src/app/__tests__/git-land-port.test.ts` — app-layer port exposes enough commit identity on a clean already-promoted worktree, or the core recovery path does not require app-layer changes because commit identity is already durable. + +✓ Failure paths that truly have no prior promoted commit still do not advance metadata. + +## Verification Approach + +- Inner: focused promotion recovery tests. +- Gate: `npm run verify`. + +## Recommended Next Route + +Build it with `ln-build`. diff --git a/memory/cards/executor-promotion--run-local-git-land-port.md b/memory/cards/executor-promotion--run-local-git-land-port.md new file mode 100644 index 000000000..1c2fee6a9 --- /dev/null +++ b/memory/cards/executor-promotion--run-local-git-land-port.md @@ -0,0 +1,65 @@ +# executor-promotion — run-local GitLandPort slice + +## Orientation + +- Containing seam: `orchestrator-cutover` real-execution substrate; `executor-sandbox` supplies a real git worktree and real verify runner, and `executor-agent-runner` supplies a sealed worker that can make sandbox diffs. +- Frontier item: `executor-promotion` (FE-1112) on `ka/fe-1112-executor-promotion`, stacked on `ka/fe-1111-executor-agent-runner`. +- Handoff state: FE-1111 is complete; `execute_promotion_prepare` is still descriptive and `execute_status.pendingTools` still reports `land`. +- Main open risk: promotion is the first hard-to-reverse git seam, so the first slice must stay run-local and consume existing run artifacts instead of touching host branches. + +## Scope Weight + +Full scope card. This slice establishes the `GitLandPort` capability boundary and changes the promotion seam from descriptive-only to a real, run-local git mutation. + +## Target Behavior + +`execute_promotion_prepare` promotes a completed run's verified sandbox worktree diff through an injected `GitLandPort` without mutating host branches. + +## Boundary Crossings + +```text +execute_promotion_prepare Pi tool +→ src/executor/promotion.ts +→ src/executor/execution-ports.ts GitLandPort contract +→ src/app/git-land-port.ts app-layer git implementation +→ run worktree git state / promotion artifact +``` + +## Risks and Assumptions + +- RISK: host branch/ref mutation sneaks into the first land slice. → MITIGATION: `GitLandPort` first supports run-local promotion only; tests assert no host `.git` branch/ref mutation and no writes outside the run/worktree/promotion paths. +- RISK: promotion re-derives run state and diverges from Petri/report artifacts. → MITIGATION: require `promotion_prepared` inputs to come from existing run metadata, Petri artifact, completed slices, and worktree path; no fresh plan topology derivation. +- RISK: no-op worktrees make promotion look successful without a diff. → MITIGATION: first port result must report an explicit `no_changes` / failure-style status that does not advance metadata, or a real promoted commit/ref artifact with changed files. +- ASSUMPTION: run-local commit/ref is enough to unlock the next reviewable layer before host promotion. → VALIDATE: focused tests prove a diff in the run worktree becomes a run-local promotion artifact, while host branch promotion remains absent. + +## Acceptance Criteria + +✓ `src/executor/__tests__/promotion.test.ts` — `preparePromotion` invokes an injected `GitLandPort` for a Petri-exported run with a worktree and records a real run-local promotion result. + +✓ `src/executor/__tests__/promotion.test.ts` — `GitLandPort` failure or no changes do not advance run metadata and report no side effects. + +✓ `src/app/__tests__/git-land-port.test.ts` — app-layer `GitLandPort` performs only run-local git operations inside the worktree/promotion area and returns the promoted commit/ref metadata. + +✓ `src/.pi/extensions/__tests__/registry.test.ts` — `execute_promotion_prepare` is wired with injected `GitLandPort`; `execute_status.pendingTools` remains `land` until the run-local layer is accepted as enough to drop it. + +✓ `src/executor/promotion.ts` / architecture checks — executor core imports no app, Pi, git, subprocess, or UI modules. + +## Verification Approach + +- Inner: focused Vitest tests for promotion core, app-layer git port, and Pi registry injection. +- Middle: `npm run fix`. +- Gate: `npm run verify`. + +## Promotion Checklist + +- [x] Does this change a requirement? It materializes FE-1112's first real promotion layer. +- [x] Does this create, retire, or invalidate an assumption? It validates whether run-local promotion is enough before host promotion. +- [x] Does this make or reverse a non-trivial design decision? It chooses run-local git promotion before host branch mutation. +- [x] Does this establish a new seam-level invariant? First promotion slice must not mutate host branches/refs. +- [x] Does it cross more than two major seams? +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? +- [ ] Can you not name the containing seam or current rationale from the live docs? + +## Recommended Next Route + +Build it with `ln-build`. diff --git a/src/.pi/extensions/__tests__/registry.test.ts b/src/.pi/extensions/__tests__/registry.test.ts index 31c3bfeb3..6bdee0658 100644 --- a/src/.pi/extensions/__tests__/registry.test.ts +++ b/src/.pi/extensions/__tests__/registry.test.ts @@ -7,12 +7,14 @@ import { describe, expect, it } from 'vitest'; import { createBrunchPiExtensions } from '../../../app/pi-extensions.js'; import { + createFakeGitLandPort, createFakeGitWorktreePort, createFakeTestRunnerPort, } from '../../../executor/__tests__/fake-ports.js'; import type { AgentRunArgs, AgentRunnerPort, + GitLandPort, GitWorktreePort, TestRunnerPort, } from '../../../executor/execution-ports.js'; @@ -1261,6 +1263,58 @@ describe('Brunch explicit Pi extension registry', () => { await expect(access(join(runDir, 'petrinaut'))).rejects.toThrow(); }); + it('registers execute_promotion_prepare as injected run-local promotion', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-promotion-prepare-')); + const runDir = join(cwd, '.brunch', 'cook', 'runs', 'run-1'); + const metadataPath = join(runDir, 'run.json'); + const reportPath = join(runDir, 'reports.jsonl'); + const petriPath = join(runDir, 'petrinaut', 'net.json'); + const worktreeDir = join(runDir, 'worktree'); + const promotionPath = join(runDir, 'promotion', 'promotion.json'); + await mkdir(dirname(petriPath), { recursive: true }); + await mkdir(worktreeDir, { recursive: true }); + await writeFile(petriPath, JSON.stringify({ runId: 'run-1' }), 'utf8'); + await writeFile( + metadataPath, + JSON.stringify({ + runId: 'run-1', + specId: '42', + planPath: '/tmp/plan.yaml', + status: 'petri_exported', + reportsPath: reportPath, + petriPath, + worktreeDir, + completedSliceIds: ['task-1'], + }), + 'utf8', + ); + const registeredTools = await collectProductTools({ + graph: { specId: 42, lsn: 31, nodes: [], edges: [] }, + gitLand: createFakeGitLandPort({ + status: 'promoted', + commitSha: 'def456', + sideEffects: [{ kind: 'git_commit', path: worktreeDir, sha: 'def456' }], + }), + }); + + const promotion = registeredTools.find((tool) => tool.name === BRUNCH_EXECUTE_PROMOTION_PREPARE_TOOL); + expect(promotion).toBeDefined(); + const result = await promotion!.execute('call-1', { runId: 'run-1' }, undefined, undefined, { cwd }); + + expect(result.content[0]?.text).toContain('execute_promotion_prepare: promotion_prepared'); + expect(result.details).toMatchObject({ + result: { status: 'promotion_prepared', runStatus: 'promotion_prepared', promotionPath }, + sideEffects: [ + { kind: 'write_file', path: metadataPath, ifExists: 'overwrite' }, + { kind: 'git_commit', path: worktreeDir, sha: 'def456' }, + { kind: 'mkdir', path: dirname(promotionPath) }, + { kind: 'write_file', path: promotionPath, ifExists: 'overwrite' }, + { kind: 'write_file', path: metadataPath, ifExists: 'overwrite' }, + ], + }); + await expect(readFile(promotionPath, 'utf8')).resolves.toContain('def456'); + }); + it('registers execute_plan_outline only with selected graph deps and returns a side-effect-free outline', async () => { const registeredTools: Array<{ name: string; @@ -1516,7 +1570,7 @@ describe('Brunch explicit Pi extension registry', () => { }); }); - it('keeps execute_status side-effect free while descriptive lifecycle tools are inactive', async () => { + it('keeps execute_status side-effect free after run-local promotion is ported', async () => { const registeredTools = await collectProductTools(); const status = registeredTools.find((tool) => tool.name === BRUNCH_EXECUTE_STATUS_TOOL); @@ -1527,10 +1581,9 @@ describe('Brunch explicit Pi extension registry', () => { expect(result.content[0]?.text).toContain( 'ported active tools: execute_status, execute_snapshot, execute_plan_check, execute_plan_outline, execute_plan_draft, execute_plan_preview', ); - expect(result.content[0]?.text).toContain('inactive registered tools: execute_plan_outline_artifact'); - expect(result.content[0]?.text).toContain('pending tools: cook, land'); + expect(result.content[0]?.text).toContain('pending tools: none'); expect(result.content[0]?.text).toContain( - 'cook execution: descriptive scaffold registered but inactive until the real-execution stack lands', + 'executor promotion: run-local git promotion ported; host promotion deferred', ); expect(result.details).toMatchObject({ discipline: 'interpretive', @@ -1543,8 +1596,7 @@ describe('Brunch explicit Pi extension registry', () => { 'execute_plan_draft', 'execute_plan_outline', ], - inactiveRegisteredTools: expect.arrayContaining(['execute_plan_file', 'execute_agent_result']), - pendingTools: ['cook', 'land'], + pendingTools: [], sideEffects: [], }); }); @@ -1816,6 +1868,7 @@ async function collectProductTools( gitWorktree?: GitWorktreePort; testRunner?: TestRunnerPort; agentRunner?: AgentRunnerPort; + gitLand?: GitLandPort; subagents?: BrunchSubagentsDeps; } = {}, ): Promise { @@ -1824,12 +1877,13 @@ async function collectProductTools( coordinator: {} as never, graphMentionSource: { listMentionCandidates: () => [] }, ...(options.subagents ? { subagents: options.subagents } : {}), - ...(options.gitWorktree || options.testRunner || options.agentRunner + ...(options.gitWorktree || options.testRunner || options.agentRunner || options.gitLand ? { executionPorts: { ...(options.gitWorktree ? { gitWorktree: options.gitWorktree } : {}), ...(options.testRunner ? { testRunner: options.testRunner } : {}), ...(options.agentRunner ? { agentRunner: options.agentRunner } : {}), + ...(options.gitLand ? { gitLand: options.gitLand } : {}), }, } : {}), diff --git a/src/.pi/extensions/agent-runtime/execute-promotion-prepare/index.ts b/src/.pi/extensions/agent-runtime/execute-promotion-prepare/index.ts index 2df713f0b..7537675ab 100644 --- a/src/.pi/extensions/agent-runtime/execute-promotion-prepare/index.ts +++ b/src/.pi/extensions/agent-runtime/execute-promotion-prepare/index.ts @@ -1,6 +1,7 @@ import type { ExtensionAPI, ToolDefinition } from '@earendil-works/pi-coding-agent'; import { Type, type Static } from 'typebox'; +import type { GitLandPort } from '../../../../executor/execution-ports.js'; import { preparePromotion, type PromotionPrepareResult } from '../../../../executor/promotion.js'; import { BRUNCH_EXECUTE_PROMOTION_PREPARE_TOOL } from '../../../../session/schema/tool-names.js'; @@ -13,10 +14,9 @@ interface ExecutePromotionPrepareDetails { readonly sideEffects: PromotionPrepareResult['sideEffects']; } -export function createExecutePromotionPrepareTool(): ToolDefinition< - typeof ExecutePromotionPrepareParams, - ExecutePromotionPrepareDetails -> { +export function createExecutePromotionPrepareTool( + gitLand: GitLandPort, +): ToolDefinition { return { name: BRUNCH_EXECUTE_PROMOTION_PREPARE_TOOL, label: 'execute_promotion_prepare', @@ -27,7 +27,7 @@ export function createExecutePromotionPrepareTool(): ToolDefinition< const cwd = ctx?.cwd; if (typeof cwd !== 'string' || cwd.trim().length === 0) throw new Error('execute_promotion_prepare requires an active cwd'); - const result = await preparePromotion({ cwd, runId: params.runId }); + const result = await preparePromotion({ cwd, runId: params.runId, gitLand }); return { content: [ { @@ -46,7 +46,7 @@ export function createExecutePromotionPrepareTool(): ToolDefinition< }; } -export function registerBrunchExecutePromotionPrepare(pi: ExtensionAPI): void { - pi.registerTool(createExecutePromotionPrepareTool() as never); +export function registerBrunchExecutePromotionPrepare(pi: ExtensionAPI, gitLand: GitLandPort): void { + pi.registerTool(createExecutePromotionPrepareTool(gitLand) as never); } export default registerBrunchExecutePromotionPrepare; diff --git a/src/.pi/extensions/agent-runtime/execute-status/index.ts b/src/.pi/extensions/agent-runtime/execute-status/index.ts index b39c29993..ac9723eda 100644 --- a/src/.pi/extensions/agent-runtime/execute-status/index.ts +++ b/src/.pi/extensions/agent-runtime/execute-status/index.ts @@ -26,8 +26,7 @@ interface ExecuteStatusDetails { 'execute_plan_draft', 'execute_plan_outline', ]; - readonly inactiveRegisteredTools: readonly string[]; - readonly pendingTools: readonly ['cook', 'land']; + readonly pendingTools: readonly []; readonly sideEffects: readonly []; } @@ -48,9 +47,8 @@ export function createExecuteStatusTool(): ToolDefinition { + it('reads the current worktree HEAD', async () => { + const calls: string[] = []; + const port = createGitLandPort({ + run: async (_command, args) => { + calls.push(args.join(' ')); + return { exitCode: 0, stdout: 'base123\n', stderr: '' }; + }, + }); + + await expect(port.currentHead({ worktreeDir: '/repo/wt' })).resolves.toEqual({ + status: 'ok', + commitSha: 'base123', + }); + expect(calls).toEqual(['rev-parse HEAD']); + }); + + it('commits run-local worktree changes and reports the commit sha', async () => { + const calls: Array<{ command: string; args: readonly string[]; cwd: string }> = []; + const port = createGitLandPort({ + run: async (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + if (args[0] === 'status') return { exitCode: 0, stdout: ' M worker-proof.txt\n', stderr: '' }; + if (args[0] === 'add') return { exitCode: 0, stdout: '', stderr: '' }; + if (args[0] === 'commit') + return { exitCode: 0, stdout: '[detached HEAD abc123] promote\n', stderr: '' }; + if (args[0] === 'rev-parse') return { exitCode: 0, stdout: 'abc123\n', stderr: '' }; + return { exitCode: 1, stdout: '', stderr: `unexpected ${args.join(' ')}` }; + }, + }); + + const result = await port.promote({ + worktreeDir: '/repo/.brunch/cook/runs/run-1/worktree', + message: 'promote run-1', + }); + + expect(calls).toEqual([ + { command: 'git', args: ['status', '--porcelain'], cwd: '/repo/.brunch/cook/runs/run-1/worktree' }, + { command: 'git', args: ['add', '-A'], cwd: '/repo/.brunch/cook/runs/run-1/worktree' }, + { + command: 'git', + args: ['commit', '-m', 'promote run-1'], + cwd: '/repo/.brunch/cook/runs/run-1/worktree', + }, + { command: 'git', args: ['rev-parse', 'HEAD'], cwd: '/repo/.brunch/cook/runs/run-1/worktree' }, + ]); + expect(result).toEqual({ + status: 'promoted', + commitSha: 'abc123', + sideEffects: [{ kind: 'git_commit', path: '/repo/.brunch/cook/runs/run-1/worktree', sha: 'abc123' }], + }); + }); + + it('reports no_changes with current HEAD without staging or committing when the worktree is clean', async () => { + const calls: string[] = []; + const port = createGitLandPort({ + run: async (_command, args) => { + calls.push(args.join(' ')); + if (args[0] === 'rev-parse') return { exitCode: 0, stdout: 'abc123\n', stderr: '' }; + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }); + + await expect(port.promote({ worktreeDir: '/repo/wt', message: 'promote' })).resolves.toEqual({ + status: 'no_changes', + message: 'no worktree changes to promote', + commitSha: 'abc123', + sideEffects: [], + }); + expect(calls).toEqual(['status --porcelain', 'rev-parse HEAD']); + }); + + it('reports git failures without claiming side effects', async () => { + const port = createGitLandPort({ + run: async (_command, args) => + args[0] === 'status' + ? { exitCode: 0, stdout: ' M file.ts\n', stderr: '' } + : { exitCode: 128, stdout: '', stderr: 'fatal: cannot commit' }, + }); + + await expect(port.promote({ worktreeDir: '/repo/wt', message: 'promote' })).resolves.toEqual({ + status: 'failed', + message: 'fatal: cannot commit', + sideEffects: [], + }); + }); +}); diff --git a/src/app/git-land-port.ts b/src/app/git-land-port.ts new file mode 100644 index 000000000..a28b47660 --- /dev/null +++ b/src/app/git-land-port.ts @@ -0,0 +1,64 @@ +import type { GitLandPort } from '../executor/execution-ports.js'; +import { runCommand, type CommandRunner } from './command-runner.js'; + +export function createGitLandPort(options: { readonly run?: CommandRunner } = {}): GitLandPort { + const run = options.run ?? runCommand; + return { + async currentHead(args) { + const revParse = await run('git', ['rev-parse', 'HEAD'], { cwd: args.worktreeDir }); + if (revParse.exitCode !== 0) return failedHead(revParse, `git rev-parse exited ${revParse.exitCode}`); + return { status: 'ok', commitSha: revParse.stdout.trim() }; + }, + async promote(args) { + const status = await run('git', ['status', '--porcelain'], { cwd: args.worktreeDir }); + if (status.exitCode !== 0) return failed(status, `git status exited ${status.exitCode}`); + if (status.stdout.trim().length === 0) { + const revParse = await run('git', ['rev-parse', 'HEAD'], { cwd: args.worktreeDir }); + if (revParse.exitCode !== 0) return failed(revParse, `git rev-parse exited ${revParse.exitCode}`); + return { + status: 'no_changes', + message: 'no worktree changes to promote', + commitSha: revParse.stdout.trim(), + sideEffects: [], + }; + } + + const add = await run('git', ['add', '-A'], { cwd: args.worktreeDir }); + if (add.exitCode !== 0) return failed(add, `git add exited ${add.exitCode}`); + + const commit = await run('git', ['commit', '-m', args.message], { cwd: args.worktreeDir }); + if (commit.exitCode !== 0) return failed(commit, `git commit exited ${commit.exitCode}`); + + const revParse = await run('git', ['rev-parse', 'HEAD'], { cwd: args.worktreeDir }); + if (revParse.exitCode !== 0) return failed(revParse, `git rev-parse exited ${revParse.exitCode}`); + + const commitSha = revParse.stdout.trim(); + return { + status: 'promoted', + commitSha, + sideEffects: [{ kind: 'git_commit', path: args.worktreeDir, sha: commitSha }], + }; + }, + }; +} + +function failedHead( + result: { readonly stderr: string; readonly stdout: string; readonly spawnError?: string }, + fallback: string, +) { + return { + status: 'failed' as const, + message: result.stderr.trim() || result.stdout.trim() || result.spawnError || fallback, + }; +} + +function failed( + result: { readonly stderr: string; readonly stdout: string; readonly spawnError?: string }, + fallback: string, +) { + return { + status: 'failed' as const, + message: result.stderr.trim() || result.stdout.trim() || result.spawnError || fallback, + sideEffects: [] as const, + }; +} diff --git a/src/app/pi-extensions.ts b/src/app/pi-extensions.ts index 1a40ac03d..9c9748a7a 100644 --- a/src/app/pi-extensions.ts +++ b/src/app/pi-extensions.ts @@ -81,6 +81,7 @@ import { type PrepareNextTurnResult, } from '../session/prepare-next-turn.js'; import { createAgentRunnerPort } from './agent-runner-port.js'; +import { createGitLandPort } from './git-land-port.js'; import { createGitWorktreePort } from './git-worktree-port.js'; import { createTestRunnerPort } from './test-runner-port.js'; @@ -296,7 +297,7 @@ export function createBrunchPiExtensions( ? createAgentRunnerPort({ subagents: options.subagents }) : createAgentRunnerPort()), testRunner: options.executionPorts?.testRunner ?? createTestRunnerPort(), - ...(options.executionPorts?.gitLand ? { gitLand: options.executionPorts.gitLand } : {}), + gitLand: options.executionPorts?.gitLand ?? createGitLandPort(), }; const extensions: BrunchProductExtensionRegistrar[] = [ (api) => { @@ -323,7 +324,7 @@ export function createBrunchPiExtensions( ...(graph ? [(api: ExtensionAPI) => registerBrunchExecutePlanFile(api, graph)] : []), ...(graph ? [(api: ExtensionAPI) => registerBrunchExecutePlanPreview(api, graph)] : []), registerBrunchExecutePetriExport, - registerBrunchExecutePromotionPrepare, + (api) => registerBrunchExecutePromotionPrepare(api, executionPorts.gitLand), registerBrunchExecutePopulate, registerBrunchExecuteReportInit, registerBrunchExecuteRunComplete, diff --git a/src/executor/TOPOLOGY.md b/src/executor/TOPOLOGY.md index d47fd0f87..ff166d0ca 100644 --- a/src/executor/TOPOLOGY.md +++ b/src/executor/TOPOLOGY.md @@ -14,7 +14,7 @@ executor/ ├── launch.ts spec-scoped plan.yaml -> non-running launch readiness ├── plan-preview.ts executable-plan draft -> old cook-compatible DTO preview ├── petri.ts completed run -> minimal Petrinaut net.json -├── promotion.ts petri-exported run -> descriptive promotion report +├── promotion.ts petri-exported run -> run-local promotion (GitLandPort) + report ├── populate.ts worktree -> plan-only worktree population ├── report.ts source-copied run -> reports.jsonl initialization ├── run-complete.ts completed slices -> run completion marker @@ -26,7 +26,7 @@ executor/ ├── source-policy.ts plan-populated worktree -> source policy selection ├── test-result.ts run worktree -> verify subprocess (TestRunnerPort) -> slice test report ├── worktree.ts run metadata -> real git worktree (GitWorktreePort) -├── execution-ports.ts injected capability ports (git worktree, agent runner, test runner) +├── execution-ports.ts injected capability ports (git worktree, agent runner, test runner, git land) ├── execution-spec-snapshot.ts graph facts -> ExecutionSpecSnapshot v1 ├── executable-plan-draft.ts plan outline -> executable-plan draft DTO ├── executable-plan-draft-artifact.ts executable-plan draft -> .brunch/execution-reports artifact @@ -90,4 +90,4 @@ rules: `petri.ts` writes the first minimal Petrinaut artifact at `.brunch/cook/runs//petrinaut/net.json` for a completed run and records `status:"petri_exported"`. Promotion refs and land branches remain deferred. -`promotion.ts` is the first land/promotion boundary, but it is intentionally descriptive: for a `petri_exported` run it writes a single promotion report at `.brunch/cook/runs//promotion/promotion.json` (runId, specId, petriPath, reportsPath, completedSliceIds) and records `status:"promotion_prepared"`. It creates no git branch, promotion ref, or worktree/topology mutation, and does not land. Actual branch-level land remains the still-pending boundary in `execute_status`. +`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. diff --git a/src/executor/__tests__/fake-ports.ts b/src/executor/__tests__/fake-ports.ts index 0a8f52813..6b29bac50 100644 --- a/src/executor/__tests__/fake-ports.ts +++ b/src/executor/__tests__/fake-ports.ts @@ -1,7 +1,13 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import type { GitWorktreePort, TestRunnerPort, TestRunResult } from '../execution-ports.js'; +import type { + GitLandPort, + GitLandResult, + GitWorktreePort, + TestRunnerPort, + TestRunResult, +} from '../execution-ports.js'; export function createFakeGitWorktreePort( create: GitWorktreePort['create'] = async ({ worktreeDir, ref }) => { @@ -26,3 +32,21 @@ export function createFakeTestRunnerPort( }, }; } + +export function createFakeGitLandPort( + result: GitLandResult = { + status: 'promoted', + commitSha: 'abc123', + sideEffects: [{ kind: 'git_commit', path: '/worktree', sha: 'abc123' }], + }, + currentHeadSha = 'base123', +): GitLandPort { + return { + async currentHead() { + return { status: 'ok', commitSha: currentHeadSha }; + }, + async promote() { + return result; + }, + }; +} diff --git a/src/executor/__tests__/promotion.test.ts b/src/executor/__tests__/promotion.test.ts index 421cf266e..5771bf047 100644 --- a/src/executor/__tests__/promotion.test.ts +++ b/src/executor/__tests__/promotion.test.ts @@ -8,6 +8,7 @@ import { petriNetPath } from '../petri.js'; import { preparePromotion, promotionReportPath } from '../promotion.js'; import { reportsPath } from '../report.js'; import { runDirPath, runMetadataPath } from '../run.js'; +import { createFakeGitLandPort } from './fake-ports.js'; async function pathExists(path: string): Promise { try { @@ -36,6 +37,7 @@ async function createPetriExportedRun(cwd: string): Promise { reportsPath: reportsPath(cwd, 'run-1'), petriPath: petriNetPath(cwd, 'run-1'), completedSliceIds: ['task-1'], + worktreeDir: join(runDir, 'worktree'), }), 'utf8', ); @@ -45,7 +47,7 @@ describe('preparePromotion', () => { it('does not prepare promotion for a missing run', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-promotion-missing-')); - const result = await preparePromotion({ cwd, runId: 'run-1' }); + const result = await preparePromotion({ cwd, runId: 'run-1', gitLand: createFakeGitLandPort() }); expect(result).toEqual({ status: 'missing_run', @@ -66,7 +68,7 @@ describe('preparePromotion', () => { 'utf8', ); - const result = await preparePromotion({ cwd, runId: 'run-1' }); + const result = await preparePromotion({ cwd, runId: 'run-1', gitLand: createFakeGitLandPort() }); expect(result).toEqual({ status: 'run_not_promotable', @@ -82,7 +84,7 @@ describe('preparePromotion', () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-promotion-ready-')); await createPetriExportedRun(cwd); - const result = await preparePromotion({ cwd, runId: 'run-1' }); + const result = await preparePromotion({ cwd, runId: 'run-1', gitLand: createFakeGitLandPort() }); expect(result).toEqual({ status: 'promotion_prepared', @@ -91,6 +93,8 @@ describe('preparePromotion', () => { metadataPath: runMetadataPath(cwd, 'run-1'), promotionPath: promotionReportPath(cwd, 'run-1'), sideEffects: [ + { kind: 'write_file', path: runMetadataPath(cwd, 'run-1'), ifExists: 'overwrite' }, + { kind: 'git_commit', path: '/worktree', sha: 'abc123' }, { kind: 'mkdir', path: join(runDirPath(cwd, 'run-1'), 'promotion') }, { kind: 'write_file', path: promotionReportPath(cwd, 'run-1'), ifExists: 'overwrite' }, { kind: 'write_file', path: runMetadataPath(cwd, 'run-1'), ifExists: 'overwrite' }, @@ -104,19 +108,197 @@ describe('preparePromotion', () => { petriPath: petriNetPath(cwd, 'run-1'), reportsPath: reportsPath(cwd, 'run-1'), completedSliceIds: ['task-1'], + land: { status: 'promoted', commitSha: 'abc123' }, }); - // Descriptive only: no git/branch/ref topology fields. - expect(report).not.toHaveProperty('ref'); expect(report).not.toHaveProperty('branch'); - expect(report).not.toHaveProperty('sha'); expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ status: 'promotion_prepared', promotionPath: promotionReportPath(cwd, 'run-1'), + promotionBaseSha: 'base123', + promotionCommitSha: 'abc123', }); - // No topology mutation: no land branch/worktree/git ref created. - expect(await pathExists(join(runDirPath(cwd, 'run-1'), 'worktree'))).toBe(false); + // No host topology mutation: no host land branch/ref created. expect(await pathExists(join(cwd, '.git'))).toBe(false); }); + + it('does not advance metadata when the land port reports no changes', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-promotion-no-changes-')); + await createPetriExportedRun(cwd); + + const result = await preparePromotion({ + cwd, + runId: 'run-1', + gitLand: createFakeGitLandPort({ + status: 'no_changes', + message: 'nothing to promote', + commitSha: 'base123', + sideEffects: [], + }), + }); + + expect(result).toEqual({ + status: 'promotion_no_changes', + runStatus: 'petri_exported', + runId: 'run-1', + worktreeDir: join(runDirPath(cwd, 'run-1'), 'worktree'), + metadataPath: runMetadataPath(cwd, 'run-1'), + message: 'nothing to promote', + sideEffects: [{ kind: 'write_file', path: runMetadataPath(cwd, 'run-1'), ifExists: 'overwrite' }], + }); + expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ + status: 'petri_exported', + promotionBaseSha: 'base123', + }); + expect(await pathExists(promotionReportPath(cwd, 'run-1'))).toBe(false); + }); + + it('recovers promotion metadata when the report exists but run metadata did not advance', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-promotion-recovery-')); + await createPetriExportedRun(cwd); + await mkdir(join(runDirPath(cwd, 'run-1'), 'promotion'), { recursive: true }); + await writeFile( + promotionReportPath(cwd, 'run-1'), + JSON.stringify({ + runId: 'run-1', + specId: '42', + petriPath: petriNetPath(cwd, 'run-1'), + reportsPath: reportsPath(cwd, 'run-1'), + completedSliceIds: ['task-1'], + land: { status: 'promoted', commitSha: 'abc123' }, + }), + 'utf8', + ); + + const result = await preparePromotion({ + cwd, + runId: 'run-1', + gitLand: createFakeGitLandPort( + { + status: 'no_changes', + message: 'nothing to promote', + sideEffects: [], + }, + 'abc123', + ), + }); + + expect(result).toEqual({ + status: 'promotion_prepared', + runStatus: 'promotion_prepared', + runId: 'run-1', + metadataPath: runMetadataPath(cwd, 'run-1'), + promotionPath: promotionReportPath(cwd, 'run-1'), + sideEffects: [{ kind: 'write_file', path: runMetadataPath(cwd, 'run-1'), ifExists: 'overwrite' }], + }); + expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ + status: 'promotion_prepared', + promotionPath: promotionReportPath(cwd, 'run-1'), + promotionCommitSha: 'abc123', + }); + }); + + it('does not recover a prewritten promotion report whose commit is not current HEAD', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-promotion-stale-report-')); + await createPetriExportedRun(cwd); + await mkdir(join(runDirPath(cwd, 'run-1'), 'promotion'), { recursive: true }); + await writeFile( + promotionReportPath(cwd, 'run-1'), + JSON.stringify({ + runId: 'run-1', + specId: '42', + petriPath: petriNetPath(cwd, 'run-1'), + reportsPath: reportsPath(cwd, 'run-1'), + completedSliceIds: ['task-1'], + land: { status: 'promoted', commitSha: 'stale123' }, + }), + 'utf8', + ); + + const result = await preparePromotion({ + cwd, + runId: 'run-1', + gitLand: createFakeGitLandPort( + { + status: 'failed', + message: 'must not trust stale report', + sideEffects: [], + }, + 'abc123', + ), + }); + + expect(result).toMatchObject({ + status: 'promotion_failed', + runStatus: 'petri_exported', + message: 'must not trust stale report', + }); + expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ + status: 'petri_exported', + }); + expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).not.toMatchObject({ + promotionCommitSha: 'stale123', + }); + }); + + it('recovers promotion metadata when the commit exists but the report was never written', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-promotion-recovery-no-report-')); + await createPetriExportedRun(cwd); + + const result = await preparePromotion({ + cwd, + runId: 'run-1', + gitLand: createFakeGitLandPort({ + status: 'no_changes', + message: 'already promoted', + commitSha: 'abc123', + sideEffects: [], + }), + }); + + expect(result).toEqual({ + status: 'promotion_prepared', + runStatus: 'promotion_prepared', + runId: 'run-1', + metadataPath: runMetadataPath(cwd, 'run-1'), + promotionPath: promotionReportPath(cwd, 'run-1'), + sideEffects: [ + { kind: 'write_file', path: runMetadataPath(cwd, 'run-1'), ifExists: 'overwrite' }, + { kind: 'mkdir', path: join(runDirPath(cwd, 'run-1'), 'promotion') }, + { kind: 'write_file', path: promotionReportPath(cwd, 'run-1'), ifExists: 'overwrite' }, + { kind: 'write_file', path: runMetadataPath(cwd, 'run-1'), ifExists: 'overwrite' }, + ], + }); + expect(JSON.parse(await readFile(promotionReportPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ + land: { status: 'promoted', commitSha: 'abc123' }, + }); + expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ + status: 'promotion_prepared', + promotionBaseSha: 'base123', + promotionCommitSha: 'abc123', + }); + }); + + it('does not advance metadata when the land port fails', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-promotion-failed-')); + await createPetriExportedRun(cwd); + + const result = await preparePromotion({ + cwd, + runId: 'run-1', + gitLand: createFakeGitLandPort({ status: 'failed', message: 'git commit failed', sideEffects: [] }), + }); + + expect(result).toMatchObject({ + status: 'promotion_failed', + runStatus: 'petri_exported', + message: 'git commit failed', + sideEffects: [{ kind: 'write_file', path: runMetadataPath(cwd, 'run-1'), ifExists: 'overwrite' }], + }); + expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ + status: 'petri_exported', + promotionBaseSha: 'base123', + }); + }); }); diff --git a/src/executor/execution-ports.ts b/src/executor/execution-ports.ts index f322e6426..d87ae2baf 100644 --- a/src/executor/execution-ports.ts +++ b/src/executor/execution-ports.ts @@ -75,11 +75,49 @@ export interface TestRunnerPort { run(args: TestRunArgs): Promise; } -export interface GitLandPort {} +export interface GitLandArgs { + readonly worktreeDir: string; + readonly message: string; +} + +export type GitHeadResult = + | { + readonly status: 'ok'; + readonly commitSha: string; + } + | { + readonly status: 'failed'; + readonly message: string; + }; + +export type GitLandResult = + | { + readonly status: 'promoted'; + readonly commitSha: string; + readonly sideEffects: readonly [ + { readonly kind: 'git_commit'; readonly path: string; readonly sha: string }, + ]; + } + | { + readonly status: 'no_changes'; + readonly message: string; + readonly commitSha?: string; + readonly sideEffects: readonly []; + } + | { + readonly status: 'failed'; + readonly message: string; + readonly sideEffects: readonly []; + }; + +export interface GitLandPort { + currentHead(args: { readonly worktreeDir: string }): Promise; + promote(args: GitLandArgs): Promise; +} export interface ExecutionPorts { readonly gitWorktree: GitWorktreePort; readonly agentRunner: AgentRunnerPort; readonly testRunner: TestRunnerPort; - readonly gitLand?: GitLandPort; + readonly gitLand: GitLandPort; } diff --git a/src/executor/promotion.ts b/src/executor/promotion.ts index 8ca15be1f..a6dfc6cab 100644 --- a/src/executor/promotion.ts +++ b/src/executor/promotion.ts @@ -1,8 +1,14 @@ -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import type { GitLandPort } from './execution-ports.js'; import { runDirPath, runMetadataPath, persistRunMetadata, readRunMetadata, type RunMetadata } from './run.js'; +type PromotionSideEffect = + | { readonly kind: 'git_commit'; readonly path: string; readonly sha: string } + | { readonly kind: 'mkdir'; readonly path: string } + | { readonly kind: 'write_file'; readonly path: string; readonly ifExists: 'overwrite' }; + export type PromotionPrepareResult = | { readonly status: 'missing_run'; @@ -18,17 +24,31 @@ export type PromotionPrepareResult = readonly metadataPath: string; readonly sideEffects: readonly []; } + | { + readonly status: 'promotion_failed'; + readonly runStatus: 'petri_exported'; + readonly runId: string; + readonly worktreeDir: string; + readonly metadataPath: string; + readonly message: string; + readonly sideEffects: readonly PromotionSideEffect[]; + } + | { + readonly status: 'promotion_no_changes'; + readonly runStatus: 'petri_exported'; + readonly runId: string; + readonly worktreeDir: string; + readonly metadataPath: string; + readonly message: string; + readonly sideEffects: readonly PromotionSideEffect[]; + } | { readonly status: 'promotion_prepared'; readonly runStatus: 'promotion_prepared'; readonly runId: string; readonly metadataPath: string; readonly promotionPath: string; - readonly sideEffects: readonly [ - { readonly kind: 'mkdir'; readonly path: string }, - { readonly kind: 'write_file'; readonly path: string; readonly ifExists: 'overwrite' }, - { readonly kind: 'write_file'; readonly path: string; readonly ifExists: 'overwrite' }, - ]; + readonly sideEffects: readonly PromotionSideEffect[]; }; export function promotionReportPath(cwd: string, runId: string): string { @@ -38,6 +58,7 @@ export function promotionReportPath(cwd: string, runId: string): string { export async function preparePromotion(args: { readonly cwd: string; readonly runId: string; + readonly gitLand: GitLandPort; }): Promise { const metadataPath = runMetadataPath(args.cwd, args.runId); const metadata = await readRunMetadata(metadataPath); @@ -57,17 +78,99 @@ export async function preparePromotion(args: { metadataPath, sideEffects: [], }; + const recovered = await recoverPreparedPromotion({ + cwd: args.cwd, + gitLand: args.gitLand, + runId: args.runId, + metadata, + metadataPath, + }); + if (recovered) return recovered; + const hasPromotionReport = Boolean(await readPromotionReport(promotionReportPath(args.cwd, args.runId))); + + const worktreeDir = metadata.worktreeDir; + if (!worktreeDir) { + return { + status: 'promotion_failed', + runStatus: 'petri_exported', + runId: args.runId, + worktreeDir: worktreeDir ?? worktreePathFallback(args.cwd, args.runId), + metadataPath, + message: 'run is missing worktreeDir', + sideEffects: [], + }; + } + + const preparedAttempt = await preparePromotionAttempt({ + gitLand: args.gitLand, + metadata, + metadataPath, + worktreeDir, + persistBaseSha: !hasPromotionReport, + }); + if (preparedAttempt.status === 'failed') { + return { + status: 'promotion_failed', + runStatus: 'petri_exported', + runId: args.runId, + worktreeDir, + metadataPath, + message: preparedAttempt.message, + sideEffects: [], + }; + } + const promotionMetadata = preparedAttempt.metadata; + + const land = await args.gitLand.promote({ worktreeDir, message: `promote ${args.runId}` }); + if (land.status === 'failed') { + return { + status: 'promotion_failed', + runStatus: 'petri_exported', + runId: args.runId, + worktreeDir, + metadataPath, + message: land.message, + sideEffects: preparedAttempt.sideEffects, + }; + } + if (land.status === 'no_changes') { + if (land.commitSha && land.commitSha !== promotionMetadata.promotionBaseSha) { + const path = promotionReportPath(args.cwd, args.runId); + const dir = dirname(path); + const report = promotionReport(args.runId, promotionMetadata, land.commitSha); + const updated = promotedRunMetadata(promotionMetadata, path, land.commitSha); + await mkdir(dir, { recursive: true }); + await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + const metadataEffect = await persistRunMetadata(metadataPath, updated); + return { + status: 'promotion_prepared', + runStatus: 'promotion_prepared', + runId: args.runId, + metadataPath, + promotionPath: path, + sideEffects: [ + ...preparedAttempt.sideEffects, + { kind: 'mkdir', path: dir }, + { kind: 'write_file', path, ifExists: 'overwrite' }, + metadataEffect, + ], + }; + } + return { + status: 'promotion_no_changes', + runStatus: 'petri_exported', + runId: args.runId, + worktreeDir, + metadataPath, + message: land.message, + sideEffects: preparedAttempt.sideEffects, + }; + } const path = promotionReportPath(args.cwd, args.runId); const dir = dirname(path); - const report = { - runId: args.runId, - specId: metadata.specId, - petriPath: metadata.petriPath ?? null, - reportsPath: metadata.reportsPath ?? null, - completedSliceIds: metadata.completedSliceIds ?? [], - }; - const updated: RunMetadata = { ...metadata, status: 'promotion_prepared', promotionPath: path }; + const report = promotionReport(args.runId, promotionMetadata, land.commitSha); + const updated = promotedRunMetadata(promotionMetadata, path, land.commitSha); await mkdir(dir, { recursive: true }); await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); const metadataEffect = await persistRunMetadata(metadataPath, updated); @@ -78,9 +181,118 @@ export async function preparePromotion(args: { metadataPath, promotionPath: path, sideEffects: [ + ...preparedAttempt.sideEffects, + ...land.sideEffects, { kind: 'mkdir', path: dir }, { kind: 'write_file', path, ifExists: 'overwrite' }, metadataEffect, ], }; } + +type PromotionAttemptPrepareResult = + | { + readonly status: 'prepared'; + readonly metadata: RunMetadata; + readonly sideEffects: readonly []; + } + | { + readonly status: 'prepared'; + readonly metadata: RunMetadata; + readonly sideEffects: readonly [ + { readonly kind: 'write_file'; readonly path: string; readonly ifExists: 'overwrite' }, + ]; + } + | { + readonly status: 'failed'; + readonly message: string; + }; + +async function preparePromotionAttempt(args: { + readonly gitLand: GitLandPort; + readonly metadata: RunMetadata; + readonly metadataPath: string; + readonly worktreeDir: string; + readonly persistBaseSha: boolean; +}): Promise { + if (args.metadata.promotionBaseSha) { + return { status: 'prepared', metadata: args.metadata, sideEffects: [] }; + } + if (!args.persistBaseSha) { + return { status: 'prepared', metadata: args.metadata, sideEffects: [] }; + } + const head = await args.gitLand.currentHead({ worktreeDir: args.worktreeDir }); + if (head.status === 'failed') return { status: 'failed', message: head.message }; + const metadata = { ...args.metadata, promotionBaseSha: head.commitSha }; + const metadataEffect = await persistRunMetadata(args.metadataPath, metadata); + return { status: 'prepared', metadata, sideEffects: [metadataEffect] }; +} + +function promotionReport(runId: string, metadata: RunMetadata, commitSha: string): object { + return { + runId, + specId: metadata.specId, + petriPath: metadata.petriPath ?? null, + reportsPath: metadata.reportsPath ?? null, + completedSliceIds: metadata.completedSliceIds ?? [], + land: { status: 'promoted', commitSha }, + }; +} + +function promotedRunMetadata(metadata: RunMetadata, promotionPath: string, commitSha: string): RunMetadata { + return { + ...metadata, + status: 'promotion_prepared', + promotionPath, + promotionCommitSha: commitSha, + }; +} + +async function recoverPreparedPromotion(args: { + readonly cwd: string; + readonly gitLand: GitLandPort; + readonly runId: string; + readonly metadata: RunMetadata; + readonly metadataPath: string; +}): Promise { + const path = promotionReportPath(args.cwd, args.runId); + const report = await readPromotionReport(path); + const commitSha = report?.land?.status === 'promoted' ? report.land.commitSha : undefined; + if (!commitSha) return undefined; + if (!args.metadata.worktreeDir) return undefined; + + const head = await args.gitLand.currentHead({ worktreeDir: args.metadata.worktreeDir }); + if (head.status !== 'ok' || head.commitSha !== commitSha) return undefined; + + const updated: RunMetadata = { + ...args.metadata, + status: 'promotion_prepared', + promotionPath: path, + promotionCommitSha: commitSha, + }; + const metadataEffect = await persistRunMetadata(args.metadataPath, updated); + return { + status: 'promotion_prepared', + runStatus: 'promotion_prepared', + runId: args.runId, + metadataPath: args.metadataPath, + promotionPath: path, + sideEffects: [metadataEffect], + }; +} + +interface PromotionReportPayload { + readonly land?: { readonly status?: string; readonly commitSha?: string }; +} + +async function readPromotionReport(path: string): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as PromotionReportPayload; + } catch { + return undefined; + } +} + +function worktreePathFallback(cwd: string, runId: string): string { + return join(runDirPath(cwd, runId), 'worktree'); +} diff --git a/src/executor/run.ts b/src/executor/run.ts index 5eb49918d..c5190a1b3 100644 --- a/src/executor/run.ts +++ b/src/executor/run.ts @@ -37,6 +37,8 @@ export interface RunMetadata { readonly completedSliceIds?: readonly string[]; readonly petriPath?: string; readonly promotionPath?: string; + readonly promotionBaseSha?: string; + readonly promotionCommitSha?: string; } export type RunCreateResult =