From f3016c57178127385fa04a48024dba9fc9548494 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 10:44:05 +0200 Subject: [PATCH 01/12] FE-1112: Scope executor promotion --- memory/PLAN.md | 5 +- ...utor-promotion--run-local-git-land-port.md | 65 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 memory/cards/executor-promotion--run-local-git-land-port.md diff --git a/memory/PLAN.md b/memory/PLAN.md index 097e5493..dbff5499 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -172,9 +172,10 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - **Name:** Reconcile executor promotion - **Linear:** [FE-1112](https://linear.app/hash/issue/FE-1112/reconcile-executor-promotion) — reconcile executor promotion -- **Branch:** tbd (stacked on `executor-agent-runner`) +- **Branch:** `ka/fe-1112-executor-promotion` (stacked on `ka/fe-1111-executor-agent-runner`) - **Kind:** structural / execute-mode runner substrate (`orchestrator-cutover` arc) -- **Status:** last; the only externally-visible, hard-to-reverse seam. +- **Status:** active; first slice scoped. +- **Current execution pointer:** `memory/cards/executor-promotion--run-local-git-land-port.md` — implement the run-local `GitLandPort` contract and keep host promotion deferred. - **Certainty:** proving. - **Why now / unlocks:** only once a run produces real, verified diffs does a truthful land have a source (D99-L land-substrate finding). This layer lands last so the hard-to-reverse git mutation is the final, independently-reviewable step. - **Objective:** Implement and inject `GitLandPort` so a run's real diffs are promoted — run-local promotion first, host promotion later — consuming/validating the Petri + promotion artifacts rather than re-deriving run state. 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 00000000..1c2fee6a --- /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`. From c0116487a35ecf533e36c44eb540185205e475a5 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 11:45:24 +0200 Subject: [PATCH 02/12] FE-1112: Add run-local GitLandPort promotion --- memory/PLAN.md | 10 +-- src/.pi/extensions/__tests__/registry.test.ts | 57 ++++++++++++++- .../execute-promotion-prepare/index.ts | 14 ++-- src/app/TOPOLOGY.md | 3 +- src/app/__tests__/git-land-port.test.ts | 73 +++++++++++++++++++ src/app/git-land-port.ts | 42 +++++++++++ src/app/pi-extensions.ts | 5 +- src/executor/TOPOLOGY.md | 6 +- src/executor/__tests__/fake-ports.ts | 22 +++++- src/executor/__tests__/promotion.test.ts | 67 +++++++++++++++-- src/executor/execution-ports.ts | 30 +++++++- src/executor/promotion.ts | 70 +++++++++++++++++- src/executor/run.ts | 1 + 13 files changed, 369 insertions(+), 31 deletions(-) create mode 100644 src/app/__tests__/git-land-port.test.ts create mode 100644 src/app/git-land-port.ts diff --git a/memory/PLAN.md b/memory/PLAN.md index dbff5499..c00fde0f 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -174,14 +174,14 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - **Linear:** [FE-1112](https://linear.app/hash/issue/FE-1112/reconcile-executor-promotion) — reconcile executor promotion - **Branch:** `ka/fe-1112-executor-promotion` (stacked on `ka/fe-1111-executor-agent-runner`) - **Kind:** structural / execute-mode runner substrate (`orchestrator-cutover` arc) -- **Status:** active; first slice scoped. -- **Current execution pointer:** `memory/cards/executor-promotion--run-local-git-land-port.md` — implement the run-local `GitLandPort` contract and keep host promotion deferred. +- **Status:** active; run-local GitLandPort slice built. +- **Current execution pointer:** run-local `GitLandPort` built (`memory/cards/executor-promotion--run-local-git-land-port.md`); next scope should decide whether run-local promotion is enough to drop `execute_status.pendingTools` or whether a host-promotion slice is required first. - **Certainty:** proving. - **Why now / unlocks:** only once a run produces real, verified diffs does a truthful land have a source (D99-L land-substrate finding). This layer lands last so the hard-to-reverse git mutation is the final, independently-reviewable step. - **Objective:** Implement and inject `GitLandPort` so a run's real diffs are promoted — run-local promotion first, host promotion later — consuming/validating the Petri + promotion artifacts rather than re-deriving run state. - **Acceptance (to refine via `ln-scope`):** - - `GitLandPort` implementation (app layer) performs a run-local promotion of the verified worktree diffs first; host promotion is a later, explicitly-accepted slice. - - The land path consumes/validates the existing Petri + `promotion.json` artifacts rather than re-deriving run state. + - Done: `GitLandPort` implementation (app layer) performs a run-local promotion of verified worktree diffs first; host promotion is a later, explicitly-accepted slice. + - Done: the promotion path consumes existing Petri/run metadata and writes `promotion.json` with the run-local commit SHA rather than re-deriving run state. - `execute_status` `pendingTools` drops `land` once a real (at minimum run-local, real-git) land exists. - **Traceability:** D39-L, D40-L, D52-L, D98-L, D99-L (land-substrate finding) / I49-L, I52-L; depends on `executor-agent-runner`; `src/executor/TOPOLOGY.md`. @@ -248,7 +248,7 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr frontiers: Active: executor-promotion (FE-1112, orchestrator-cutover arc) - status: ready to scope + status: active; run-local GitLandPort slice built depends_on: executor-agent-runner, D99-L land-substrate finding, D52-L, I52-L ports: GitLandPort stacks_on: ka/fe-1111-executor-agent-runner diff --git a/src/.pi/extensions/__tests__/registry.test.ts b/src/.pi/extensions/__tests__/registry.test.ts index 46e20867..1b3177f3 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'; @@ -1265,6 +1267,57 @@ 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: '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; @@ -1827,6 +1880,7 @@ async function collectProductTools( gitWorktree?: GitWorktreePort; testRunner?: TestRunnerPort; agentRunner?: AgentRunnerPort; + gitLand?: GitLandPort; subagents?: BrunchSubagentsDeps; } = {}, ): Promise { @@ -1835,12 +1889,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 2df713f0..7537675a 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/app/TOPOLOGY.md b/src/app/TOPOLOGY.md index 3db059d0..6115c9a7 100644 --- a/src/app/TOPOLOGY.md +++ b/src/app/TOPOLOGY.md @@ -21,7 +21,8 @@ Current runtime support modules: - `pi-session-options.ts` — internal Brunch-to-Pi session option projection for lifecycle forwarding, tool hardening, thinking preset, and optional concrete model override. -- `git-worktree-port.ts`, `agent-runner-port.ts`, `test-runner-port.ts` — +- `git-worktree-port.ts`, `agent-runner-port.ts`, `test-runner-port.ts`, + `git-land-port.ts` — app-layer execution-port implementations injected into executor Pi tools; executor core owns the port contracts and state transitions, while app owns concrete external capability implementations. `agent-runner-port.ts` bridges diff --git a/src/app/__tests__/git-land-port.test.ts b/src/app/__tests__/git-land-port.test.ts new file mode 100644 index 00000000..176f4b17 --- /dev/null +++ b/src/app/__tests__/git-land-port.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { createGitLandPort } from '../git-land-port.js'; + +describe('createGitLandPort', () => { + 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 without staging or committing when the worktree is clean', async () => { + const calls: string[] = []; + const port = createGitLandPort({ + run: async (_command, args) => { + calls.push(args.join(' ')); + 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', + sideEffects: [], + }); + expect(calls).toEqual(['status --porcelain']); + }); + + 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 00000000..0a8f6384 --- /dev/null +++ b/src/app/git-land-port.ts @@ -0,0 +1,42 @@ +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 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) { + return { status: 'no_changes', message: 'no worktree changes to promote', 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 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 ef510a4a..e2d50c91 100644 --- a/src/app/pi-extensions.ts +++ b/src/app/pi-extensions.ts @@ -88,6 +88,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'; @@ -308,7 +309,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 commandGapReads = options.getElicitationGaps ?? @@ -338,7 +339,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 d47fd0f8..ff166d0c 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 0a8f5281..398e24b3 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,17 @@ export function createFakeTestRunnerPort( }, }; } + +export function createFakeGitLandPort( + result: GitLandResult = { + status: 'promoted', + commitSha: 'abc123', + sideEffects: [{ kind: 'git_commit', path: '/worktree', sha: 'abc123' }], + }, +): GitLandPort { + return { + async promote() { + return result; + }, + }; +} diff --git a/src/executor/__tests__/promotion.test.ts b/src/executor/__tests__/promotion.test.ts index 421cf266..988c9314 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,7 @@ describe('preparePromotion', () => { metadataPath: runMetadataPath(cwd, 'run-1'), promotionPath: promotionReportPath(cwd, 'run-1'), sideEffects: [ + { 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 +107,67 @@ 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'), + 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', + 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: [], + }); + expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ + status: 'petri_exported', + }); + expect(await pathExists(promotionReportPath(cwd, 'run-1'))).toBe(false); + }); + + 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: [], + }); + expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ + status: 'petri_exported', + }); + }); }); diff --git a/src/executor/execution-ports.ts b/src/executor/execution-ports.ts index f322e642..fac8db5b 100644 --- a/src/executor/execution-ports.ts +++ b/src/executor/execution-ports.ts @@ -75,11 +75,37 @@ export interface TestRunnerPort { run(args: TestRunArgs): Promise; } -export interface GitLandPort {} +export interface GitLandArgs { + readonly worktreeDir: string; + 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 sideEffects: readonly []; + } + | { + readonly status: 'failed'; + readonly message: string; + readonly sideEffects: readonly []; + }; + +export interface GitLandPort { + 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 8ca15be1..bf4db09c 100644 --- a/src/executor/promotion.ts +++ b/src/executor/promotion.ts @@ -1,6 +1,7 @@ import { mkdir, 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'; export type PromotionPrepareResult = @@ -18,6 +19,24 @@ 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 []; + } + | { + readonly status: 'promotion_no_changes'; + readonly runStatus: 'petri_exported'; + readonly runId: string; + readonly worktreeDir: string; + readonly metadataPath: string; + readonly message: string; + readonly sideEffects: readonly []; + } | { readonly status: 'promotion_prepared'; readonly runStatus: 'promotion_prepared'; @@ -25,6 +44,7 @@ export type PromotionPrepareResult = readonly metadataPath: string; readonly promotionPath: string; readonly sideEffects: readonly [ + { 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' }, { readonly kind: 'write_file'; readonly path: string; readonly ifExists: 'overwrite' }, @@ -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,6 +78,42 @@ export async function preparePromotion(args: { metadataPath, sideEffects: [], }; + 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 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: [], + }; + } + if (land.status === 'no_changes') { + return { + status: 'promotion_no_changes', + runStatus: 'petri_exported', + runId: args.runId, + worktreeDir, + metadataPath, + message: land.message, + sideEffects: [], + }; + } const path = promotionReportPath(args.cwd, args.runId); const dir = dirname(path); @@ -66,8 +123,14 @@ export async function preparePromotion(args: { petriPath: metadata.petriPath ?? null, reportsPath: metadata.reportsPath ?? null, completedSliceIds: metadata.completedSliceIds ?? [], + land: { status: 'promoted', commitSha: land.commitSha }, + }; + const updated: RunMetadata = { + ...metadata, + status: 'promotion_prepared', + promotionPath: path, + promotionCommitSha: land.commitSha, }; - const updated: RunMetadata = { ...metadata, status: 'promotion_prepared', promotionPath: path }; await mkdir(dir, { recursive: true }); await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); const metadataEffect = await persistRunMetadata(metadataPath, updated); @@ -78,9 +141,14 @@ export async function preparePromotion(args: { metadataPath, promotionPath: path, sideEffects: [ + ...land.sideEffects, { kind: 'mkdir', path: dir }, { kind: 'write_file', path, ifExists: 'overwrite' }, metadataEffect, ], }; } + +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 5eb49918..a75a7f88 100644 --- a/src/executor/run.ts +++ b/src/executor/run.ts @@ -37,6 +37,7 @@ export interface RunMetadata { readonly completedSliceIds?: readonly string[]; readonly petriPath?: string; readonly promotionPath?: string; + readonly promotionCommitSha?: string; } export type RunCreateResult = From 08ec5ec476faed90e48fc08c1f81a56311d34f75 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 12:00:08 +0200 Subject: [PATCH 03/12] FE-1112: Clear executor promotion pending status --- memory/PLAN.md | 8 ++--- memory/SPEC.md | 4 ++- src/.pi/extensions/__tests__/registry.test.ts | 10 +++---- .../agent-runtime/execute-status/index.ts | 30 +++---------------- 4 files changed, 15 insertions(+), 37 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index c00fde0f..4ab59637 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -174,15 +174,15 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - **Linear:** [FE-1112](https://linear.app/hash/issue/FE-1112/reconcile-executor-promotion) — reconcile executor promotion - **Branch:** `ka/fe-1112-executor-promotion` (stacked on `ka/fe-1111-executor-agent-runner`) - **Kind:** structural / execute-mode runner substrate (`orchestrator-cutover` arc) -- **Status:** active; run-local GitLandPort slice built. -- **Current execution pointer:** run-local `GitLandPort` built (`memory/cards/executor-promotion--run-local-git-land-port.md`); next scope should decide whether run-local promotion is enough to drop `execute_status.pendingTools` or whether a host-promotion slice is required first. +- **Status:** active; run-local GitLandPort slice built and status updated. +- **Current execution pointer:** run-local `GitLandPort` built (`memory/cards/executor-promotion--run-local-git-land-port.md`); next scope should decide whether host promotion is needed before closing the frontier or should remain deferred after this arc. - **Certainty:** proving. - **Why now / unlocks:** only once a run produces real, verified diffs does a truthful land have a source (D99-L land-substrate finding). This layer lands last so the hard-to-reverse git mutation is the final, independently-reviewable step. - **Objective:** Implement and inject `GitLandPort` so a run's real diffs are promoted — run-local promotion first, host promotion later — consuming/validating the Petri + promotion artifacts rather than re-deriving run state. - **Acceptance (to refine via `ln-scope`):** - Done: `GitLandPort` implementation (app layer) performs a run-local promotion of verified worktree diffs first; host promotion is a later, explicitly-accepted slice. - Done: the promotion path consumes existing Petri/run metadata and writes `promotion.json` with the run-local commit SHA rather than re-deriving run state. - - `execute_status` `pendingTools` drops `land` once a real (at minimum run-local, real-git) land exists. + - Done: `execute_status` `pendingTools` drops `land` because real run-local git promotion exists; host promotion remains explicitly deferred. - **Traceability:** D39-L, D40-L, D52-L, D98-L, D99-L (land-substrate finding) / I49-L, I52-L; depends on `executor-agent-runner`; `src/executor/TOPOLOGY.md`. ### elicitor-project @@ -248,7 +248,7 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr frontiers: Active: executor-promotion (FE-1112, orchestrator-cutover arc) - status: active; run-local GitLandPort slice built + status: active; run-local GitLandPort slice built and status updated depends_on: executor-agent-runner, D99-L land-substrate finding, D52-L, I52-L ports: GitLandPort stacks_on: ka/fe-1111-executor-agent-runner diff --git a/memory/SPEC.md b/memory/SPEC.md index 90264bf7..b2c6684b 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -269,6 +269,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c > D101-L AgentRunnerPort implementation refinement (2026-07-01, FE-1111): `execute_agent_result` now runs an injected `AgentRunnerPort` for the active requested slice rather than reading a prewritten `agent-output//result.json`. The executor core (`src/executor/agent-result.ts`) owns only the port type, active run/slice/worktree/request/result paths, Pi model-runtime handoff, the `slice_agent_result` report append, and the metadata transition; the app layer (`src/app/agent-runner-port.ts`) launches the registry-owned sealed `worker` subagent when subagent deps are injected, otherwise failing closed. The worker is a code-owned background definition (`src/agents/subagents/worker.md`) with bounded `read` + `write_worktree_file` authority over the sandbox worktree, no shell, no graph mutation, and no subagent nesting. Runner failure returns `status:"agent_run_failed"` and leaves metadata unchanged (no side effects). The prewritten-file `missing_agent_result` path is removed from this layer; focused tests and the portable faux-provider witness probe prove the contract and a deterministic worker file change, while real-provider/manual worker evidence remains frontier work. +> D101-L GitLandPort run-local promotion refinement (2026-07-01, FE-1112): `execute_promotion_prepare` now runs an injected `GitLandPort` before writing `promotion.json`. The executor core (`src/executor/promotion.ts`) owns only the port type, existing run/Petri/report metadata, promotion report append, and metadata transition; the app layer (`src/app/git-land-port.ts`) owns run-local git operations in the run worktree (`git status --porcelain`, `git add -A`, `git commit`, `git rev-parse HEAD`). This first promotion layer commits verified sandbox worktree diffs locally and records the promoted commit SHA in `promotion.json` / `run.json`; host branch/ref promotion remains deferred. `no_changes` and port failures do not advance run metadata and report no side effects. Because real run-local git promotion exists, `execute_status.pendingTools` is empty; host promotion is explicitly deferred rather than a remaining pending execute foothold. + | # | Invariant | Status / oracle anchor | Decision anchor | @@ -328,7 +330,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I53-L | `session.submitExchangeResponse` review-set approval validates the rehydrated pending `reviewSet` against the canonical `zReviewSetDetailsPayload` schema (owned by `src/.pi/extensions/exchanges/schemas/present.ts`) before constructing a `ReviewSetProposalPayload`; malformed persisted details surface as `structural_illegal` diagnostics rather than an unsafe-cast runtime throw. | covered (`src/rpc/__tests__/handlers.test.ts` malformed-pending-review-set and valid-approval RPC tests) | D27-L; I15-L, I20-L | | I54-L | Every id in `LIVE_BRUNCH_SKILL_IDS` (`src/agents/skills/registry.ts`) has a packaged `dist/agents/skills//SKILL.md` after `npm run build`; `scripts/copy-skill-resources.mjs` derives the copy/cleanup list from the compiled registry rather than a second hardcoded id list, so a published install cannot silently miss a live skill's runtime-loaded resource or retain a retired one. | covered (`src/agents/skills/__tests__/registry.test.ts` source-file check always runs; dist-file check runs whenever `dist/` is present, i.e. after `npm run build`) | D39-L, D52-L, D95-L, D100-L | | I55-L | No committed `.fixtures/runs/**` promoted-evidence file contains an absolute developer-workstation path (`/Users//…`, `/home//…`); leaked cwd/prompt-resource/tool-call paths are normalized to a portable placeholder (``, ``, ``, ``) that preserves replay/review value without machine-specific roots. `.fixtures/seeds/**` is out of scope — curated source-domain input, not run evidence. | covered (`npm run check:promoted-run-paths` enumerates via `git ls-files .fixtures/runs`, wired into `npm run check`) | requirement 24 | -| I56-L | Execute-mode orchestration footholds remain bounded until the runner frontier explicitly accepts host git mutation. Active CODE-mode tools are read-only and report `sideEffects: []`; writer/lifecycle scaffold tools remain registered/testable but inactive until the real-execution stack lands. Those inactive tools write only declared files under `.brunch/execution-reports` or `.brunch/cook` when invoked directly by tests; `execute_worktree_create` uses injected `GitWorktreePort` for the real `git_worktree_add`, `execute_agent_result` uses injected `AgentRunnerPort` for the active slice's worktree/request/result paths and sealed worker launch, `execute_test_result` uses injected `TestRunnerPort` for the real verify subprocess in the run worktree, and the remaining lifecycle tools update run metadata, markers, reports, Petri export, or promotion-preparation artifacts without mutating the graph, creating host git branches, promoting, or landing. The worker may write only through bounded worktree tools (`write_worktree_file` in this tracer). | covered (`src/.pi/extensions/__tests__/agent-runtime-runtime.test.ts` proves lifecycle tools are inactive in execute mode; `src/.pi/extensions/__tests__/registry.test.ts` and `src/executor/__tests__/*` cover tool registration, side-effect details, bounded paths, port injection, run metadata transitions, and path traversal guards; `src/.pi/extensions/__tests__/subagents.test.ts` proves the worker registry/grant boundary; `src/app/__tests__/agent-runner-port.test.ts`, `src/app/__tests__/git-worktree-port.test.ts`, and `src/app/__tests__/test-runner-port.test.ts` cover app-layer runner/git/verify contracts) | D39-L, D40-L, D52-L, D58-L, D90-L, D91-L, D92-L, D93-L, D98-L, D101-L | +| I56-L | Execute-mode orchestration footholds remain bounded until the runner frontier explicitly accepts host git mutation. Active CODE-mode tools are read-only and report `sideEffects: []`; writer/lifecycle scaffold tools remain registered/testable but inactive until the real-execution stack lands. Those inactive tools write only declared files under `.brunch/execution-reports` or `.brunch/cook` when invoked directly by tests; `execute_worktree_create` uses injected `GitWorktreePort` for the real `git_worktree_add`, `execute_agent_result` uses injected `AgentRunnerPort` for the active slice's worktree/request/result paths and sealed worker launch, `execute_test_result` uses injected `TestRunnerPort` for the real verify subprocess in the run worktree, `execute_promotion_prepare` uses injected `GitLandPort` for run-local worktree promotion, and the remaining lifecycle tools update run metadata, markers, reports, Petri export, or promotion artifacts without mutating the graph, creating host git branches, or promoting to the host. The worker may write only through bounded worktree tools (`write_worktree_file` in this tracer). | covered (`src/.pi/extensions/__tests__/agent-runtime-runtime.test.ts` proves lifecycle tools are inactive in execute mode; `src/.pi/extensions/__tests__/registry.test.ts` and `src/executor/__tests__/*` cover tool registration, side-effect details, bounded paths, port injection, run metadata transitions, and path traversal guards; `src/.pi/extensions/__tests__/subagents.test.ts` proves the worker registry/grant boundary; `src/app/__tests__/agent-runner-port.test.ts`, `src/app/__tests__/git-land-port.test.ts`, `src/app/__tests__/git-worktree-port.test.ts`, and `src/app/__tests__/test-runner-port.test.ts` cover app-layer runner/git/verify contracts) | D39-L, D40-L, D52-L, D58-L, D90-L, D91-L, D92-L, D93-L, D98-L, D101-L | ## Future Direction Register diff --git a/src/.pi/extensions/__tests__/registry.test.ts b/src/.pi/extensions/__tests__/registry.test.ts index 1b3177f3..a85238a6 100644 --- a/src/.pi/extensions/__tests__/registry.test.ts +++ b/src/.pi/extensions/__tests__/registry.test.ts @@ -1576,7 +1576,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); @@ -1587,10 +1587,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', @@ -1603,8 +1602,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: [], }); }); diff --git a/src/.pi/extensions/agent-runtime/execute-status/index.ts b/src/.pi/extensions/agent-runtime/execute-status/index.ts index b39c2999..ac9723ed 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 Date: Wed, 1 Jul 2026 18:41:33 +0200 Subject: [PATCH 04/12] FE-1112: Mark executor promotion complete --- memory/PLAN.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 4ab59637..8c25a76c 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -56,7 +56,6 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr ### Active -- `executor-promotion` (FE-1112, `orchestrator-cutover` arc) — **ready to scope.** Last real-execution frontier: inject `GitLandPort` so verified sandbox worktree diffs get promoted, run-local first. Stacks on `ka/fe-1111-executor-agent-runner`. - `elicitation-gap-guidance` — **proving frontier.** Generate "what next?" gap guidance from graph shape/readiness, distinct from ranking already-registered gaps. ### Recently Completed @@ -64,6 +63,7 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - 2026-07-01 `portable-resource-paths--manifest-location` (bugfix, `ln-induct` from PR #273) — skill manifest `location` is now the loader-resolved absolute `Skill.filePath` instead of a hardcoded repo-relative string, so it resolves under any process cwd or `dist/`-only install; dead `liveBrunchSkillRepoPath`/`bundledAgentBodyRepoPath` builders removed; the two prompt-composition goldens normalize the machine root to a `/…` token. See SPEC §Acknowledged Blind Spots "Live-vs-harness wiring divergence". - 2026-07-01 `promoted-run-path-normalization` (tooling) — `.fixtures/runs/**` no longer leaks developer-workstation absolute paths; `npm run check:promoted-run-paths` guards committed evidence going forward. `.fixtures/seeds/**` is untouched (separate seed-curation concern). Not a `fixture-vs-real-audit` sweep. - 2026-06-30 `orchestrator-alpha-cutover` (FE-1089) — **descriptive cutover scaffold done** (arc member of `orchestrator-cutover`). Landed the `ExecutionSpecSnapshot` projection seam plus the full `fs`-only cook lifecycle simulation through `execute_promotion_prepare`, establishing the thin-Pi-adapter / one-explicit-side-effect-per-tool pattern with zero real execution. Scoping real land surfaced the D101-L land-substrate finding (copied-dir worktree, prewritten-ingested results, no git in core), so real execution + land were reordered into the `executor-sandbox` → `executor-agent-runner` → `executor-land` frontiers. +- 2026-07-01 `executor-promotion` (FE-1112) — **run-local promotion built** (arc member of `orchestrator-cutover`). Added injected `GitLandPort`, app-layer run-local git commit promotion, promotion report commit SHA recording, failure/no-change non-advancement, and `execute_status.pendingTools: []`. Host branch promotion remains explicitly deferred beyond this frontier. - 2026-07-01 `executor-agent-runner` (FE-1111) — **sealed worker runner built** (arc member of `orchestrator-cutover`). Replaced prewritten `execute_agent_result` ingest with injected `AgentRunnerPort`; added sealed `worker` subagent with bounded `read` + `write_worktree_file` authority; proved default app composition and portable faux-provider witness (`executor-agent-runner-witness`) for worker tool use and sandbox worktree writes. Real-provider content-quality evidence is not a frontier blocker; richer write/shell authority, if needed, should be scoped separately before or after promotion. - 2026-07-01 `executor-sandbox` (FE-1109) — **real runnable sandbox built** (arc member of `orchestrator-cutover`). Landed `GitWorktreePort` for real per-run git worktrees and `TestRunnerPort` for real verify-subprocess ingestion, completing the no-LLM substrate needed by `executor-agent-runner`. - 2026-06-30 `structured-exchange-affordance` (FE-1108) — exchange authoring guidance now teaches present-side response rules and review-set nested companions at the boundary; one unearned exchange projection adapter was inlined into its RPC consumer, and topology inventories name the retained model-facing/projection homes. @@ -174,15 +174,15 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - **Linear:** [FE-1112](https://linear.app/hash/issue/FE-1112/reconcile-executor-promotion) — reconcile executor promotion - **Branch:** `ka/fe-1112-executor-promotion` (stacked on `ka/fe-1111-executor-agent-runner`) - **Kind:** structural / execute-mode runner substrate (`orchestrator-cutover` arc) -- **Status:** active; run-local GitLandPort slice built and status updated. -- **Current execution pointer:** run-local `GitLandPort` built (`memory/cards/executor-promotion--run-local-git-land-port.md`); next scope should decide whether host promotion is needed before closing the frontier or should remain deferred after this arc. +- **Status:** done. +- **Current execution pointer:** none — frontier complete. Run-local `GitLandPort` built (`memory/cards/executor-promotion--run-local-git-land-port.md`); host promotion remains explicitly deferred beyond this frontier. - **Certainty:** proving. - **Why now / unlocks:** only once a run produces real, verified diffs does a truthful land have a source (D99-L land-substrate finding). This layer lands last so the hard-to-reverse git mutation is the final, independently-reviewable step. - **Objective:** Implement and inject `GitLandPort` so a run's real diffs are promoted — run-local promotion first, host promotion later — consuming/validating the Petri + promotion artifacts rather than re-deriving run state. - **Acceptance (to refine via `ln-scope`):** - Done: `GitLandPort` implementation (app layer) performs a run-local promotion of verified worktree diffs first; host promotion is a later, explicitly-accepted slice. - Done: the promotion path consumes existing Petri/run metadata and writes `promotion.json` with the run-local commit SHA rather than re-deriving run state. - - Done: `execute_status` `pendingTools` drops `land` because real run-local git promotion exists; host promotion remains explicitly deferred. + - Done: `execute_status` `pendingTools` drops `land` because real run-local git promotion exists; host promotion remains explicitly deferred beyond this frontier. - **Traceability:** D39-L, D40-L, D52-L, D98-L, D99-L (land-substrate finding) / I49-L, I52-L; depends on `executor-agent-runner`; `src/executor/TOPOLOGY.md`. ### elicitor-project @@ -247,12 +247,6 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr ```text frontiers: Active: - executor-promotion (FE-1112, orchestrator-cutover arc) - status: active; run-local GitLandPort slice built and status updated - depends_on: executor-agent-runner, D99-L land-substrate finding, D52-L, I52-L - ports: GitLandPort - stacks_on: ka/fe-1111-executor-agent-runner - 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 @@ -270,7 +264,7 @@ frontiers: depends_on: readiness bands, data-model legibility, elicitor-generate, and a stable exchange affordance surface for asking/proposal loops Recently Completed: - executor-agent-runner (FE-1111), executor-sandbox (FE-1109), orchestrator-alpha-cutover (FE-1089), structured-exchange-affordance, elicitor-project, spec-structural-relief, renderer-golden-coverage, data-model-legibility + executor-promotion (FE-1112), executor-agent-runner (FE-1111), executor-sandbox (FE-1109), orchestrator-alpha-cutover (FE-1089), structured-exchange-affordance, elicitor-project, spec-structural-relief, renderer-golden-coverage, data-model-legibility Next: executor-land (orchestrator-cutover arc) From 5fd35dcb7dd33b89bd78a21c34de848c54d5f4d1 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 12:17:16 +0200 Subject: [PATCH 05/12] FE-1112: Recover promotion metadata after commit --- memory/PLAN.md | 5 +- ...-promotion--promotion-metadata-recovery.md | 47 ++++++++++++++ src/executor/__tests__/promotion.test.ts | 42 +++++++++++++ src/executor/promotion.ts | 62 ++++++++++++++++--- 4 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 memory/cards/executor-promotion--promotion-metadata-recovery.md diff --git a/memory/PLAN.md b/memory/PLAN.md index 8c25a76c..e88d6fe2 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -174,8 +174,8 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - **Linear:** [FE-1112](https://linear.app/hash/issue/FE-1112/reconcile-executor-promotion) — reconcile executor promotion - **Branch:** `ka/fe-1112-executor-promotion` (stacked on `ka/fe-1111-executor-agent-runner`) - **Kind:** structural / execute-mode runner substrate (`orchestrator-cutover` arc) -- **Status:** done. -- **Current execution pointer:** none — frontier complete. Run-local `GitLandPort` built (`memory/cards/executor-promotion--run-local-git-land-port.md`); host promotion remains explicitly deferred beyond this frontier. +- **Status:** active; review follow-up scoped. +- **Current execution pointer:** `memory/cards/executor-promotion--promotion-metadata-recovery.md` — close the run-local promotion recovery hole where git commit succeeds but promotion report or metadata persistence fails. - **Certainty:** proving. - **Why now / unlocks:** only once a run produces real, verified diffs does a truthful land have a source (D99-L land-substrate finding). This layer lands last so the hard-to-reverse git mutation is the final, independently-reviewable step. - **Objective:** Implement and inject `GitLandPort` so a run's real diffs are promoted — run-local promotion first, host promotion later — consuming/validating the Petri + promotion artifacts rather than re-deriving run state. @@ -183,6 +183,7 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - Done: `GitLandPort` implementation (app layer) performs a run-local promotion of verified worktree diffs first; host promotion is a later, explicitly-accepted slice. - Done: the promotion path consumes existing Petri/run metadata and writes `promotion.json` with the run-local commit SHA rather than re-deriving run state. - Done: `execute_status` `pendingTools` drops `land` because real run-local git promotion exists; host promotion remains explicitly deferred beyond this frontier. + - Review follow-up: recover/idempotently complete promotion metadata if the run-local git commit succeeds but `promotion.json` or `run.json` persistence fails before status advancement. - **Traceability:** D39-L, D40-L, D52-L, D98-L, D99-L (land-substrate finding) / I49-L, I52-L; depends on `executor-agent-runner`; `src/executor/TOPOLOGY.md`. ### elicitor-project 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 00000000..77f6de0a --- /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/src/executor/__tests__/promotion.test.ts b/src/executor/__tests__/promotion.test.ts index 988c9314..5c45df52 100644 --- a/src/executor/__tests__/promotion.test.ts +++ b/src/executor/__tests__/promotion.test.ts @@ -150,6 +150,48 @@ describe('preparePromotion', () => { 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: [], + }), + }); + + 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 advance metadata when the land port fails', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-promotion-failed-')); await createPetriExportedRun(cwd); diff --git a/src/executor/promotion.ts b/src/executor/promotion.ts index bf4db09c..1d107a5f 100644 --- a/src/executor/promotion.ts +++ b/src/executor/promotion.ts @@ -1,9 +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'; @@ -43,12 +48,7 @@ export type PromotionPrepareResult = readonly runId: string; readonly metadataPath: string; readonly promotionPath: string; - readonly sideEffects: readonly [ - { 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' }, - { readonly kind: 'write_file'; readonly path: string; readonly ifExists: 'overwrite' }, - ]; + readonly sideEffects: readonly PromotionSideEffect[]; }; export function promotionReportPath(cwd: string, runId: string): string { @@ -78,6 +78,14 @@ export async function preparePromotion(args: { metadataPath, sideEffects: [], }; + const recovered = await recoverPreparedPromotion({ + cwd: args.cwd, + runId: args.runId, + metadata, + metadataPath, + }); + if (recovered) return recovered; + const worktreeDir = metadata.worktreeDir; if (!worktreeDir) { return { @@ -149,6 +157,46 @@ export async function preparePromotion(args: { }; } +async function recoverPreparedPromotion(args: { + readonly cwd: string; + 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; + + 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'); } From 3713de956e36224add391a7f437cd50c803fe2b9 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 13:23:46 +0200 Subject: [PATCH 06/12] FE-1112: Mark promotion recovery complete --- memory/PLAN.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index e88d6fe2..4ab72245 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -174,8 +174,8 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - **Linear:** [FE-1112](https://linear.app/hash/issue/FE-1112/reconcile-executor-promotion) — reconcile executor promotion - **Branch:** `ka/fe-1112-executor-promotion` (stacked on `ka/fe-1111-executor-agent-runner`) - **Kind:** structural / execute-mode runner substrate (`orchestrator-cutover` arc) -- **Status:** active; review follow-up scoped. -- **Current execution pointer:** `memory/cards/executor-promotion--promotion-metadata-recovery.md` — close the run-local promotion recovery hole where git commit succeeds but promotion report or metadata persistence fails. +- **Status:** done. +- **Current execution pointer:** none — frontier complete. Run-local `GitLandPort` and promotion metadata recovery built (`memory/cards/executor-promotion--run-local-git-land-port.md`, `memory/cards/executor-promotion--promotion-metadata-recovery.md`); host promotion remains explicitly deferred beyond this frontier. - **Certainty:** proving. - **Why now / unlocks:** only once a run produces real, verified diffs does a truthful land have a source (D99-L land-substrate finding). This layer lands last so the hard-to-reverse git mutation is the final, independently-reviewable step. - **Objective:** Implement and inject `GitLandPort` so a run's real diffs are promoted — run-local promotion first, host promotion later — consuming/validating the Petri + promotion artifacts rather than re-deriving run state. @@ -183,7 +183,7 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr - Done: `GitLandPort` implementation (app layer) performs a run-local promotion of verified worktree diffs first; host promotion is a later, explicitly-accepted slice. - Done: the promotion path consumes existing Petri/run metadata and writes `promotion.json` with the run-local commit SHA rather than re-deriving run state. - Done: `execute_status` `pendingTools` drops `land` because real run-local git promotion exists; host promotion remains explicitly deferred beyond this frontier. - - Review follow-up: recover/idempotently complete promotion metadata if the run-local git commit succeeds but `promotion.json` or `run.json` persistence fails before status advancement. + - Done: recover/idempotently complete promotion metadata if the run-local git commit succeeds but `promotion.json` or `run.json` persistence fails before status advancement. - **Traceability:** D39-L, D40-L, D52-L, D98-L, D99-L (land-substrate finding) / I49-L, I52-L; depends on `executor-agent-runner`; `src/executor/TOPOLOGY.md`. ### elicitor-project From 8b057cec41054b459e751510a16c101ca22f2af1 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 14:03:04 +0200 Subject: [PATCH 07/12] FE-1112: Recover promotion after committed retry Co-authored-by: Cursor --- src/app/__tests__/git-land-port.test.ts | 6 ++- src/app/git-land-port.ts | 9 +++- src/executor/__tests__/promotion.test.ts | 36 +++++++++++++++ src/executor/execution-ports.ts | 1 + src/executor/promotion.ts | 57 ++++++++++++++++++------ 5 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/app/__tests__/git-land-port.test.ts b/src/app/__tests__/git-land-port.test.ts index 176f4b17..668dc272 100644 --- a/src/app/__tests__/git-land-port.test.ts +++ b/src/app/__tests__/git-land-port.test.ts @@ -39,11 +39,12 @@ describe('createGitLandPort', () => { }); }); - it('reports no_changes without staging or committing when the worktree is clean', async () => { + 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: '' }; }, }); @@ -51,9 +52,10 @@ describe('createGitLandPort', () => { 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']); + expect(calls).toEqual(['status --porcelain', 'rev-parse HEAD']); }); it('reports git failures without claiming side effects', async () => { diff --git a/src/app/git-land-port.ts b/src/app/git-land-port.ts index 0a8f6384..4b6c350e 100644 --- a/src/app/git-land-port.ts +++ b/src/app/git-land-port.ts @@ -8,7 +8,14 @@ export function createGitLandPort(options: { readonly run?: CommandRunner } = {} 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) { - return { status: 'no_changes', message: 'no worktree changes to promote', sideEffects: [] }; + 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 }); diff --git a/src/executor/__tests__/promotion.test.ts b/src/executor/__tests__/promotion.test.ts index 5c45df52..f319e31d 100644 --- a/src/executor/__tests__/promotion.test.ts +++ b/src/executor/__tests__/promotion.test.ts @@ -192,6 +192,42 @@ describe('preparePromotion', () => { }); }); + 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: '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', + 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); diff --git a/src/executor/execution-ports.ts b/src/executor/execution-ports.ts index fac8db5b..21a8df9c 100644 --- a/src/executor/execution-ports.ts +++ b/src/executor/execution-ports.ts @@ -91,6 +91,7 @@ export type GitLandResult = | { readonly status: 'no_changes'; readonly message: string; + readonly commitSha?: string; readonly sideEffects: readonly []; } | { diff --git a/src/executor/promotion.ts b/src/executor/promotion.ts index 1d107a5f..6278e03a 100644 --- a/src/executor/promotion.ts +++ b/src/executor/promotion.ts @@ -112,6 +112,27 @@ export async function preparePromotion(args: { }; } if (land.status === 'no_changes') { + if (land.commitSha) { + const path = promotionReportPath(args.cwd, args.runId); + const dir = dirname(path); + const report = promotionReport(args.runId, metadata, land.commitSha); + const updated = promotedRunMetadata(metadata, 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: [ + { kind: 'mkdir', path: dir }, + { kind: 'write_file', path, ifExists: 'overwrite' }, + metadataEffect, + ], + }; + } return { status: 'promotion_no_changes', runStatus: 'petri_exported', @@ -125,20 +146,8 @@ export async function preparePromotion(args: { 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 ?? [], - land: { status: 'promoted', commitSha: land.commitSha }, - }; - const updated: RunMetadata = { - ...metadata, - status: 'promotion_prepared', - promotionPath: path, - promotionCommitSha: land.commitSha, - }; + const report = promotionReport(args.runId, metadata, land.commitSha); + const updated = promotedRunMetadata(metadata, path, land.commitSha); await mkdir(dir, { recursive: true }); await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); const metadataEffect = await persistRunMetadata(metadataPath, updated); @@ -157,6 +166,26 @@ export async function preparePromotion(args: { }; } +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 runId: string; From f280481bd4d2714594e2b65321ba5f0055f34d8b Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 14:06:28 +0200 Subject: [PATCH 08/12] FE-1112: Require promotion head for retry recovery Co-authored-by: Cursor --- src/app/__tests__/git-land-port.test.ts | 24 +++++++++++++++++++++--- src/app/git-land-port.ts | 13 ++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/app/__tests__/git-land-port.test.ts b/src/app/__tests__/git-land-port.test.ts index 668dc272..d1f339e6 100644 --- a/src/app/__tests__/git-land-port.test.ts +++ b/src/app/__tests__/git-land-port.test.ts @@ -39,12 +39,30 @@ describe('createGitLandPort', () => { }); }); - it('reports no_changes with current HEAD without staging or committing when the worktree is clean', async () => { + it('reports no_changes without a recovery sha when clean HEAD is unrelated to promotion', 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: '' }; + if (args[0] === 'log') return { exitCode: 0, stdout: 'abc123\u0000some other commit\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', + sideEffects: [], + }); + expect(calls).toEqual(['status --porcelain', 'log -1 --format=%H%x00%s']); + }); + + it('reports no_changes with a recovery sha when clean HEAD is the promotion commit', async () => { + const calls: string[] = []; + const port = createGitLandPort({ + run: async (_command, args) => { + calls.push(args.join(' ')); + if (args[0] === 'log') return { exitCode: 0, stdout: 'abc123\u0000promote\n', stderr: '' }; return { exitCode: 0, stdout: '', stderr: '' }; }, }); @@ -55,7 +73,7 @@ describe('createGitLandPort', () => { commitSha: 'abc123', sideEffects: [], }); - expect(calls).toEqual(['status --porcelain', 'rev-parse HEAD']); + expect(calls).toEqual(['status --porcelain', 'log -1 --format=%H%x00%s']); }); it('reports git failures without claiming side effects', async () => { diff --git a/src/app/git-land-port.ts b/src/app/git-land-port.ts index 4b6c350e..858daddf 100644 --- a/src/app/git-land-port.ts +++ b/src/app/git-land-port.ts @@ -8,12 +8,19 @@ export function createGitLandPort(options: { readonly run?: CommandRunner } = {} 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}`); + const head = await run('git', ['log', '-1', '--format=%H%x00%s'], { cwd: args.worktreeDir }); + if (head.exitCode !== 0) return failed(head, `git log exited ${head.exitCode}`); + const [commitSha, subject] = head.stdout.trimEnd().split('\0'); + if (commitSha && subject === args.message) + return { + status: 'no_changes', + message: 'no worktree changes to promote', + commitSha, + sideEffects: [], + }; return { status: 'no_changes', message: 'no worktree changes to promote', - commitSha: revParse.stdout.trim(), sideEffects: [], }; } From 257644275c052d814c2d6e3e14eaf13494f0aba2 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 14:12:28 +0200 Subject: [PATCH 09/12] FE-1112: Track promotion base for retry recovery Co-authored-by: Cursor --- src/app/__tests__/git-land-port.test.ts | 40 ++++++------- src/app/git-land-port.ts | 28 +++++---- src/executor/__tests__/fake-ports.ts | 4 ++ src/executor/__tests__/promotion.test.ts | 11 +++- src/executor/execution-ports.ts | 11 ++++ src/executor/promotion.ts | 73 +++++++++++++++++++++--- src/executor/run.ts | 1 + 7 files changed, 126 insertions(+), 42 deletions(-) diff --git a/src/app/__tests__/git-land-port.test.ts b/src/app/__tests__/git-land-port.test.ts index d1f339e6..acc1f434 100644 --- a/src/app/__tests__/git-land-port.test.ts +++ b/src/app/__tests__/git-land-port.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from 'vitest'; import { createGitLandPort } from '../git-land-port.js'; describe('createGitLandPort', () => { + 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({ @@ -39,30 +55,12 @@ describe('createGitLandPort', () => { }); }); - it('reports no_changes without a recovery sha when clean HEAD is unrelated to promotion', async () => { - const calls: string[] = []; - const port = createGitLandPort({ - run: async (_command, args) => { - calls.push(args.join(' ')); - if (args[0] === 'log') return { exitCode: 0, stdout: 'abc123\u0000some other commit\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', - sideEffects: [], - }); - expect(calls).toEqual(['status --porcelain', 'log -1 --format=%H%x00%s']); - }); - - it('reports no_changes with a recovery sha when clean HEAD is the promotion commit', async () => { + 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] === 'log') return { exitCode: 0, stdout: 'abc123\u0000promote\n', stderr: '' }; + if (args[0] === 'rev-parse') return { exitCode: 0, stdout: 'abc123\n', stderr: '' }; return { exitCode: 0, stdout: '', stderr: '' }; }, }); @@ -73,7 +71,7 @@ describe('createGitLandPort', () => { commitSha: 'abc123', sideEffects: [], }); - expect(calls).toEqual(['status --porcelain', 'log -1 --format=%H%x00%s']); + expect(calls).toEqual(['status --porcelain', 'rev-parse HEAD']); }); it('reports git failures without claiming side effects', async () => { diff --git a/src/app/git-land-port.ts b/src/app/git-land-port.ts index 858daddf..a28b4766 100644 --- a/src/app/git-land-port.ts +++ b/src/app/git-land-port.ts @@ -4,23 +4,21 @@ 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 head = await run('git', ['log', '-1', '--format=%H%x00%s'], { cwd: args.worktreeDir }); - if (head.exitCode !== 0) return failed(head, `git log exited ${head.exitCode}`); - const [commitSha, subject] = head.stdout.trimEnd().split('\0'); - if (commitSha && subject === args.message) - return { - status: 'no_changes', - message: 'no worktree changes to promote', - commitSha, - sideEffects: [], - }; + 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: [], }; } @@ -44,6 +42,16 @@ export function createGitLandPort(options: { readonly run?: CommandRunner } = {} }; } +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, diff --git a/src/executor/__tests__/fake-ports.ts b/src/executor/__tests__/fake-ports.ts index 398e24b3..6b29bac5 100644 --- a/src/executor/__tests__/fake-ports.ts +++ b/src/executor/__tests__/fake-ports.ts @@ -39,8 +39,12 @@ export function createFakeGitLandPort( 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 f319e31d..b5c220c1 100644 --- a/src/executor/__tests__/promotion.test.ts +++ b/src/executor/__tests__/promotion.test.ts @@ -93,6 +93,7 @@ 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' }, @@ -114,6 +115,7 @@ describe('preparePromotion', () => { expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ status: 'promotion_prepared', promotionPath: promotionReportPath(cwd, 'run-1'), + promotionBaseSha: 'base123', promotionCommitSha: 'abc123', }); @@ -131,6 +133,7 @@ describe('preparePromotion', () => { gitLand: createFakeGitLandPort({ status: 'no_changes', message: 'nothing to promote', + commitSha: 'base123', sideEffects: [], }), }); @@ -142,10 +145,11 @@ describe('preparePromotion', () => { worktreeDir: join(runDirPath(cwd, 'run-1'), 'worktree'), metadataPath: runMetadataPath(cwd, 'run-1'), message: 'nothing to promote', - sideEffects: [], + 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); }); @@ -214,6 +218,7 @@ describe('preparePromotion', () => { 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' }, @@ -224,6 +229,7 @@ describe('preparePromotion', () => { }); expect(JSON.parse(await readFile(runMetadataPath(cwd, 'run-1'), 'utf8'))).toMatchObject({ status: 'promotion_prepared', + promotionBaseSha: 'base123', promotionCommitSha: 'abc123', }); }); @@ -242,10 +248,11 @@ describe('preparePromotion', () => { status: 'promotion_failed', runStatus: 'petri_exported', message: 'git commit failed', - sideEffects: [], + 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 21a8df9c..d87ae2ba 100644 --- a/src/executor/execution-ports.ts +++ b/src/executor/execution-ports.ts @@ -80,6 +80,16 @@ export interface GitLandArgs { readonly message: string; } +export type GitHeadResult = + | { + readonly status: 'ok'; + readonly commitSha: string; + } + | { + readonly status: 'failed'; + readonly message: string; + }; + export type GitLandResult = | { readonly status: 'promoted'; @@ -101,6 +111,7 @@ export type GitLandResult = }; export interface GitLandPort { + currentHead(args: { readonly worktreeDir: string }): Promise; promote(args: GitLandArgs): Promise; } diff --git a/src/executor/promotion.ts b/src/executor/promotion.ts index 6278e03a..33d90399 100644 --- a/src/executor/promotion.ts +++ b/src/executor/promotion.ts @@ -31,7 +31,7 @@ export type PromotionPrepareResult = readonly worktreeDir: string; readonly metadataPath: string; readonly message: string; - readonly sideEffects: readonly []; + readonly sideEffects: readonly PromotionSideEffect[]; } | { readonly status: 'promotion_no_changes'; @@ -40,7 +40,7 @@ export type PromotionPrepareResult = readonly worktreeDir: string; readonly metadataPath: string; readonly message: string; - readonly sideEffects: readonly []; + readonly sideEffects: readonly PromotionSideEffect[]; } | { readonly status: 'promotion_prepared'; @@ -99,6 +99,25 @@ export async function preparePromotion(args: { }; } + const preparedAttempt = await preparePromotionAttempt({ + gitLand: args.gitLand, + metadata, + metadataPath, + worktreeDir, + }); + 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 { @@ -108,15 +127,15 @@ export async function preparePromotion(args: { worktreeDir, metadataPath, message: land.message, - sideEffects: [], + sideEffects: preparedAttempt.sideEffects, }; } if (land.status === 'no_changes') { - if (land.commitSha) { + if (land.commitSha && land.commitSha !== promotionMetadata.promotionBaseSha) { const path = promotionReportPath(args.cwd, args.runId); const dir = dirname(path); - const report = promotionReport(args.runId, metadata, land.commitSha); - const updated = promotedRunMetadata(metadata, path, land.commitSha); + 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); @@ -127,6 +146,7 @@ export async function preparePromotion(args: { metadataPath, promotionPath: path, sideEffects: [ + ...preparedAttempt.sideEffects, { kind: 'mkdir', path: dir }, { kind: 'write_file', path, ifExists: 'overwrite' }, metadataEffect, @@ -140,14 +160,14 @@ export async function preparePromotion(args: { worktreeDir, metadataPath, message: land.message, - sideEffects: [], + sideEffects: preparedAttempt.sideEffects, }; } const path = promotionReportPath(args.cwd, args.runId); const dir = dirname(path); - const report = promotionReport(args.runId, metadata, land.commitSha); - const updated = promotedRunMetadata(metadata, path, land.commitSha); + 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); @@ -158,6 +178,7 @@ export async function preparePromotion(args: { metadataPath, promotionPath: path, sideEffects: [ + ...preparedAttempt.sideEffects, ...land.sideEffects, { kind: 'mkdir', path: dir }, { kind: 'write_file', path, ifExists: 'overwrite' }, @@ -166,6 +187,40 @@ export async function preparePromotion(args: { }; } +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; +}): Promise { + if (args.metadata.promotionBaseSha) { + 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, diff --git a/src/executor/run.ts b/src/executor/run.ts index a75a7f88..c5190a1b 100644 --- a/src/executor/run.ts +++ b/src/executor/run.ts @@ -37,6 +37,7 @@ export interface RunMetadata { readonly completedSliceIds?: readonly string[]; readonly petriPath?: string; readonly promotionPath?: string; + readonly promotionBaseSha?: string; readonly promotionCommitSha?: string; } From 0ccd0d5f27aebe0f02f96827457968d919a06f7a Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 15:06:04 +0200 Subject: [PATCH 10/12] FE-1112: Verify promotion report against HEAD Co-authored-by: Cursor --- src/executor/__tests__/promotion.test.ts | 56 +++++++++++++++++++++--- src/executor/promotion.ts | 12 +++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/executor/__tests__/promotion.test.ts b/src/executor/__tests__/promotion.test.ts index b5c220c1..5771bf04 100644 --- a/src/executor/__tests__/promotion.test.ts +++ b/src/executor/__tests__/promotion.test.ts @@ -174,11 +174,14 @@ describe('preparePromotion', () => { const result = await preparePromotion({ cwd, runId: 'run-1', - gitLand: createFakeGitLandPort({ - status: 'no_changes', - message: 'nothing to promote', - sideEffects: [], - }), + gitLand: createFakeGitLandPort( + { + status: 'no_changes', + message: 'nothing to promote', + sideEffects: [], + }, + 'abc123', + ), }); expect(result).toEqual({ @@ -196,6 +199,49 @@ describe('preparePromotion', () => { }); }); + 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); diff --git a/src/executor/promotion.ts b/src/executor/promotion.ts index 33d90399..a6dfc6ca 100644 --- a/src/executor/promotion.ts +++ b/src/executor/promotion.ts @@ -80,11 +80,13 @@ export async function preparePromotion(args: { }; 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) { @@ -104,6 +106,7 @@ export async function preparePromotion(args: { metadata, metadataPath, worktreeDir, + persistBaseSha: !hasPromotionReport, }); if (preparedAttempt.status === 'failed') { return { @@ -210,10 +213,14 @@ async function preparePromotionAttempt(args: { 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 }; @@ -243,6 +250,7 @@ function promotedRunMetadata(metadata: RunMetadata, promotionPath: string, commi async function recoverPreparedPromotion(args: { readonly cwd: string; + readonly gitLand: GitLandPort; readonly runId: string; readonly metadata: RunMetadata; readonly metadataPath: string; @@ -251,6 +259,10 @@ async function recoverPreparedPromotion(args: { 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, From 5de0fabaec908a24fcb82886b0c34872761f4518 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 15:12:18 +0200 Subject: [PATCH 11/12] FE-1112: Align promotion registry side effects --- src/.pi/extensions/__tests__/registry.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/.pi/extensions/__tests__/registry.test.ts b/src/.pi/extensions/__tests__/registry.test.ts index a85238a6..c5e67560 100644 --- a/src/.pi/extensions/__tests__/registry.test.ts +++ b/src/.pi/extensions/__tests__/registry.test.ts @@ -1309,6 +1309,7 @@ describe('Brunch explicit Pi extension registry', () => { 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' }, From 3e8bb5b644e68f22eb05d320856523aa77a4640e Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 1 Jul 2026 15:33:34 +0200 Subject: [PATCH 12/12] FE-1112: Align run-local promotion title --- memory/PLAN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 4ab72245..ff5020b8 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -170,8 +170,8 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr ### executor-promotion -- **Name:** Reconcile executor promotion -- **Linear:** [FE-1112](https://linear.app/hash/issue/FE-1112/reconcile-executor-promotion) — reconcile executor promotion +- **Name:** Reconcile run-local executor promotion +- **Linear:** [FE-1112](https://linear.app/hash/issue/FE-1112/reconcile-run-local-executor-promotion) — reconcile run-local executor promotion - **Branch:** `ka/fe-1112-executor-promotion` (stacked on `ka/fe-1111-executor-agent-runner`) - **Kind:** structural / execute-mode runner substrate (`orchestrator-cutover` arc) - **Status:** done.