diff --git a/fixtures/txt/plan.yaml b/fixtures/txt/plan.yaml index caf85f8b..dbfd29ce 100644 --- a/fixtures/txt/plan.yaml +++ b/fixtures/txt/plan.yaml @@ -2,14 +2,16 @@ epics: - id: scaffolding summary: "CLI scaffolding" depends_on: [] - verification: - - kind: integration-test - target: "tests/cli-scaffolding.integration.test.ts" + # Slice unit tests cover version-flag and help-flag; epic verify deferred until + # text-ops slices exist (cli-scaffolding integration spans the full CLI). + verification: [] - id: text-ops summary: "Text operations" depends_on: [scaffolding] verification: + - kind: integration-test + target: "tests/cli-scaffolding.integration.test.ts" - kind: integration-test target: "tests/text-ops-pipe.integration.test.ts" diff --git a/memory/PLAN.md b/memory/PLAN.md index da0fa52e..3fa87723 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -30,6 +30,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### Recently Completed +- `petri-epic-verification-merge` — `verify-epic` now runs against a freshly-merged `/__epic__//` built from completed slice worktrees (declaration-order wins on path collisions; conflicts surfaced via `epic-sandbox-merged` event). Unblocks multi-slice `cook` runs. Follows FE-743. - `petri-parallel-execution` (FE-743) — parallel firing policy, shared resource pool tokens, worktree-per-slice isolation. Decision gate passed: parallel measurably beats serial on wall clock for multi-slice plans. Follows `petri-semantic-lanes` (FE-738). - `petri-semantic-lanes` (FE-738) — two-lane subnet, compiler topology/wiring split, engine factory, semantic rework budget, §7 events. PR #148. Criterion (5) stale-graph deferred → `petri-graph-compilation`. @@ -104,7 +105,6 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Open design constraints (from PR #143 / FE-743 review):** - **Declarative output arcs:** Current topology declares only input places; output routing lives in fire closures (conditional on report payloads). FE-738's `HandlerDescriptor` declares candidate outputs (`onTrue`/`onFalse`/`onPass`/`onFail`) but selection is runtime. This limits formal analyzability (reachability, deadlock detection, simulation) to input-side structure. Phase 3 should move conditional routing into the topology — explicit guard predicates + declared output arcs per branch — so the compiled net is formally analyzable end-to-end. - **Token state enrichment:** Open question whether more metadata should move from reports into tokens (richer typed token payloads per spec §3). FE-738 added `reworkCount`, FE-743 added pool tokens with `agentPoolSize`, but the boundary between control state (tokens) and substantive handoff state (reports) is a design choice this frontier needs to resolve as the token taxonomy gets richer. - - **Epic verification sandbox scope:** Per-slice sandbox isolation means `verify-epic` can't see all slices' artifacts. Currently `verify-epic` falls back to the parent sandbox dir. The production fix is to merge per-slice sandboxes into an epic-scoped dir before epic verification runs. - **Acceptance:** TBD — depends on FE-700 relation-policy shape. - **Verification:** Compiled-net topology tests against plan-graph fixtures; reachability assertions for relation-policy-derived gates; comparison of compiled vs hand-authored net shapes. - **Traceability:** Requirements 46–50; spec §5 (relation-policy compilation), §6 (transition contracts). @@ -505,6 +505,7 @@ TRACK F — Petri-net execution substrate (umbrella H-6476) orchestrator-poc (Phase 0: compiler extraction — done) └──→ petri-semantic-lanes (Phase 1: two-lane subnet + §7 events — done) └──→ petri-parallel-execution (Phase 2: concurrent firing + resource pools — done) + ├──→ petri-epic-verification-merge (hardening: merge slice worktrees for verify-epic — done) └──→ petri-graph-compilation (Phase 3: compile from plan-graph + relation policy) ├──→ depends on intent-graph-semantics (FE-700) for relation-policy gates └──→ petri-simulation-oracle (Phase 4: reachability, deadlock, resume) diff --git a/memory/SPEC.md b/memory/SPEC.md index d89fe3c9..a9836038 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -256,6 +256,7 @@ Each invariant is a formalization candidate: the property is stated in human lan | I121-K | Both orchestrator engines (`proc` and `petri`) pass the same contract test suite with identical observable behavior. | contract tests with fake agents/runner | Requirements 46, 47; D155-K | | I122-K | Orchestrator event content lives in `reports.jsonl`; petri engine tokens carry only `{ reportId, sliceId, epicId }` pointers. Proc engine may pass data through normal function calls — the shared seam is inputs and outputs. | contract tests | Requirement 48; D156-K | | I123-K | Worktree isolation holds — fixture directory and source repo are never mutated by an orchestrator run; worktree is cwd-scoped at `/.cook/runs//worktree/`. | integration tests, worktree.test.ts | Requirement 49; D159-K | +| I124-K | Epic verification runs against a freshly-rebuilt `/__epic__//` dir holding the deterministic merge of its completed slices' worktrees (later slices in plan declaration order overwrite earlier ones on path collisions; collisions are reported via the `epic-sandbox-merged` event). Per-slice worktrees are not mutated by the merge. | epic-sandbox-merge.test.ts, engine-contract.test.ts | Requirement 49; D159-K | ## Future Direction Register diff --git a/src/orchestrator/src/engine-contract.test.ts b/src/orchestrator/src/engine-contract.test.ts index 7ab80ca9..af6546c1 100644 --- a/src/orchestrator/src/engine-contract.test.ts +++ b/src/orchestrator/src/engine-contract.test.ts @@ -1,3 +1,7 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { createOrchestrator } from './engine.js'; @@ -1080,7 +1084,7 @@ describe('Engine contract test #13 — resource pool bounds concurrency', () => // --------------------------------------------------------------------------- describe('Adapter: sandbox-per-slice isolation', () => { - it('each action handler receives a per-slice sandboxDir', async () => { + it('each action handler receives a per-slice sandboxDir (parallel-safe)', async () => { const sandboxDirs = new Map(); const fakes = createFakes({ evalSequence: [true], semanticResults: [true] }); @@ -1103,49 +1107,55 @@ describe('Adapter: sandbox-per-slice isolation', () => { }); expect(result.status).toBe('completed'); - // Every action should receive sandboxDir = /tmp/run/ for (const [key, dir] of sandboxDirs) { const sliceId = key.split(':')[0]!; expect(dir).toBe(`/tmp/run/${sliceId}`); + expect(simplePlan.slices.find((s) => s.id === sliceId)?.epic_id).toBe('epic-1'); } - // At least evaluate-done and assess-semantic were called expect(sandboxDirs.size).toBeGreaterThanOrEqual(2); }); - it('verify-epic receives the parent sandboxDir (not per-slice)', async () => { - const verifyPlan: Plan = { - epics: [ + it('parallel slices in the same epic receive distinct sandboxDirs', async () => { + const parallelPlan: Plan = { + epics: [{ id: 'e1', summary: 'Three independent slices', depends_on: [], verification: [] }], + slices: [ { - id: 'ev', - summary: 'Verified', + id: 'p1', + epic_id: 'e1', + definition: 'S1', depends_on: [], - verification: [{ kind: 'integration-test', target: 't' }], + verification: [{ kind: 'unit-test', target: 't1' }], }, - ], - slices: [ { - id: 'sv', - epic_id: 'ev', - definition: 'S', + id: 'p2', + epic_id: 'e1', + definition: 'S2', depends_on: [], - verification: [{ kind: 'unit-test', target: 't' }], + verification: [{ kind: 'unit-test', target: 't2' }], + }, + { + id: 'p3', + epic_id: 'e1', + definition: 'S3', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't3' }], }, ], }; - let verifyEpicSandboxDir = ''; - const fakes = createFakes({ evalSequence: [true], semanticResults: [true], verifyEpicResult: true }); + const sandboxDirs = new Set(); + const fakes = createFakes({ evalSequence: [true], semanticResults: [true] }); const trackingActions: ActionHandlers = {}; for (const [key, handler] of Object.entries(fakes.actions)) { trackingActions[key] = async (ctx: ActionContext) => { - if (key === 'verify-epic') verifyEpicSandboxDir = ctx.sandboxDir; + sandboxDirs.add(ctx.sandboxDir); return handler!(ctx); }; } - const result = await createOrchestrator('serial').run({ - plan: verifyPlan, - sandboxDir: '/tmp/run', + const result = await createOrchestrator('parallel').run({ + plan: parallelPlan, + sandboxDir: '/tmp/parallel-run', actions: trackingActions, reports: fakes.reports, testRunner: fakes.testRunner, @@ -1153,7 +1163,73 @@ describe('Adapter: sandbox-per-slice isolation', () => { }); expect(result.status).toBe('completed'); - // verify-epic gets the parent sandbox, not /tmp/run/sv - expect(verifyEpicSandboxDir).toBe('/tmp/run'); + expect(sandboxDirs.size).toBeGreaterThan(1); + for (const dir of sandboxDirs) { + expect(dir.startsWith('/tmp/parallel-run/')).toBe(true); + } + }); + + it('verify-epic receives a merged epic sandbox under /__epic__// (not slice worktree, not parent)', async () => { + const verifyPlan: Plan = { + epics: [ + { + id: 'ev', + summary: 'Verified', + depends_on: [], + verification: [{ kind: 'integration-test', target: 't' }], + }, + ], + slices: [ + { + id: 'sv', + epic_id: 'ev', + definition: 'S', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't' }], + }, + ], + }; + + const parent = mkdtempSync(join(tmpdir(), 'cook-ec-')); + try { + // Seed the slice worktree with a file so the merge has something to copy. + mkdirSync(join(parent, 'sv'), { recursive: true }); + writeFileSync(join(parent, 'sv', 'slice-marker.txt'), 'from-slice-sv'); + + let verifyEpicSandboxDir = ''; + const fakes = createFakes({ evalSequence: [true], semanticResults: [true], verifyEpicResult: true }); + const trackingActions: ActionHandlers = {}; + for (const [key, handler] of Object.entries(fakes.actions)) { + trackingActions[key] = async (ctx: ActionContext) => { + if (key === 'verify-epic') verifyEpicSandboxDir = ctx.sandboxDir; + return handler!(ctx); + }; + } + + const result = await createOrchestrator('serial').run({ + plan: verifyPlan, + sandboxDir: parent, + actions: trackingActions, + reports: fakes.reports, + testRunner: fakes.testRunner, + policy: { maxRetries: 3 }, + }); + + expect(result.status).toBe('completed'); + expect(verifyEpicSandboxDir).toBe(join(parent, '__epic__', 'ev')); + // Merge produced a real dir holding the slice worktree seed file. + expect(existsSync(join(verifyEpicSandboxDir, 'slice-marker.txt'))).toBe(true); + + // An epic-sandbox-merged event was appended before verify-epic. + const merged = fakes.reports.getAll().find((r) => r.event === 'epic-sandbox-merged'); + expect(merged).toBeDefined(); + expect(merged?.payload).toMatchObject({ + epicSandboxDir: join(parent, '__epic__', 'ev'), + sliceIds: ['sv'], + conflicts: [], + }); + } finally { + rmSync(parent, { recursive: true, force: true }); + } }); }); diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts new file mode 100644 index 00000000..24856ff6 --- /dev/null +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -0,0 +1,417 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + epicIdsForEpicVerifyMerge, + mergeSlicesIntoEpicSandbox, + seedSliceSandboxFromDeps, + sliceIdsForEpicVerifyMerge, +} from './epic-sandbox-merge.js'; +import type { Plan } from './types.js'; + +const txtLikePlan: Plan = { + epics: [ + { id: 'scaffolding', summary: '', depends_on: [], verification: [] }, + { id: 'text-ops', summary: '', depends_on: ['scaffolding'], verification: [] }, + ], + slices: [ + { id: 'version-flag', epic_id: 'scaffolding', definition: '', depends_on: [], verification: [] }, + { + id: 'help-flag', + epic_id: 'scaffolding', + definition: '', + depends_on: ['version-flag'], + verification: [], + }, + { id: 'reverse', epic_id: 'text-ops', definition: '', depends_on: [], verification: [] }, + { id: 'count', epic_id: 'text-ops', definition: '', depends_on: [], verification: [] }, + { id: 'slugify', epic_id: 'text-ops', definition: '', depends_on: [], verification: [] }, + ], +}; + +const crossEpicSliceDepPlan: Plan = { + epics: [ + { id: 'epic-a', summary: '', depends_on: [], verification: [] }, + { id: 'epic-b', summary: '', depends_on: [], verification: [] }, + ], + slices: [ + { id: 'slice-a', epic_id: 'epic-a', definition: '', depends_on: [], verification: [] }, + { id: 'slice-b', epic_id: 'epic-b', definition: '', depends_on: ['slice-a'], verification: [] }, + ], +}; + +describe('epicIdsForEpicVerifyMerge', () => { + it('includes only the target epic when there are no epic dependencies', () => { + expect(epicIdsForEpicVerifyMerge(txtLikePlan, 'scaffolding')).toEqual(['scaffolding']); + }); + + it('includes dependency epics in plan declaration order', () => { + expect(epicIdsForEpicVerifyMerge(txtLikePlan, 'text-ops')).toEqual(['scaffolding', 'text-ops']); + }); + + it('includes epics reachable only via slice depends_on', () => { + expect(epicIdsForEpicVerifyMerge(crossEpicSliceDepPlan, 'epic-b')).toEqual(['epic-a', 'epic-b']); + }); + + it('tolerates cyclic slice depends_on without stack overflow', () => { + const cyclicPlan: Plan = { + epics: [{ id: 'e1', summary: '', depends_on: [], verification: [] }], + slices: [ + { id: 'a', epic_id: 'e1', definition: '', depends_on: ['b'], verification: [] }, + { id: 'b', epic_id: 'e1', definition: '', depends_on: ['a'], verification: [] }, + ], + }; + expect(() => epicIdsForEpicVerifyMerge(cyclicPlan, 'e1')).not.toThrow(); + expect(epicIdsForEpicVerifyMerge(cyclicPlan, 'e1')).toEqual(['e1']); + }); +}); + +describe('sliceIdsForEpicVerifyMerge', () => { + it('lists slices from dependency epics then target epic in plan order', () => { + expect(sliceIdsForEpicVerifyMerge(txtLikePlan, 'text-ops')).toEqual([ + 'version-flag', + 'help-flag', + 'reverse', + 'count', + 'slugify', + ]); + }); + + it('includes cross-epic slice dependencies', () => { + expect(sliceIdsForEpicVerifyMerge(crossEpicSliceDepPlan, 'epic-b')).toEqual(['slice-a', 'slice-b']); + }); +}); + +describe('seedSliceSandboxFromDeps', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + it('copies completed dependency slice files into the target slice sandbox', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-seed-')); + dirs.push(parent); + mkdirSync(join(parent, 'version-flag', 'src'), { recursive: true }); + writeFileSync(join(parent, 'version-flag', 'src/cli.ts'), 'version\n'); + + const slice = txtLikePlan.slices.find((s) => s.id === 'help-flag')!; + const sliceDir = seedSliceSandboxFromDeps(parent, txtLikePlan, slice); + + expect(sliceDir).toBe(join(parent, 'help-flag')); + expect(readFileSync(join(sliceDir, 'src/cli.ts'), 'utf8')).toBe('version\n'); + }); + + it('preserveExisting keeps slice-owned files and edits on dep paths', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-seed-')); + dirs.push(parent); + mkdirSync(join(parent, 'version-flag', 'src'), { recursive: true }); + writeFileSync(join(parent, 'version-flag', 'src/cli.ts'), 'dep\n'); + + const slice = txtLikePlan.slices.find((s) => s.id === 'help-flag')!; + seedSliceSandboxFromDeps(parent, txtLikePlan, slice); + writeFileSync(join(parent, 'help-flag', 'src/stale.ts'), 'slice-owned\n'); + writeFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'slice edit\n'); + + seedSliceSandboxFromDeps(parent, txtLikePlan, slice, { preserveExisting: true }); + + expect(readFileSync(join(parent, 'help-flag', 'src/stale.ts'), 'utf8')).toBe('slice-owned\n'); + expect(readFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'utf8')).toBe('slice edit\n'); + }); + + it('preserveExisting keeps slice modifications when re-seeding before tests', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-seed-')); + dirs.push(parent); + mkdirSync(join(parent, 'version-flag', 'src'), { recursive: true }); + writeFileSync(join(parent, 'version-flag', 'src/cli.ts'), 'dep\n'); + + const slice = txtLikePlan.slices.find((s) => s.id === 'help-flag')!; + seedSliceSandboxFromDeps(parent, txtLikePlan, slice); + writeFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'slice edit\n'); + + seedSliceSandboxFromDeps(parent, txtLikePlan, slice, { preserveExisting: true }); + + expect(readFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'utf8')).toBe('slice edit\n'); + }); + + it('uses plan order when multiple deps share a path', () => { + const plan: Plan = { + epics: [{ id: 'e1', summary: '', depends_on: [], verification: [] }], + slices: [ + { id: 'dep-b', epic_id: 'e1', definition: '', depends_on: [], verification: [] }, + { id: 'dep-a', epic_id: 'e1', definition: '', depends_on: [], verification: [] }, + { + id: 'target', + epic_id: 'e1', + definition: '', + depends_on: ['dep-b', 'dep-a'], + verification: [], + }, + ], + }; + const parent = mkdtempSync(join(tmpdir(), 'cook-seed-')); + dirs.push(parent); + mkdirSync(join(parent, 'dep-b'), { recursive: true }); + writeFileSync(join(parent, 'dep-b', 'shared.txt'), 'B\n'); + mkdirSync(join(parent, 'dep-a'), { recursive: true }); + writeFileSync(join(parent, 'dep-a', 'shared.txt'), 'A\n'); + + const slice = plan.slices.find((s) => s.id === 'target')!; + seedSliceSandboxFromDeps(parent, plan, slice); + + expect(readFileSync(join(parent, 'target', 'shared.txt'), 'utf8')).toBe('A\n'); + }); + + it('reset re-seed removes orphaned slice files from a prior rework attempt', () => { + const parent = mkdtempSync(join(tmpdir(), 'cook-seed-')); + dirs.push(parent); + mkdirSync(join(parent, 'version-flag', 'src'), { recursive: true }); + writeFileSync(join(parent, 'version-flag', 'src/cli.ts'), 'dep\n'); + + const slice = txtLikePlan.slices.find((s) => s.id === 'help-flag')!; + seedSliceSandboxFromDeps(parent, txtLikePlan, slice); + writeFileSync(join(parent, 'help-flag', 'src/stale.ts'), 'orphan\n'); + writeFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'bad edit\n'); + + seedSliceSandboxFromDeps(parent, txtLikePlan, slice); + + expect(existsSync(join(parent, 'help-flag', 'src/stale.ts'))).toBe(false); + expect(readFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'utf8')).toBe('dep\n'); + }); +}); + +describe('mergeSlicesIntoEpicSandbox', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + function makeParent(): string { + const runDir = mkdtempSync(join(tmpdir(), 'cook-merge-')); + dirs.push(runDir); + const parent = join(runDir, 'worktree'); + mkdirSync(parent, { recursive: true }); + return parent; + } + + function seedSlice(parent: string, sliceId: string, files: Record): void { + const sliceDir = join(parent, sliceId); + for (const [rel, contents] of Object.entries(files)) { + const abs = join(sliceDir, rel); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, contents); + } + } + + it('copies disjoint files from each slice worktree into a fresh verify sandbox', () => { + const parent = makeParent(); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'export const a = 1;\n' }); + seedSlice(parent, 'slice-b', { 'src/b.ts': 'export const b = 2;\n' }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-b', + sliceIds: ['slice-a', 'slice-b'], + }); + + const expected = join(parent, '__epic__', 'epic-b'); + expect(result.epicSandboxDir).toBe(expected); + expect(result.conflicts).toEqual([]); + expect(readFileSync(join(expected, 'src/a.ts'), 'utf8')).toBe('export const a = 1;\n'); + expect(readFileSync(join(expected, 'src/b.ts'), 'utf8')).toBe('export const b = 2;\n'); + }); + + it('resolves path collisions in slice order (last slice wins) and reports them', () => { + const parent = makeParent(); + seedSlice(parent, 'slice-a', { 'src/x.ts': 'A\n' }); + seedSlice(parent, 'slice-b', { 'src/x.ts': 'B\n' }); + seedSlice(parent, 'slice-c', { 'src/x.ts': 'C\n' }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-c', + sliceIds: ['slice-a', 'slice-b', 'slice-c'], + }); + + expect(readFileSync(join(result.epicSandboxDir, 'src/x.ts'), 'utf8')).toBe('C\n'); + expect(result.conflicts).toEqual([ + { path: 'src/x.ts', slices: ['slice-a', 'slice-b', 'slice-c'], winner: 'slice-c' }, + ]); + }); + + it('leaves slice worktrees byte-identical after merge', () => { + const parent = makeParent(); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A\n', 'tests/a.test.ts': 'TA\n' }); + seedSlice(parent, 'slice-b', { 'src/a.ts': 'B\n' }); + + const before = { + aSrc: readFileSync(join(parent, 'slice-a', 'src/a.ts'), 'utf8'), + aTests: readFileSync(join(parent, 'slice-a', 'tests/a.test.ts'), 'utf8'), + bSrc: readFileSync(join(parent, 'slice-b', 'src/a.ts'), 'utf8'), + }; + + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-b', + sliceIds: ['slice-a', 'slice-b'], + }); + + expect(readFileSync(join(parent, 'slice-a', 'src/a.ts'), 'utf8')).toBe(before.aSrc); + expect(readFileSync(join(parent, 'slice-a', 'tests/a.test.ts'), 'utf8')).toBe(before.aTests); + expect(readFileSync(join(parent, 'slice-b', 'src/a.ts'), 'utf8')).toBe(before.bSrc); + }); + + it('rebuilds the verify sandbox fresh on every call (no cruft from prior merge)', () => { + const parent = makeParent(); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A1\n', 'src/stale.ts': 'stale\n' }); + + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-a', + sliceIds: ['slice-a'], + }); + + rmSync(join(parent, 'slice-a', 'src/stale.ts')); + const second = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-a', + sliceIds: ['slice-a'], + }); + + expect(existsSync(join(second.epicSandboxDir, 'src/a.ts'))).toBe(true); + expect(existsSync(join(second.epicSandboxDir, 'src/stale.ts'))).toBe(false); + }); + + it('skips slices whose worktree does not exist (e.g. halted before any write)', () => { + const parent = makeParent(); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A\n' }); + // slice "slice-b" never created its worktree + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-b', + sliceIds: ['slice-a', 'slice-b'], + }); + + expect(existsSync(join(result.epicSandboxDir, 'src/a.ts'))).toBe(true); + expect(result.conflicts).toEqual([]); + }); + + it('rejects epic ids that escape the parent sandbox', () => { + const parent = makeParent(); + expect(() => + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: '..', + sliceIds: [], + }), + ).toThrow(/Invalid epic id/); + }); + + it('rejects reserved __epic__ as a source slice id', () => { + const parent = makeParent(); + expect(() => + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['__epic__'], + }), + ).toThrow(/Invalid slice id: __epic__/); + }); + + it('does not nest other verify merge dirs into the verify sandbox', () => { + const parent = makeParent(); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A\n' }); + + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['slice-a'], + }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-2', + sliceIds: ['slice-a'], + }); + + expect(existsSync(join(result.epicSandboxDir, 'src/a.ts'))).toBe(true); + expect(existsSync(join(result.epicSandboxDir, 'epic-1'))).toBe(false); + }); + + it('ignores symlinks when walking slice worktree files', () => { + const parent = makeParent(); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A\n' }); + writeFileSync(join(parent, 'outside.ts'), 'OUT\n'); + symlinkSync(join(parent, 'outside.ts'), join(parent, 'slice-a', 'escape.link')); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-a', + sliceIds: ['slice-a'], + }); + + expect(existsSync(join(result.epicSandboxDir, 'src/a.ts'))).toBe(true); + expect(existsSync(join(result.epicSandboxDir, 'escape.link'))).toBe(false); + }); + + it('replaces a file with a directory when later slices need nested paths', () => { + const parent = makeParent(); + seedSlice(parent, 'slice-a', { 'src/x': 'file\n' }); + seedSlice(parent, 'slice-b', { 'src/x/inner.ts': 'inner\n' }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-b', + sliceIds: ['slice-a', 'slice-b'], + }); + + expect(readFileSync(join(result.epicSandboxDir, 'src/x/inner.ts'), 'utf8')).toBe('inner\n'); + expect(result.conflicts).toEqual([]); + }); + + it('merges txt-like scaffolding + text-ops without intra-epic slice collisions', () => { + const parent = makeParent(); + seedSlice(parent, 'version-flag', { + 'src/cli.ts': 'version\n', + 'tests/version.test.ts': 'v\n', + }); + seedSlice(parent, 'help-flag', { + 'src/cli.ts': 'version + help\n', + 'tests/help.test.ts': 'h\n', + }); + seedSlice(parent, 'reverse', { + 'src/cli.ts': 'version + help + reverse\n', + 'tests/reverse.test.ts': 'r\n', + }); + seedSlice(parent, 'count', { 'tests/count.test.ts': 'c\n' }); + seedSlice(parent, 'slugify', { 'tests/slugify.test.ts': 's\n' }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'text-ops', + sliceIds: sliceIdsForEpicVerifyMerge(txtLikePlan, 'text-ops'), + }); + + expect(readFileSync(join(result.epicSandboxDir, 'src/cli.ts'), 'utf8')).toBe( + 'version + help + reverse\n', + ); + expect(result.conflicts).toEqual([ + { path: 'src/cli.ts', slices: ['version-flag', 'help-flag', 'reverse'], winner: 'reverse' }, + ]); + expect(existsSync(join(result.epicSandboxDir, 'tests/version.test.ts'))).toBe(true); + expect(existsSync(join(result.epicSandboxDir, 'tests/slugify.test.ts'))).toBe(true); + }); +}); diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts new file mode 100644 index 00000000..e026e7ab --- /dev/null +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -0,0 +1,258 @@ +// Materialize `/__epic__//` as the union of completed +// slice worktrees at `//`. Sources apply in plan +// declaration order among included slices; later slices overwrite earlier ones +// on the same path and the collision is reported. Source worktrees are not +// mutated. The verify dir is rebuilt fresh on every call. + +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; + +import type { Plan, Slice } from './types.js'; + +export type MergeConflict = { + path: string; + slices: string[]; + winner: string; +}; + +export type MergeResult = { + epicSandboxDir: string; + conflicts: MergeConflict[]; +}; + +export type MergeOptions = { + /** Parent worktree dir holding slice sandboxes at `/`. */ + parentSandboxDir: string; + epicId: string; + /** Slice ids to merge in plan declaration order. */ + sliceIds: string[]; +}; + +/** Epic ids whose slice worktrees participate in verify-epic for `epicId`. */ +export function epicIdsForEpicVerifyMerge(plan: Plan, epicId: string): string[] { + const epicIds = new Set(); + + const visitEpic = (id: string) => { + if (epicIds.has(id)) return; + const epic = plan.epics.find((e) => e.id === id); + if (!epic) return; + for (const dep of epic.depends_on) visitEpic(dep); + epicIds.add(id); + }; + + const visitedSliceDeps = new Set(); + const visitSliceDeps = (sliceId: string) => { + if (visitedSliceDeps.has(sliceId)) return; + visitedSliceDeps.add(sliceId); + const slice = plan.slices.find((s) => s.id === sliceId); + if (!slice) return; + visitEpic(slice.epic_id); + for (const depId of slice.depends_on) visitSliceDeps(depId); + }; + + visitEpic(epicId); + for (const slice of plan.slices.filter((s) => s.epic_id === epicId)) { + for (const depId of slice.depends_on) visitSliceDeps(depId); + } + + return plan.epics.filter((e) => epicIds.has(e.id)).map((e) => e.id); +} + +/** Slice ids to merge before verify-epic: deps then target epic, plan declaration order. */ +export function sliceIdsForEpicVerifyMerge(plan: Plan, epicId: string): string[] { + const epicIds = epicIdsForEpicVerifyMerge(plan, epicId); + const epicOrder = new Map(epicIds.map((id, i) => [id, i])); + return plan.slices.filter((s) => epicOrder.has(s.epic_id)).map((s) => s.id); +} + +/** Reserved under the parent sandbox for merged epic verify trees. */ +const EPIC_MERGE_SEGMENT = '__epic__'; + +function assertSafePathSegment(id: string, label: string): void { + if (!id || id.includes('..') || id.includes('/') || id.includes('\\')) { + throw new Error(`Invalid ${label}: ${id}`); + } + if (id === EPIC_MERGE_SEGMENT) { + throw new Error(`Invalid ${label}: ${id}`); + } +} + +function resolveEpicSandboxDir(parentSandboxDir: string, epicId: string): string { + assertSafePathSegment(epicId, 'epic id'); + const parent = resolve(parentSandboxDir); + const epicRoot = resolve(parent, EPIC_MERGE_SEGMENT); + const dir = resolve(epicRoot, epicId); + if (dir === parent || !dir.startsWith(epicRoot + sep)) { + throw new Error(`Invalid epic id: ${epicId}`); + } + return dir; +} + +export function resolveSliceWorktreeDir(parentSandboxDir: string, sliceId: string): string { + assertSafePathSegment(sliceId, 'slice id'); + const parent = resolve(parentSandboxDir); + const dir = resolve(parent, sliceId); + if (dir === parent || !dir.startsWith(parent + sep)) { + throw new Error(`Invalid slice id: ${sliceId}`); + } + return dir; +} + +function relativePathWithin(rootDir: string, file: string): string { + const rel = relative(rootDir, file); + if (!rel || rel.startsWith('..') || rel.split(sep).includes('..')) { + throw new Error(`Path escapes slice sandbox: ${file}`); + } + return rel; +} + +function prepareDestForFile(treeRoot: string, dest: string): void { + const root = resolve(treeRoot); + const dir = dirname(resolve(dest)); + if (dir !== root && !dir.startsWith(root + sep)) { + throw new Error(`Path escapes sandbox: ${dest}`); + } + + const relDir = relative(root, dir); + if (relDir && relDir !== '.') { + let current = root; + for (const part of relDir.split(sep)) { + current = join(current, part); + if (existsSync(current) && !lstatSync(current).isDirectory()) { + rmSync(current, { force: true }); + mkdirSync(current); + } + } + } + + mkdirSync(dir, { recursive: true }); +} + +function copyIntoTree(src: string, dest: string, treeRoot: string): void { + prepareDestForFile(treeRoot, dest); + if (existsSync(dest) && lstatSync(dest).isDirectory()) { + rmSync(dest, { recursive: true, force: true }); + } + cpSync(src, dest, { dereference: false }); +} + +export type SeedSliceSandboxOptions = { + /** Keep slice-owned paths; only add missing dependency files (post-action test/assess). */ + preserveExisting?: boolean; +}; + +/** Dependency slice ids in plan declaration order (matches epic verify merge). */ +function depSliceIdsInPlanOrder(plan: Plan, slice: Slice): string[] { + const order = new Map(plan.slices.map((s, i) => [s.id, i])); + return [...slice.depends_on].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)); +} + +function collectDepFiles(parentSandboxDir: string, plan: Plan, slice: Slice): Map { + const depFiles = new Map(); + for (const depId of depSliceIdsInPlanOrder(plan, slice)) { + const depDir = resolveSliceWorktreeDir(parentSandboxDir, depId); + if (!existsSync(depDir)) continue; + + for (const file of walkFiles(depDir)) { + const rel = relativePathWithin(depDir, file); + depFiles.set(rel, file); + } + } + return depFiles; +} + +function pruneEmptyDirs(rootDir: string, dir: string = rootDir): void { + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + if (lstatSync(abs).isDirectory()) { + pruneEmptyDirs(rootDir, abs); + } + } + if (dir !== rootDir && readdirSync(dir).length === 0) { + rmSync(dir); + } +} + +/** Copy completed dependency slice worktrees into `slice`'s sandbox (plan order). */ +export function seedSliceSandboxFromDeps( + parentSandboxDir: string, + plan: Plan, + slice: Slice, + opts?: SeedSliceSandboxOptions, +): string { + const preserveExisting = opts?.preserveExisting ?? false; + const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, slice.id); + mkdirSync(sliceDir, { recursive: true }); + + const depFiles = collectDepFiles(parentSandboxDir, plan, slice); + + if (!preserveExisting && depFiles.size > 0 && existsSync(sliceDir)) { + for (const file of walkFiles(sliceDir)) { + const rel = relativePathWithin(sliceDir, file); + if (!depFiles.has(rel)) { + rmSync(file, { force: true }); + } + } + pruneEmptyDirs(sliceDir); + } + + for (const [rel, src] of depFiles) { + const dest = join(sliceDir, rel); + if (preserveExisting && existsSync(dest)) continue; + copyIntoTree(src, dest, sliceDir); + } + + return sliceDir; +} + +export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { + const epicSandboxDir = resolveEpicSandboxDir(opts.parentSandboxDir, opts.epicId); + + if (existsSync(epicSandboxDir)) { + rmSync(epicSandboxDir, { recursive: true, force: true }); + } + mkdirSync(epicSandboxDir, { recursive: true }); + + const writers = new Map(); + const parent = resolve(opts.parentSandboxDir); + const epicRoot = resolve(parent, EPIC_MERGE_SEGMENT); + + for (const sliceId of opts.sliceIds) { + const sliceDir = resolveSliceWorktreeDir(opts.parentSandboxDir, sliceId); + if (sliceDir === epicRoot || sliceDir.startsWith(epicRoot + sep)) continue; + if (!existsSync(sliceDir)) continue; + + for (const file of walkFiles(sliceDir)) { + const rel = relativePathWithin(sliceDir, file); + const list = writers.get(rel) ?? []; + list.push(sliceId); + writers.set(rel, list); + + const dest = join(epicSandboxDir, rel); + copyIntoTree(file, dest, epicSandboxDir); + } + } + + const conflicts: MergeConflict[] = []; + for (const [path, slices] of writers) { + if (slices.length > 1) { + conflicts.push({ path, slices, winner: slices[slices.length - 1]! }); + } + } + conflicts.sort((a, b) => a.path.localeCompare(b.path)); + + return { epicSandboxDir, conflicts }; +} + +function* walkFiles(rootDir: string, dir: string = rootDir): Iterable { + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + const st = lstatSync(abs); + if (st.isSymbolicLink()) continue; + if (st.isDirectory()) { + yield* walkFiles(rootDir, abs); + } else if (st.isFile()) { + yield abs; + } + } +} diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index be74f9f6..9a2100ce 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -6,8 +6,13 @@ // --------------------------------------------------------------------------- import { mkdirSync } from 'node:fs'; -import { resolve, sep } from 'node:path'; +import { + mergeSlicesIntoEpicSandbox, + resolveSliceWorktreeDir, + seedSliceSandboxFromDeps, + sliceIdsForEpicVerifyMerge, +} from './epic-sandbox-merge.js'; import type { NetBlueprint, TokenSeed, TransitionSkeleton } from './net-blueprint.js'; import { PetriNet } from './petri-net.js'; import type { Token } from './petri-net.js'; @@ -26,19 +31,6 @@ function ep(epicId: string, place: string): string { return `epic:${epicId}:${place}`; } -/** Resolve a per-slice sandbox under the run root; reject path-escape ids. */ -function sliceSandboxDir(rootSandboxDir: string, sliceId: string): string { - if (!sliceId || sliceId.includes('..') || sliceId.includes('/') || sliceId.includes('\\')) { - throw new Error(`Invalid slice id: ${sliceId}`); - } - const root = resolve(rootSandboxDir); - const dir = resolve(root, sliceId); - if (dir !== root && !dir.startsWith(root + sep)) { - throw new Error(`Invalid slice id: ${sliceId}`); - } - return dir; -} - // --------------------------------------------------------------------------- // Pass 1 — compileTopology: pure function, no closures over runtime state. // Same Plan + Policy → same blueprint. Trivially snapshot-testable. @@ -336,9 +328,9 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, net.addPlace(place); } - // Create per-slice sandbox directories + // Create per-slice sandbox directories (parallel-safe; deps seeded at fire time) for (const slice of plan.slices) { - mkdirSync(sliceSandboxDir(input.sandboxDir, slice.id), { recursive: true }); + mkdirSync(resolveSliceWorktreeDir(input.sandboxDir, slice.id), { recursive: true }); } // Register transitions with wired fire handlers @@ -358,16 +350,18 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const { actionKey, sliceId, epicId, routeField, onTrue, onFalse, agentReturnPlace } = h; const slice = plan.slices.find((s) => s.id === sliceId)!; const epic = plan.epics.find((e) => e.id === epicId)!; - const actCtx: ActionContext = { - slice, - epic, - plan, - sandboxDir: sliceSandboxDir(input.sandboxDir, sliceId), - reports, - }; const baseToken: Token = { sliceId, epicId }; fire = async (consumed) => { + const actCtx: ActionContext = { + slice, + epic, + plan, + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { + preserveExisting: true, + }), + reports, + }; const reportId = await actions[actionKey]!(actCtx); ctx.reportIds.push(reportId); const tok: Token = { ...consumed[0]!, reportId }; @@ -398,7 +392,11 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const retryToken = consumed[1]!; const retryCount = retryToken.retryCount ?? 0; - const result = await testRunner.run(target, sliceSandboxDir(input.sandboxDir, sliceId)); + const slice = plan.slices.find((s) => s.id === sliceId)!; + const sandboxDir = seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { + preserveExisting: true, + }); + const result = await testRunner.run(target, sandboxDir); const reportId = createReport(reports, { epicId, sliceId, @@ -433,19 +431,21 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const { actionKey, sliceId, epicId, onSatisfied, onRejected, budgetPlace, maxReworks } = h; const slice = plan.slices.find((s) => s.id === sliceId)!; const epic = plan.epics.find((e) => e.id === epicId)!; - const actCtx: ActionContext = { - slice, - epic, - plan, - sandboxDir: sliceSandboxDir(input.sandboxDir, sliceId), - reports, - }; const baseToken: Token = { sliceId, epicId }; fire = async (consumed) => { const budgetToken = consumed[1]!; const reworkCount = budgetToken.reworkCount ?? 0; + const actCtx: ActionContext = { + slice, + epic, + plan, + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { + preserveExisting: true, + }), + reports, + }; const reportId = await actions[actionKey]!(actCtx); ctx.reportIds.push(reportId); const report = reports.getById(reportId); @@ -500,18 +500,40 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const { actionKey, epicId, representativeSliceId, onPassOutputs } = h; const epic = plan.epics.find((e) => e.id === epicId)!; const slice = plan.slices.find((s) => s.id === representativeSliceId)!; - // Epic verification runs against the parent sandbox (not a per-slice dir) - // so it can see artifacts from all slices. TODO: merge per-slice sandboxes - // into an epic-scoped dir once parallel slice isolation is production-ready. - const actCtx: ActionContext = { - slice, - epic, - plan, - sandboxDir: input.sandboxDir, - reports, - }; + // Epic verification runs against a freshly-merged `__epic__//` + // dir built from completed slice worktrees (cross-epic slice deps included). + const sliceIdsInMergeOrder = sliceIdsForEpicVerifyMerge(plan, epicId); fire = async () => { + const mergeSliceIds = sliceIdsInMergeOrder.filter( + (sid) => ctx.sliceOutcomes.get(sid)?.status === 'completed', + ); + const merge = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: input.sandboxDir, + epicId, + sliceIds: mergeSliceIds, + }); + ctx.reportIds.push( + createReport(reports, { + epicId, + sliceId: '', + actor: 'orchestrator', + event: 'epic-sandbox-merged', + payload: { + epicSandboxDir: merge.epicSandboxDir, + sliceIds: mergeSliceIds, + conflicts: merge.conflicts, + }, + }), + ); + + const actCtx: ActionContext = { + slice, + epic, + plan, + sandboxDir: merge.epicSandboxDir, + reports, + }; const reportId = await actions[actionKey]!(actCtx); ctx.reportIds.push(reportId); const report = reports.getById(reportId); diff --git a/src/server/cli.ts b/src/server/cli.ts index fdc4c030..c09572fc 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -1,5 +1,9 @@ #!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { runAgentJsonlSession } from './agent-jsonl.js'; import { createDb } from './db.js'; import { launch } from './launcher.js'; @@ -12,6 +16,13 @@ const launchCwd = process.env.BRUNCH_LAUNCH_CWD || process.cwd(); loadLocalEnvFile(launchCwd); +if (rawArgs[0] === '--version' || rawArgs[0] === '-V') { + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json'); + const { version } = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; + console.log(version); + process.exit(0); +} + if (args.has('--help') || args.has('-h') || args.has('help')) { console.log('Usage: brunch [command]'); console.log(''); diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 00000000..f7022dad --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,77 @@ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'bun:test'; + +// When this file lives at tests/version.test.ts, two dirnames reach the package root. +// The worktree is a full git checkout of brunch, so package.json and src/ are present. +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const cliEntrypoint = join(packageRoot, 'src', 'server', 'cli.ts'); +const packageJsonPath = join(packageRoot, 'package.json'); + +type CommandResult = { + code: number | null; + stderr: string; + stdout: string; +}; + +function runCli(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn('bun', [cliEntrypoint, ...args], { + cwd: packageRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.once('error', reject); + child.once('close', (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +function getPackageVersion(): string { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version: string }; + return pkg.version; +} + +describe('--version flag', () => { + it('exits with code 0 when --version is passed', async () => { + const result = await runCli(['--version']); + expect(result.code).toBe(0); + }, 10_000); + + it('prints the version from package.json to stdout', async () => { + const version = getPackageVersion(); + const result = await runCli(['--version']); + expect(result.stdout).toContain(version); + }, 10_000); + + it('prints nothing to stderr when --version is passed', async () => { + const result = await runCli(['--version']); + expect(result.stderr).toBe(''); + }, 10_000); + + it('does not launch the web server when --version is passed', async () => { + const result = await runCli(['--version']); + expect(result.stdout).not.toContain('Brunch running at'); + expect(result.stdout).not.toContain('localhost'); + }, 10_000); + + it('output consists only of the version string (no extra noise)', async () => { + const version = getPackageVersion(); + const result = await runCli(['--version']); + expect(result.stdout.trim()).toBe(version); + }, 10_000); +});