From 71acedad57796bad1d001b19183005f295e34aa6 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Fri, 22 May 2026 17:44:28 +0200 Subject: [PATCH 01/10] FE-745: merge slice worktrees into epic-scoped dir for verify-epic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-epic now runs against a freshly-merged /__epic__// built from the epic's completed slice worktrees. Slices apply in plan declaration order; later slices overwrite earlier ones on the same path and the collision is reported via the new epic-sandbox-merged event. Per-slice worktrees are not mutated. Unblocks multi-slice cook runs (verify-epic previously fell back to the empty parent worktree root, halting every multi-slice plan). - new src/orchestrator/src/epic-sandbox-merge.{ts,test.ts} - net-compiler.ts verify-epic case wires the merger + emits epic-sandbox-merged - engine-contract.test.ts: 'verify-epic receives parent sandboxDir' assertion updated to pin the new __epic__// invariant - memory/SPEC.md: new I124-K invariant alongside I123-K - memory/PLAN.md: petri-epic-verification-merge → Recently Completed; bullet retired from petri-graph-compilation (no longer blocked on FE-700) --- memory/PLAN.md | 3 +- memory/SPEC.md | 1 + src/orchestrator/src/engine-contract.test.ts | 65 ++++++--- .../src/epic-sandbox-merge.test.ts | 124 ++++++++++++++++++ src/orchestrator/src/epic-sandbox-merge.ts | 76 +++++++++++ src/orchestrator/src/net-compiler.ts | 43 ++++-- src/server/cli.ts | 11 ++ 7 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 src/orchestrator/src/epic-sandbox-merge.test.ts create mode 100644 src/orchestrator/src/epic-sandbox-merge.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..da2a5d05 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'; @@ -1112,7 +1116,7 @@ describe('Adapter: sandbox-per-slice isolation', () => { expect(sandboxDirs.size).toBeGreaterThanOrEqual(2); }); - it('verify-epic receives the parent sandboxDir (not per-slice)', async () => { + it('verify-epic receives a merged epic sandbox under /__epic__// (not per-slice, not parent)', async () => { const verifyPlan: Plan = { epics: [ { @@ -1133,27 +1137,46 @@ describe('Adapter: sandbox-per-slice isolation', () => { ], }; - 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 parent = mkdtempSync(join(tmpdir(), 'cook-ec-')); + try { + // Seed the per-slice worktree with a file so the merge has something to copy. + mkdirSync(join(parent, 'sv'), { recursive: true }); + writeFileSync(join(parent, 'sv', 'sv-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: '/tmp/run', - actions: trackingActions, - reports: fakes.reports, - testRunner: fakes.testRunner, - policy: { maxRetries: 3 }, - }); + 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'); - // verify-epic gets the parent sandbox, not /tmp/run/sv - expect(verifyEpicSandboxDir).toBe('/tmp/run'); + expect(result.status).toBe('completed'); + expect(verifyEpicSandboxDir).toBe(join(parent, '__epic__', 'ev')); + // Merge produced a real dir holding the slice's seed file. + expect(existsSync(join(verifyEpicSandboxDir, 'sv-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..a20e48d9 --- /dev/null +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -0,0 +1,124 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { mergeSlicesIntoEpicSandbox } from './epic-sandbox-merge.js'; + +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 into a fresh epic sandbox', () => { + const parent = makeParent(); + seedSlice(parent, 'a', { 'src/a.ts': 'export const a = 1;\n' }); + seedSlice(parent, 'b', { 'src/b.ts': 'export const b = 2;\n' }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['a', 'b'], + }); + + const expected = join(parent, '__epic__', 'epic-1'); + 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 declaration order (last slice wins) and reports them', () => { + const parent = makeParent(); + seedSlice(parent, 'a', { 'src/x.ts': 'A\n' }); + seedSlice(parent, 'b', { 'src/x.ts': 'B\n' }); + seedSlice(parent, 'c', { 'src/x.ts': 'C\n' }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['a', 'b', 'c'], + }); + + expect(readFileSync(join(result.epicSandboxDir, 'src/x.ts'), 'utf8')).toBe('C\n'); + expect(result.conflicts).toEqual([{ path: 'src/x.ts', slices: ['a', 'b', 'c'], winner: 'c' }]); + }); + + it('leaves per-slice worktrees byte-identical after merge', () => { + const parent = makeParent(); + seedSlice(parent, 'a', { 'src/a.ts': 'A\n', 'tests/a.test.ts': 'TA\n' }); + seedSlice(parent, 'b', { 'src/a.ts': 'B\n' }); + + const before = { + aSrc: readFileSync(join(parent, 'a', 'src/a.ts'), 'utf8'), + aTests: readFileSync(join(parent, 'a', 'tests/a.test.ts'), 'utf8'), + bSrc: readFileSync(join(parent, 'b', 'src/a.ts'), 'utf8'), + }; + + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['a', 'b'], + }); + + expect(readFileSync(join(parent, 'a', 'src/a.ts'), 'utf8')).toBe(before.aSrc); + expect(readFileSync(join(parent, 'a', 'tests/a.test.ts'), 'utf8')).toBe(before.aTests); + expect(readFileSync(join(parent, 'b', 'src/a.ts'), 'utf8')).toBe(before.bSrc); + }); + + it('rebuilds the epic sandbox fresh on every call (no cruft from prior merge)', () => { + const parent = makeParent(); + seedSlice(parent, 'a', { 'src/a.ts': 'A1\n', 'src/stale.ts': 'stale\n' }); + + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['a'], + }); + + rmSync(join(parent, 'a', 'src/stale.ts')); + const second = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['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, 'a', { 'src/a.ts': 'A\n' }); + // slice "b" never created its worktree + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['a', 'b'], + }); + + expect(existsSync(join(result.epicSandboxDir, 'src/a.ts'))).toBe(true); + expect(result.conflicts).toEqual([]); + }); +}); diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts new file mode 100644 index 00000000..70e58d5e --- /dev/null +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -0,0 +1,76 @@ +// Materialize `/__epic__//` as the union of the +// epic's completed slice worktrees. Slices apply in declaration order; later +// slices overwrite earlier ones on the same path and the collision is +// reported. Per-slice worktrees are not mutated. The epic dir is rebuilt +// fresh on every call. + +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +export type MergeConflict = { + path: string; + slices: string[]; + winner: string; +}; + +export type MergeResult = { + epicSandboxDir: string; + conflicts: MergeConflict[]; +}; + +export type MergeOptions = { + /** Parent worktree dir holding per-slice sandboxes at `/`. */ + parentSandboxDir: string; + epicId: string; + /** Completed slices in epic declaration order. */ + sliceIds: string[]; +}; + +export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { + const epicSandboxDir = join(opts.parentSandboxDir, '__epic__', opts.epicId); + + if (existsSync(epicSandboxDir)) { + rmSync(epicSandboxDir, { recursive: true, force: true }); + } + mkdirSync(epicSandboxDir, { recursive: true }); + + const writers = new Map(); + + for (const sliceId of opts.sliceIds) { + const sliceDir = join(opts.parentSandboxDir, sliceId); + if (!existsSync(sliceDir)) continue; + + for (const file of walkFiles(sliceDir)) { + const rel = relative(sliceDir, file); + const list = writers.get(rel) ?? []; + list.push(sliceId); + writers.set(rel, list); + + const dest = join(epicSandboxDir, rel); + mkdirSync(join(dest, '..'), { recursive: true }); + cpSync(file, dest, { dereference: false }); + } + } + + 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(dir: string): Iterable { + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + const st = statSync(abs); + if (st.isDirectory()) { + yield* walkFiles(abs); + } else { + yield abs; + } + } +} diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index be74f9f6..c87a2c55 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -8,6 +8,7 @@ import { mkdirSync } from 'node:fs'; import { resolve, sep } from 'node:path'; +import { mergeSlicesIntoEpicSandbox } 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'; @@ -500,18 +501,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 this epic's completed slice worktrees. + const sliceIdsInDeclOrder = plan.slices.filter((s) => s.epic_id === epicId).map((s) => s.id); fire = async () => { + const completedSliceIds = sliceIdsInDeclOrder.filter( + (sid) => ctx.sliceOutcomes.get(sid)?.status === 'completed', + ); + const merge = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: input.sandboxDir, + epicId, + sliceIds: completedSliceIds, + }); + ctx.reportIds.push( + createReport(reports, { + epicId, + sliceId: '', + actor: 'orchestrator', + event: 'epic-sandbox-merged', + payload: { + epicSandboxDir: merge.epicSandboxDir, + sliceIds: completedSliceIds, + 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..902aa8f9 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 (args.has('--version') || args.has('-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(''); From 783d3fc80ae7fcafdbc38d933ce50813cd272650 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Mon, 25 May 2026 12:25:56 +0200 Subject: [PATCH 02/10] Fix bot review issues on epic sandbox merge and CLI version flag. Validate epic/slice ids and relative paths, skip symlinks during merge walks, handle file/directory collisions safely, and only honor --version as a top-level flag. Co-authored-by: Cursor --- .../src/epic-sandbox-merge.test.ts | 52 ++++++++++- src/orchestrator/src/epic-sandbox-merge.ts | 87 ++++++++++++++++--- src/server/cli.ts | 2 +- 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index a20e48d9..dbf461d9 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -121,4 +129,46 @@ describe('mergeSlicesIntoEpicSandbox', () => { 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('ignores symlinks when walking slice files', () => { + const parent = makeParent(); + seedSlice(parent, 'a', { 'src/a.ts': 'A\n' }); + writeFileSync(join(parent, 'outside.ts'), 'OUT\n'); + symlinkSync(join(parent, 'outside.ts'), join(parent, 'a', 'escape.link')); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['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, 'a', { 'src/x': 'file\n' }); + seedSlice(parent, 'b', { 'src/x/inner.ts': 'inner\n' }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['a', 'b'], + }); + + expect(readFileSync(join(result.epicSandboxDir, 'src/x/inner.ts'), 'utf8')).toBe('inner\n'); + expect(result.conflicts).toEqual([]); + }); }); diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index 70e58d5e..f6539fb2 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -4,8 +4,8 @@ // reported. Per-slice worktrees are not mutated. The epic dir is rebuilt // fresh on every call. -import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; export type MergeConflict = { path: string; @@ -26,8 +26,73 @@ export type MergeOptions = { sliceIds: string[]; }; +function assertSafePathSegment(id: string, label: string): void { + if (!id || id.includes('..') || id.includes('/') || id.includes('\\')) { + 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__'); + const dir = resolve(epicRoot, epicId); + if (dir === parent || !dir.startsWith(epicRoot + sep)) { + throw new Error(`Invalid epic id: ${epicId}`); + } + return dir; +} + +function resolveSliceDir(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(epicRoot: string, dest: string): void { + const root = resolve(epicRoot); + const dir = dirname(resolve(dest)); + if (dir !== root && !dir.startsWith(root + sep)) { + throw new Error(`Path escapes epic 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 copyIntoEpicSandbox(src: string, dest: string, epicRoot: string): void { + prepareDestForFile(epicRoot, dest); + if (existsSync(dest) && lstatSync(dest).isDirectory()) { + rmSync(dest, { recursive: true, force: true }); + } + cpSync(src, dest, { dereference: false }); +} + export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { - const epicSandboxDir = join(opts.parentSandboxDir, '__epic__', opts.epicId); + const epicSandboxDir = resolveEpicSandboxDir(opts.parentSandboxDir, opts.epicId); if (existsSync(epicSandboxDir)) { rmSync(epicSandboxDir, { recursive: true, force: true }); @@ -37,18 +102,17 @@ export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { const writers = new Map(); for (const sliceId of opts.sliceIds) { - const sliceDir = join(opts.parentSandboxDir, sliceId); + const sliceDir = resolveSliceDir(opts.parentSandboxDir, sliceId); if (!existsSync(sliceDir)) continue; for (const file of walkFiles(sliceDir)) { - const rel = relative(sliceDir, file); + const rel = relativePathWithin(sliceDir, file); const list = writers.get(rel) ?? []; list.push(sliceId); writers.set(rel, list); const dest = join(epicSandboxDir, rel); - mkdirSync(join(dest, '..'), { recursive: true }); - cpSync(file, dest, { dereference: false }); + copyIntoEpicSandbox(file, dest, epicSandboxDir); } } @@ -63,13 +127,14 @@ export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { return { epicSandboxDir, conflicts }; } -function* walkFiles(dir: string): Iterable { +function* walkFiles(rootDir: string, dir: string = rootDir): Iterable { for (const entry of readdirSync(dir)) { const abs = join(dir, entry); - const st = statSync(abs); + const st = lstatSync(abs); + if (st.isSymbolicLink()) continue; if (st.isDirectory()) { - yield* walkFiles(abs); - } else { + yield* walkFiles(rootDir, abs); + } else if (st.isFile()) { yield abs; } } diff --git a/src/server/cli.ts b/src/server/cli.ts index 902aa8f9..c09572fc 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -16,7 +16,7 @@ const launchCwd = process.env.BRUNCH_LAUNCH_CWD || process.cwd(); loadLocalEnvFile(launchCwd); -if (args.has('--version') || args.has('-V')) { +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); From 5af01445685550e92b35e5297373851c9dee6bf6 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Mon, 25 May 2026 12:43:39 +0200 Subject: [PATCH 03/10] Reject reserved __epic__ slice id to prevent merge poisoning. Slice sandboxes must not collide with the epic merge root; skip any accidental epic-root paths during merge and mirror the guard in net-compiler. Co-authored-by: Cursor --- .../src/epic-sandbox-merge.test.ts | 31 +++++++++++++++++++ src/orchestrator/src/epic-sandbox-merge.ts | 11 ++++++- src/orchestrator/src/net-compiler.ts | 3 ++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index dbf461d9..5c63199e 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -141,6 +141,37 @@ describe('mergeSlicesIntoEpicSandbox', () => { ).toThrow(/Invalid epic id/); }); + it('rejects reserved __epic__ slice id', () => { + const parent = makeParent(); + expect(() => + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['__epic__'], + }), + ).toThrow(/Invalid slice id: __epic__/); + }); + + it('does not nest other epic merge dirs into the verify sandbox', () => { + const parent = makeParent(); + seedSlice(parent, 'a', { 'src/a.ts': 'A\n' }); + + mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-1', + sliceIds: ['a'], + }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'epic-2', + sliceIds: ['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 files', () => { const parent = makeParent(); seedSlice(parent, 'a', { 'src/a.ts': 'A\n' }); diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index f6539fb2..3db08b55 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -26,16 +26,22 @@ export type MergeOptions = { sliceIds: string[]; }; +/** 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 (label === 'slice id' && id === EPIC_MERGE_SEGMENT) { + throw new Error(`Invalid slice id: ${id}`); + } } function resolveEpicSandboxDir(parentSandboxDir: string, epicId: string): string { assertSafePathSegment(epicId, 'epic id'); const parent = resolve(parentSandboxDir); - const epicRoot = resolve(parent, '__epic__'); + 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}`); @@ -100,9 +106,12 @@ export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { 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 = resolveSliceDir(opts.parentSandboxDir, sliceId); + if (sliceDir === epicRoot || sliceDir.startsWith(epicRoot + sep)) continue; if (!existsSync(sliceDir)) continue; for (const file of walkFiles(sliceDir)) { diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index c87a2c55..1537c12c 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -32,6 +32,9 @@ function sliceSandboxDir(rootSandboxDir: string, sliceId: string): string { if (!sliceId || sliceId.includes('..') || sliceId.includes('/') || sliceId.includes('\\')) { throw new Error(`Invalid slice id: ${sliceId}`); } + if (sliceId === '__epic__') { + throw new Error(`Invalid slice id: ${sliceId}`); + } const root = resolve(rootSandboxDir); const dir = resolve(root, sliceId); if (dir !== root && !dir.startsWith(root + sep)) { From 38ee1c9d93527ce693c0ddca3bd857dcfd82fe01 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Mon, 25 May 2026 17:13:23 +0200 Subject: [PATCH 04/10] FE-745: epic-scoped sandboxes and dependency-aware verify merge Defer scaffolding epic verify in fixtures/txt plan and move CLI integration tests to text-ops. Merge verify-epic from worktree// trees in plan order (target plus transitive deps) so slice work accumulates per epic instead of per-slice last-winner overwrites such as cli.ts. Co-authored-by: Cursor --- fixtures/txt/plan.yaml | 8 +- src/orchestrator/src/engine-contract.test.ts | 25 +-- .../src/epic-sandbox-merge.test.ts | 173 ++++++++++++------ src/orchestrator/src/epic-sandbox-merge.ts | 66 ++++--- src/orchestrator/src/net-compiler.ts | 45 ++--- tests/version.test.ts | 77 ++++++++ 6 files changed, 274 insertions(+), 120 deletions(-) create mode 100644 tests/version.test.ts 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/src/orchestrator/src/engine-contract.test.ts b/src/orchestrator/src/engine-contract.test.ts index da2a5d05..fbec8eda 100644 --- a/src/orchestrator/src/engine-contract.test.ts +++ b/src/orchestrator/src/engine-contract.test.ts @@ -1080,11 +1080,11 @@ describe('Engine contract test #13 — resource pool bounds concurrency', () => }); // --------------------------------------------------------------------------- -// Adapter test — sandbox-per-slice isolation +// Adapter test — sandbox-per-epic isolation // --------------------------------------------------------------------------- -describe('Adapter: sandbox-per-slice isolation', () => { - it('each action handler receives a per-slice sandboxDir', async () => { +describe('Adapter: sandbox-per-epic isolation', () => { + it('each action handler receives an epic-scoped sandboxDir shared by slices in the epic', async () => { const sandboxDirs = new Map(); const fakes = createFakes({ evalSequence: [true], semanticResults: [true] }); @@ -1107,16 +1107,17 @@ describe('Adapter: sandbox-per-slice isolation', () => { }); expect(result.status).toBe('completed'); - // Every action should receive sandboxDir = /tmp/run/ + // 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(dir).toBe('/tmp/run/epic-1'); + 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 a merged epic sandbox under /__epic__// (not per-slice, not parent)', async () => { + it('verify-epic receives a merged epic sandbox under /__epic__// (not epic worktree, not parent)', async () => { const verifyPlan: Plan = { epics: [ { @@ -1139,9 +1140,9 @@ describe('Adapter: sandbox-per-slice isolation', () => { const parent = mkdtempSync(join(tmpdir(), 'cook-ec-')); try { - // Seed the per-slice worktree with a file so the merge has something to copy. - mkdirSync(join(parent, 'sv'), { recursive: true }); - writeFileSync(join(parent, 'sv', 'sv-marker.txt'), 'from-slice-sv'); + // Seed the epic worktree with a file so the merge has something to copy. + mkdirSync(join(parent, 'ev'), { recursive: true }); + writeFileSync(join(parent, 'ev', 'ev-marker.txt'), 'from-epic-ev'); let verifyEpicSandboxDir = ''; const fakes = createFakes({ evalSequence: [true], semanticResults: [true], verifyEpicResult: true }); @@ -1164,15 +1165,15 @@ describe('Adapter: sandbox-per-slice isolation', () => { expect(result.status).toBe('completed'); expect(verifyEpicSandboxDir).toBe(join(parent, '__epic__', 'ev')); - // Merge produced a real dir holding the slice's seed file. - expect(existsSync(join(verifyEpicSandboxDir, 'sv-marker.txt'))).toBe(true); + // Merge produced a real dir holding the epic worktree seed file. + expect(existsSync(join(verifyEpicSandboxDir, 'ev-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'], + epicIds: ['ev'], conflicts: [], }); } finally { diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index 5c63199e..38c687e5 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -12,7 +12,32 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { mergeSlicesIntoEpicSandbox } from './epic-sandbox-merge.js'; +import { epicIdsForEpicVerifyMerge, mergeSlicesIntoEpicSandbox } 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: [], 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: [] }, + ], +}; + +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']); + }); +}); describe('mergeSlicesIntoEpicSandbox', () => { const dirs: string[] = []; @@ -29,101 +54,103 @@ describe('mergeSlicesIntoEpicSandbox', () => { return parent; } - function seedSlice(parent: string, sliceId: string, files: Record): void { - const sliceDir = join(parent, sliceId); + function seedEpic(parent: string, epicId: string, files: Record): void { + const epicDir = join(parent, epicId); for (const [rel, contents] of Object.entries(files)) { - const abs = join(sliceDir, rel); + const abs = join(epicDir, rel); mkdirSync(join(abs, '..'), { recursive: true }); writeFileSync(abs, contents); } } - it('copies disjoint files from each slice into a fresh epic sandbox', () => { + it('copies disjoint files from each epic worktree into a fresh verify sandbox', () => { const parent = makeParent(); - seedSlice(parent, 'a', { 'src/a.ts': 'export const a = 1;\n' }); - seedSlice(parent, 'b', { 'src/b.ts': 'export const b = 2;\n' }); + seedEpic(parent, 'epic-a', { 'src/a.ts': 'export const a = 1;\n' }); + seedEpic(parent, 'epic-b', { 'src/b.ts': 'export const b = 2;\n' }); const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, - epicId: 'epic-1', - sliceIds: ['a', 'b'], + epicId: 'epic-b', + epicIds: ['epic-a', 'epic-b'], }); - const expected = join(parent, '__epic__', 'epic-1'); + 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 declaration order (last slice wins) and reports them', () => { + it('resolves path collisions in epic order (last epic wins) and reports them', () => { const parent = makeParent(); - seedSlice(parent, 'a', { 'src/x.ts': 'A\n' }); - seedSlice(parent, 'b', { 'src/x.ts': 'B\n' }); - seedSlice(parent, 'c', { 'src/x.ts': 'C\n' }); + seedEpic(parent, 'epic-a', { 'src/x.ts': 'A\n' }); + seedEpic(parent, 'epic-b', { 'src/x.ts': 'B\n' }); + seedEpic(parent, 'epic-c', { 'src/x.ts': 'C\n' }); const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, - epicId: 'epic-1', - sliceIds: ['a', 'b', 'c'], + epicId: 'epic-c', + epicIds: ['epic-a', 'epic-b', 'epic-c'], }); expect(readFileSync(join(result.epicSandboxDir, 'src/x.ts'), 'utf8')).toBe('C\n'); - expect(result.conflicts).toEqual([{ path: 'src/x.ts', slices: ['a', 'b', 'c'], winner: 'c' }]); + expect(result.conflicts).toEqual([ + { path: 'src/x.ts', epics: ['epic-a', 'epic-b', 'epic-c'], winner: 'epic-c' }, + ]); }); - it('leaves per-slice worktrees byte-identical after merge', () => { + it('leaves epic worktrees byte-identical after merge', () => { const parent = makeParent(); - seedSlice(parent, 'a', { 'src/a.ts': 'A\n', 'tests/a.test.ts': 'TA\n' }); - seedSlice(parent, 'b', { 'src/a.ts': 'B\n' }); + seedEpic(parent, 'epic-a', { 'src/a.ts': 'A\n', 'tests/a.test.ts': 'TA\n' }); + seedEpic(parent, 'epic-b', { 'src/a.ts': 'B\n' }); const before = { - aSrc: readFileSync(join(parent, 'a', 'src/a.ts'), 'utf8'), - aTests: readFileSync(join(parent, 'a', 'tests/a.test.ts'), 'utf8'), - bSrc: readFileSync(join(parent, 'b', 'src/a.ts'), 'utf8'), + aSrc: readFileSync(join(parent, 'epic-a', 'src/a.ts'), 'utf8'), + aTests: readFileSync(join(parent, 'epic-a', 'tests/a.test.ts'), 'utf8'), + bSrc: readFileSync(join(parent, 'epic-b', 'src/a.ts'), 'utf8'), }; mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, - epicId: 'epic-1', - sliceIds: ['a', 'b'], + epicId: 'epic-b', + epicIds: ['epic-a', 'epic-b'], }); - expect(readFileSync(join(parent, 'a', 'src/a.ts'), 'utf8')).toBe(before.aSrc); - expect(readFileSync(join(parent, 'a', 'tests/a.test.ts'), 'utf8')).toBe(before.aTests); - expect(readFileSync(join(parent, 'b', 'src/a.ts'), 'utf8')).toBe(before.bSrc); + expect(readFileSync(join(parent, 'epic-a', 'src/a.ts'), 'utf8')).toBe(before.aSrc); + expect(readFileSync(join(parent, 'epic-a', 'tests/a.test.ts'), 'utf8')).toBe(before.aTests); + expect(readFileSync(join(parent, 'epic-b', 'src/a.ts'), 'utf8')).toBe(before.bSrc); }); - it('rebuilds the epic sandbox fresh on every call (no cruft from prior merge)', () => { + it('rebuilds the verify sandbox fresh on every call (no cruft from prior merge)', () => { const parent = makeParent(); - seedSlice(parent, 'a', { 'src/a.ts': 'A1\n', 'src/stale.ts': 'stale\n' }); + seedEpic(parent, 'epic-a', { 'src/a.ts': 'A1\n', 'src/stale.ts': 'stale\n' }); mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, - epicId: 'epic-1', - sliceIds: ['a'], + epicId: 'epic-a', + epicIds: ['epic-a'], }); - rmSync(join(parent, 'a', 'src/stale.ts')); + rmSync(join(parent, 'epic-a', 'src/stale.ts')); const second = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, - epicId: 'epic-1', - sliceIds: ['a'], + epicId: 'epic-a', + epicIds: ['epic-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)', () => { + it('skips epics whose worktree does not exist (e.g. halted before any write)', () => { const parent = makeParent(); - seedSlice(parent, 'a', { 'src/a.ts': 'A\n' }); - // slice "b" never created its worktree + seedEpic(parent, 'epic-a', { 'src/a.ts': 'A\n' }); + // epic "epic-b" never created its worktree const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, - epicId: 'epic-1', - sliceIds: ['a', 'b'], + epicId: 'epic-b', + epicIds: ['epic-a', 'epic-b'], }); expect(existsSync(join(result.epicSandboxDir, 'src/a.ts'))).toBe(true); @@ -136,70 +163,100 @@ describe('mergeSlicesIntoEpicSandbox', () => { mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: '..', - sliceIds: [], + epicIds: [], }), ).toThrow(/Invalid epic id/); }); - it('rejects reserved __epic__ slice id', () => { + it('rejects reserved __epic__ as a source epic id', () => { const parent = makeParent(); expect(() => mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-1', - sliceIds: ['__epic__'], + epicIds: ['__epic__'], }), - ).toThrow(/Invalid slice id: __epic__/); + ).toThrow(/Invalid epic id: __epic__/); }); - it('does not nest other epic merge dirs into the verify sandbox', () => { + it('does not nest other verify merge dirs into the verify sandbox', () => { const parent = makeParent(); - seedSlice(parent, 'a', { 'src/a.ts': 'A\n' }); + seedEpic(parent, 'epic-a', { 'src/a.ts': 'A\n' }); mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-1', - sliceIds: ['a'], + epicIds: ['epic-a'], }); const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-2', - sliceIds: ['a'], + epicIds: ['epic-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 files', () => { + it('ignores symlinks when walking epic worktree files', () => { const parent = makeParent(); - seedSlice(parent, 'a', { 'src/a.ts': 'A\n' }); + seedEpic(parent, 'epic-a', { 'src/a.ts': 'A\n' }); writeFileSync(join(parent, 'outside.ts'), 'OUT\n'); - symlinkSync(join(parent, 'outside.ts'), join(parent, 'a', 'escape.link')); + symlinkSync(join(parent, 'outside.ts'), join(parent, 'epic-a', 'escape.link')); const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, - epicId: 'epic-1', - sliceIds: ['a'], + epicId: 'epic-a', + epicIds: ['epic-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', () => { + it('replaces a file with a directory when later epics need nested paths', () => { const parent = makeParent(); - seedSlice(parent, 'a', { 'src/x': 'file\n' }); - seedSlice(parent, 'b', { 'src/x/inner.ts': 'inner\n' }); + seedEpic(parent, 'epic-a', { 'src/x': 'file\n' }); + seedEpic(parent, 'epic-b', { 'src/x/inner.ts': 'inner\n' }); const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, - epicId: 'epic-1', - sliceIds: ['a', 'b'], + epicId: 'epic-b', + epicIds: ['epic-a', 'epic-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(); + seedEpic(parent, 'scaffolding', { + 'src/cli.ts': 'version + help\n', + 'tests/version.test.ts': 'v\n', + 'tests/help.test.ts': 'h\n', + }); + seedEpic(parent, 'text-ops', { + 'src/cli.ts': 'version + help + reverse + count + slugify\n', + 'tests/reverse.test.ts': 'r\n', + 'tests/count.test.ts': 'c\n', + 'tests/slugify.test.ts': 's\n', + }); + + const result = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: parent, + epicId: 'text-ops', + epicIds: epicIdsForEpicVerifyMerge(txtLikePlan, 'text-ops'), + }); + + expect(readFileSync(join(result.epicSandboxDir, 'src/cli.ts'), 'utf8')).toBe( + 'version + help + reverse + count + slugify\n', + ); + expect(result.conflicts).toEqual([ + { path: 'src/cli.ts', epics: ['scaffolding', 'text-ops'], winner: 'text-ops' }, + ]); + 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 index 3db08b55..1f415696 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -1,15 +1,17 @@ -// Materialize `/__epic__//` as the union of the -// epic's completed slice worktrees. Slices apply in declaration order; later -// slices overwrite earlier ones on the same path and the collision is -// reported. Per-slice worktrees are not mutated. The epic dir is rebuilt -// fresh on every call. +// Materialize `/__epic__//` as the union of epic-scoped +// worktrees at `//`. Sources apply in epic +// dependency order (plan declaration order among included epics); later epics +// 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 } from './types.js'; + export type MergeConflict = { path: string; - slices: string[]; + epics: string[]; winner: string; }; @@ -19,13 +21,27 @@ export type MergeResult = { }; export type MergeOptions = { - /** Parent worktree dir holding per-slice sandboxes at `/`. */ + /** Parent worktree dir holding epic sandboxes at `/`. */ parentSandboxDir: string; epicId: string; - /** Completed slices in epic declaration order. */ - sliceIds: string[]; + /** Epic ids to merge in plan declaration order (this epic plus transitive deps). */ + epicIds: string[]; }; +/** Epic ids to merge before verify-epic: transitive deps then target, plan declaration order. */ +export function epicIdsForEpicVerifyMerge(plan: Plan, epicId: string): string[] { + const epicIds = new Set(); + const visit = (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) visit(dep); + epicIds.add(id); + }; + visit(epicId); + return plan.epics.filter((e) => epicIds.has(e.id)).map((e) => e.id); +} + /** Reserved under the parent sandbox for merged epic verify trees. */ const EPIC_MERGE_SEGMENT = '__epic__'; @@ -33,8 +49,8 @@ function assertSafePathSegment(id: string, label: string): void { if (!id || id.includes('..') || id.includes('/') || id.includes('\\')) { throw new Error(`Invalid ${label}: ${id}`); } - if (label === 'slice id' && id === EPIC_MERGE_SEGMENT) { - throw new Error(`Invalid slice id: ${id}`); + if (id === EPIC_MERGE_SEGMENT) { + throw new Error(`Invalid ${label}: ${id}`); } } @@ -49,12 +65,12 @@ function resolveEpicSandboxDir(parentSandboxDir: string, epicId: string): string return dir; } -function resolveSliceDir(parentSandboxDir: string, sliceId: string): string { - assertSafePathSegment(sliceId, 'slice id'); +function resolveEpicWorktreeDir(parentSandboxDir: string, sourceEpicId: string): string { + assertSafePathSegment(sourceEpicId, 'epic id'); const parent = resolve(parentSandboxDir); - const dir = resolve(parent, sliceId); + const dir = resolve(parent, sourceEpicId); if (dir === parent || !dir.startsWith(parent + sep)) { - throw new Error(`Invalid slice id: ${sliceId}`); + throw new Error(`Invalid epic id: ${sourceEpicId}`); } return dir; } @@ -109,15 +125,15 @@ export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { const parent = resolve(opts.parentSandboxDir); const epicRoot = resolve(parent, EPIC_MERGE_SEGMENT); - for (const sliceId of opts.sliceIds) { - const sliceDir = resolveSliceDir(opts.parentSandboxDir, sliceId); - if (sliceDir === epicRoot || sliceDir.startsWith(epicRoot + sep)) continue; - if (!existsSync(sliceDir)) continue; + for (const sourceEpicId of opts.epicIds) { + const epicWorktreeDir = resolveEpicWorktreeDir(opts.parentSandboxDir, sourceEpicId); + if (epicWorktreeDir === epicRoot || epicWorktreeDir.startsWith(epicRoot + sep)) continue; + if (!existsSync(epicWorktreeDir)) continue; - for (const file of walkFiles(sliceDir)) { - const rel = relativePathWithin(sliceDir, file); + for (const file of walkFiles(epicWorktreeDir)) { + const rel = relativePathWithin(epicWorktreeDir, file); const list = writers.get(rel) ?? []; - list.push(sliceId); + list.push(sourceEpicId); writers.set(rel, list); const dest = join(epicSandboxDir, rel); @@ -126,9 +142,9 @@ export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { } const conflicts: MergeConflict[] = []; - for (const [path, slices] of writers) { - if (slices.length > 1) { - conflicts.push({ path, slices, winner: slices[slices.length - 1]! }); + for (const [path, epics] of writers) { + if (epics.length > 1) { + conflicts.push({ path, epics, winner: epics[epics.length - 1]! }); } } conflicts.sort((a, b) => a.path.localeCompare(b.path)); diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 1537c12c..3321ce95 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -8,7 +8,7 @@ import { mkdirSync } from 'node:fs'; import { resolve, sep } from 'node:path'; -import { mergeSlicesIntoEpicSandbox } from './epic-sandbox-merge.js'; +import { mergeSlicesIntoEpicSandbox, epicIdsForEpicVerifyMerge } 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'; @@ -27,18 +27,18 @@ 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}`); +/** Resolve an epic-scoped sandbox under the run root; reject path-escape ids. */ +function epicWorktreeDir(rootSandboxDir: string, epicId: string): string { + if (!epicId || epicId.includes('..') || epicId.includes('/') || epicId.includes('\\')) { + throw new Error(`Invalid epic id: ${epicId}`); } - if (sliceId === '__epic__') { - throw new Error(`Invalid slice id: ${sliceId}`); + if (epicId === '__epic__') { + throw new Error(`Invalid epic id: ${epicId}`); } const root = resolve(rootSandboxDir); - const dir = resolve(root, sliceId); + const dir = resolve(root, epicId); if (dir !== root && !dir.startsWith(root + sep)) { - throw new Error(`Invalid slice id: ${sliceId}`); + throw new Error(`Invalid epic id: ${epicId}`); } return dir; } @@ -340,9 +340,9 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, net.addPlace(place); } - // Create per-slice sandbox directories - for (const slice of plan.slices) { - mkdirSync(sliceSandboxDir(input.sandboxDir, slice.id), { recursive: true }); + // Create epic-scoped sandbox directories (slices in the same epic share one worktree) + for (const epic of plan.epics) { + mkdirSync(epicWorktreeDir(input.sandboxDir, epic.id), { recursive: true }); } // Register transitions with wired fire handlers @@ -366,7 +366,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, slice, epic, plan, - sandboxDir: sliceSandboxDir(input.sandboxDir, sliceId), + sandboxDir: epicWorktreeDir(input.sandboxDir, epicId), reports, }; const baseToken: Token = { sliceId, epicId }; @@ -402,7 +402,7 @@ 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 result = await testRunner.run(target, epicWorktreeDir(input.sandboxDir, epicId)); const reportId = createReport(reports, { epicId, sliceId, @@ -441,7 +441,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, slice, epic, plan, - sandboxDir: sliceSandboxDir(input.sandboxDir, sliceId), + sandboxDir: epicWorktreeDir(input.sandboxDir, epicId), reports, }; const baseToken: Token = { sliceId, epicId }; @@ -505,17 +505,18 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const epic = plan.epics.find((e) => e.id === epicId)!; const slice = plan.slices.find((s) => s.id === representativeSliceId)!; // Epic verification runs against a freshly-merged `__epic__//` - // dir built from this epic's completed slice worktrees. - const sliceIdsInDeclOrder = plan.slices.filter((s) => s.epic_id === epicId).map((s) => s.id); + // dir built from dependency epic worktrees plus this epic's accumulated worktree. + const epicIdsInMergeOrder = epicIdsForEpicVerifyMerge(plan, epicId); fire = async () => { - const completedSliceIds = sliceIdsInDeclOrder.filter( - (sid) => ctx.sliceOutcomes.get(sid)?.status === 'completed', - ); + const mergeEpicIds = epicIdsInMergeOrder.filter((eid) => { + if (eid === epicId) return true; + return ctx.epicOutcomes.get(eid)?.status === 'completed'; + }); const merge = mergeSlicesIntoEpicSandbox({ parentSandboxDir: input.sandboxDir, epicId, - sliceIds: completedSliceIds, + epicIds: mergeEpicIds, }); ctx.reportIds.push( createReport(reports, { @@ -525,7 +526,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, event: 'epic-sandbox-merged', payload: { epicSandboxDir: merge.epicSandboxDir, - sliceIds: completedSliceIds, + epicIds: mergeEpicIds, conflicts: merge.conflicts, }, }), 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); +}); From fabc77b9fe0a18b6c4d6e263ba09ea95067ae296 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Mon, 25 May 2026 19:01:17 +0200 Subject: [PATCH 05/10] Fix parallel epic sandbox races and cross-epic slice merge deps. Restore per-slice runtime sandboxes with dependency seeding so parallel slices do not corrupt a shared epic worktree. verify-epic merges completed slice worktrees and follows slice depends_on across epics, not just epic depends_on. Co-authored-by: Cursor --- src/orchestrator/src/engine-contract.test.ts | 78 ++++++-- .../src/epic-sandbox-merge.test.ts | 180 ++++++++++++------ src/orchestrator/src/epic-sandbox-merge.ts | 106 +++++++---- src/orchestrator/src/net-compiler.ts | 75 ++++---- 4 files changed, 300 insertions(+), 139 deletions(-) diff --git a/src/orchestrator/src/engine-contract.test.ts b/src/orchestrator/src/engine-contract.test.ts index fbec8eda..af6546c1 100644 --- a/src/orchestrator/src/engine-contract.test.ts +++ b/src/orchestrator/src/engine-contract.test.ts @@ -1080,11 +1080,11 @@ describe('Engine contract test #13 — resource pool bounds concurrency', () => }); // --------------------------------------------------------------------------- -// Adapter test — sandbox-per-epic isolation +// Adapter test — sandbox-per-slice isolation // --------------------------------------------------------------------------- -describe('Adapter: sandbox-per-epic isolation', () => { - it('each action handler receives an epic-scoped sandboxDir shared by slices in the epic', async () => { +describe('Adapter: sandbox-per-slice isolation', () => { + it('each action handler receives a per-slice sandboxDir (parallel-safe)', async () => { const sandboxDirs = new Map(); const fakes = createFakes({ evalSequence: [true], semanticResults: [true] }); @@ -1107,17 +1107,69 @@ describe('Adapter: sandbox-per-epic 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/epic-1'); + 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 a merged epic sandbox under /__epic__// (not epic worktree, not parent)', async () => { + 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: 'p1', + epic_id: 'e1', + definition: 'S1', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't1' }], + }, + { + id: 'p2', + epic_id: 'e1', + definition: 'S2', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't2' }], + }, + { + id: 'p3', + epic_id: 'e1', + definition: 'S3', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't3' }], + }, + ], + }; + + 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) => { + sandboxDirs.add(ctx.sandboxDir); + return handler!(ctx); + }; + } + + const result = await createOrchestrator('parallel').run({ + plan: parallelPlan, + sandboxDir: '/tmp/parallel-run', + actions: trackingActions, + reports: fakes.reports, + testRunner: fakes.testRunner, + policy: { maxRetries: 3 }, + }); + + expect(result.status).toBe('completed'); + 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: [ { @@ -1140,9 +1192,9 @@ describe('Adapter: sandbox-per-epic isolation', () => { const parent = mkdtempSync(join(tmpdir(), 'cook-ec-')); try { - // Seed the epic worktree with a file so the merge has something to copy. - mkdirSync(join(parent, 'ev'), { recursive: true }); - writeFileSync(join(parent, 'ev', 'ev-marker.txt'), 'from-epic-ev'); + // 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 }); @@ -1165,15 +1217,15 @@ describe('Adapter: sandbox-per-epic isolation', () => { expect(result.status).toBe('completed'); expect(verifyEpicSandboxDir).toBe(join(parent, '__epic__', 'ev')); - // Merge produced a real dir holding the epic worktree seed file. - expect(existsSync(join(verifyEpicSandboxDir, 'ev-marker.txt'))).toBe(true); + // 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'), - epicIds: ['ev'], + sliceIds: ['sv'], conflicts: [], }); } finally { diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index 38c687e5..dd914bb3 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -12,7 +12,12 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { epicIdsForEpicVerifyMerge, mergeSlicesIntoEpicSandbox } from './epic-sandbox-merge.js'; +import { + epicIdsForEpicVerifyMerge, + mergeSlicesIntoEpicSandbox, + seedSliceSandboxFromDeps, + sliceIdsForEpicVerifyMerge, +} from './epic-sandbox-merge.js'; import type { Plan } from './types.js'; const txtLikePlan: Plan = { @@ -22,13 +27,30 @@ const txtLikePlan: Plan = { ], slices: [ { id: 'version-flag', epic_id: 'scaffolding', definition: '', depends_on: [], verification: [] }, - { id: 'help-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']); @@ -37,6 +59,47 @@ describe('epicIdsForEpicVerifyMerge', () => { 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']); + }); +}); + +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, slice); + + expect(sliceDir).toBe(join(parent, 'help-flag')); + expect(readFileSync(join(sliceDir, 'src/cli.ts'), 'utf8')).toBe('version\n'); + }); }); describe('mergeSlicesIntoEpicSandbox', () => { @@ -54,24 +117,24 @@ describe('mergeSlicesIntoEpicSandbox', () => { return parent; } - function seedEpic(parent: string, epicId: string, files: Record): void { - const epicDir = join(parent, epicId); + 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(epicDir, rel); + const abs = join(sliceDir, rel); mkdirSync(join(abs, '..'), { recursive: true }); writeFileSync(abs, contents); } } - it('copies disjoint files from each epic worktree into a fresh verify sandbox', () => { + it('copies disjoint files from each slice worktree into a fresh verify sandbox', () => { const parent = makeParent(); - seedEpic(parent, 'epic-a', { 'src/a.ts': 'export const a = 1;\n' }); - seedEpic(parent, 'epic-b', { 'src/b.ts': 'export const b = 2;\n' }); + 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', - epicIds: ['epic-a', 'epic-b'], + sliceIds: ['slice-a', 'slice-b'], }); const expected = join(parent, '__epic__', 'epic-b'); @@ -81,76 +144,76 @@ describe('mergeSlicesIntoEpicSandbox', () => { expect(readFileSync(join(expected, 'src/b.ts'), 'utf8')).toBe('export const b = 2;\n'); }); - it('resolves path collisions in epic order (last epic wins) and reports them', () => { + it('resolves path collisions in slice order (last slice wins) and reports them', () => { const parent = makeParent(); - seedEpic(parent, 'epic-a', { 'src/x.ts': 'A\n' }); - seedEpic(parent, 'epic-b', { 'src/x.ts': 'B\n' }); - seedEpic(parent, 'epic-c', { 'src/x.ts': 'C\n' }); + 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', - epicIds: ['epic-a', 'epic-b', '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', epics: ['epic-a', 'epic-b', 'epic-c'], winner: 'epic-c' }, + { path: 'src/x.ts', slices: ['slice-a', 'slice-b', 'slice-c'], winner: 'slice-c' }, ]); }); - it('leaves epic worktrees byte-identical after merge', () => { + it('leaves slice worktrees byte-identical after merge', () => { const parent = makeParent(); - seedEpic(parent, 'epic-a', { 'src/a.ts': 'A\n', 'tests/a.test.ts': 'TA\n' }); - seedEpic(parent, 'epic-b', { 'src/a.ts': 'B\n' }); + 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, 'epic-a', 'src/a.ts'), 'utf8'), - aTests: readFileSync(join(parent, 'epic-a', 'tests/a.test.ts'), 'utf8'), - bSrc: readFileSync(join(parent, 'epic-b', 'src/a.ts'), 'utf8'), + 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', - epicIds: ['epic-a', 'epic-b'], + sliceIds: ['slice-a', 'slice-b'], }); - expect(readFileSync(join(parent, 'epic-a', 'src/a.ts'), 'utf8')).toBe(before.aSrc); - expect(readFileSync(join(parent, 'epic-a', 'tests/a.test.ts'), 'utf8')).toBe(before.aTests); - expect(readFileSync(join(parent, 'epic-b', 'src/a.ts'), 'utf8')).toBe(before.bSrc); + 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(); - seedEpic(parent, 'epic-a', { 'src/a.ts': 'A1\n', 'src/stale.ts': 'stale\n' }); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A1\n', 'src/stale.ts': 'stale\n' }); mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-a', - epicIds: ['epic-a'], + sliceIds: ['slice-a'], }); - rmSync(join(parent, 'epic-a', 'src/stale.ts')); + rmSync(join(parent, 'slice-a', 'src/stale.ts')); const second = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-a', - epicIds: ['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 epics whose worktree does not exist (e.g. halted before any write)', () => { + it('skips slices whose worktree does not exist (e.g. halted before any write)', () => { const parent = makeParent(); - seedEpic(parent, 'epic-a', { 'src/a.ts': 'A\n' }); - // epic "epic-b" never created its worktree + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A\n' }); + // slice "slice-b" never created its worktree const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-b', - epicIds: ['epic-a', 'epic-b'], + sliceIds: ['slice-a', 'slice-b'], }); expect(existsSync(join(result.epicSandboxDir, 'src/a.ts'))).toBe(true); @@ -163,67 +226,67 @@ describe('mergeSlicesIntoEpicSandbox', () => { mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: '..', - epicIds: [], + sliceIds: [], }), ).toThrow(/Invalid epic id/); }); - it('rejects reserved __epic__ as a source epic id', () => { + it('rejects reserved __epic__ as a source slice id', () => { const parent = makeParent(); expect(() => mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-1', - epicIds: ['__epic__'], + sliceIds: ['__epic__'], }), - ).toThrow(/Invalid epic id: __epic__/); + ).toThrow(/Invalid slice id: __epic__/); }); it('does not nest other verify merge dirs into the verify sandbox', () => { const parent = makeParent(); - seedEpic(parent, 'epic-a', { 'src/a.ts': 'A\n' }); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A\n' }); mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-1', - epicIds: ['epic-a'], + sliceIds: ['slice-a'], }); const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-2', - epicIds: ['epic-a'], + 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 epic worktree files', () => { + it('ignores symlinks when walking slice worktree files', () => { const parent = makeParent(); - seedEpic(parent, 'epic-a', { 'src/a.ts': 'A\n' }); + seedSlice(parent, 'slice-a', { 'src/a.ts': 'A\n' }); writeFileSync(join(parent, 'outside.ts'), 'OUT\n'); - symlinkSync(join(parent, 'outside.ts'), join(parent, 'epic-a', 'escape.link')); + symlinkSync(join(parent, 'outside.ts'), join(parent, 'slice-a', 'escape.link')); const result = mergeSlicesIntoEpicSandbox({ parentSandboxDir: parent, epicId: 'epic-a', - epicIds: ['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 epics need nested paths', () => { + it('replaces a file with a directory when later slices need nested paths', () => { const parent = makeParent(); - seedEpic(parent, 'epic-a', { 'src/x': 'file\n' }); - seedEpic(parent, 'epic-b', { 'src/x/inner.ts': 'inner\n' }); + 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', - epicIds: ['epic-a', 'epic-b'], + sliceIds: ['slice-a', 'slice-b'], }); expect(readFileSync(join(result.epicSandboxDir, 'src/x/inner.ts'), 'utf8')).toBe('inner\n'); @@ -232,29 +295,32 @@ describe('mergeSlicesIntoEpicSandbox', () => { it('merges txt-like scaffolding + text-ops without intra-epic slice collisions', () => { const parent = makeParent(); - seedEpic(parent, 'scaffolding', { - 'src/cli.ts': 'version + help\n', + 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', }); - seedEpic(parent, 'text-ops', { - 'src/cli.ts': 'version + help + reverse + count + slugify\n', + seedSlice(parent, 'reverse', { + 'src/cli.ts': 'version + help + reverse\n', 'tests/reverse.test.ts': 'r\n', - 'tests/count.test.ts': 'c\n', - 'tests/slugify.test.ts': 's\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', - epicIds: epicIdsForEpicVerifyMerge(txtLikePlan, 'text-ops'), + sliceIds: sliceIdsForEpicVerifyMerge(txtLikePlan, 'text-ops'), }); expect(readFileSync(join(result.epicSandboxDir, 'src/cli.ts'), 'utf8')).toBe( - 'version + help + reverse + count + slugify\n', + 'version + help + reverse\n', ); expect(result.conflicts).toEqual([ - { path: 'src/cli.ts', epics: ['scaffolding', 'text-ops'], winner: 'text-ops' }, + { 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 index 1f415696..b84548e2 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -1,17 +1,17 @@ -// Materialize `/__epic__//` as the union of epic-scoped -// worktrees at `//`. Sources apply in epic -// dependency order (plan declaration order among included epics); later epics -// 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. +// 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 } from './types.js'; +import type { Plan, Slice } from './types.js'; export type MergeConflict = { path: string; - epics: string[]; + slices: string[]; winner: string; }; @@ -21,27 +21,47 @@ export type MergeResult = { }; export type MergeOptions = { - /** Parent worktree dir holding epic sandboxes at `/`. */ + /** Parent worktree dir holding slice sandboxes at `/`. */ parentSandboxDir: string; epicId: string; - /** Epic ids to merge in plan declaration order (this epic plus transitive deps). */ - epicIds: string[]; + /** Slice ids to merge in plan declaration order. */ + sliceIds: string[]; }; -/** Epic ids to merge before verify-epic: transitive deps then target, plan declaration order. */ +/** Epic ids whose slice worktrees participate in verify-epic for `epicId`. */ export function epicIdsForEpicVerifyMerge(plan: Plan, epicId: string): string[] { const epicIds = new Set(); - const visit = (id: string) => { + + 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) visit(dep); + for (const dep of epic.depends_on) visitEpic(dep); epicIds.add(id); }; - visit(epicId); + + const visitSliceDeps = (sliceId: string) => { + 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__'; @@ -65,12 +85,12 @@ function resolveEpicSandboxDir(parentSandboxDir: string, epicId: string): string return dir; } -function resolveEpicWorktreeDir(parentSandboxDir: string, sourceEpicId: string): string { - assertSafePathSegment(sourceEpicId, 'epic id'); +export function resolveSliceWorktreeDir(parentSandboxDir: string, sliceId: string): string { + assertSafePathSegment(sliceId, 'slice id'); const parent = resolve(parentSandboxDir); - const dir = resolve(parent, sourceEpicId); + const dir = resolve(parent, sliceId); if (dir === parent || !dir.startsWith(parent + sep)) { - throw new Error(`Invalid epic id: ${sourceEpicId}`); + throw new Error(`Invalid slice id: ${sliceId}`); } return dir; } @@ -83,11 +103,11 @@ function relativePathWithin(rootDir: string, file: string): string { return rel; } -function prepareDestForFile(epicRoot: string, dest: string): void { - const root = resolve(epicRoot); +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 epic sandbox: ${dest}`); + throw new Error(`Path escapes sandbox: ${dest}`); } const relDir = relative(root, dir); @@ -105,14 +125,32 @@ function prepareDestForFile(epicRoot: string, dest: string): void { mkdirSync(dir, { recursive: true }); } -function copyIntoEpicSandbox(src: string, dest: string, epicRoot: string): void { - prepareDestForFile(epicRoot, dest); +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 }); } +/** Copy completed dependency slice worktrees into `slice`'s sandbox (plan order). */ +export function seedSliceSandboxFromDeps(parentSandboxDir: string, slice: Slice): string { + const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, slice.id); + mkdirSync(sliceDir, { recursive: true }); + + for (const depId of slice.depends_on) { + const depDir = resolveSliceWorktreeDir(parentSandboxDir, depId); + if (!existsSync(depDir)) continue; + + for (const file of walkFiles(depDir)) { + const rel = relativePathWithin(depDir, file); + copyIntoTree(file, join(sliceDir, rel), sliceDir); + } + } + + return sliceDir; +} + export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { const epicSandboxDir = resolveEpicSandboxDir(opts.parentSandboxDir, opts.epicId); @@ -125,26 +163,26 @@ export function mergeSlicesIntoEpicSandbox(opts: MergeOptions): MergeResult { const parent = resolve(opts.parentSandboxDir); const epicRoot = resolve(parent, EPIC_MERGE_SEGMENT); - for (const sourceEpicId of opts.epicIds) { - const epicWorktreeDir = resolveEpicWorktreeDir(opts.parentSandboxDir, sourceEpicId); - if (epicWorktreeDir === epicRoot || epicWorktreeDir.startsWith(epicRoot + sep)) continue; - if (!existsSync(epicWorktreeDir)) continue; + 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(epicWorktreeDir)) { - const rel = relativePathWithin(epicWorktreeDir, file); + for (const file of walkFiles(sliceDir)) { + const rel = relativePathWithin(sliceDir, file); const list = writers.get(rel) ?? []; - list.push(sourceEpicId); + list.push(sliceId); writers.set(rel, list); const dest = join(epicSandboxDir, rel); - copyIntoEpicSandbox(file, dest, epicSandboxDir); + copyIntoTree(file, dest, epicSandboxDir); } } const conflicts: MergeConflict[] = []; - for (const [path, epics] of writers) { - if (epics.length > 1) { - conflicts.push({ path, epics, winner: epics[epics.length - 1]! }); + 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)); diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 3321ce95..287c7350 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -8,7 +8,11 @@ import { mkdirSync } from 'node:fs'; import { resolve, sep } from 'node:path'; -import { mergeSlicesIntoEpicSandbox, epicIdsForEpicVerifyMerge } from './epic-sandbox-merge.js'; +import { + mergeSlicesIntoEpicSandbox, + 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'; @@ -27,18 +31,18 @@ function ep(epicId: string, place: string): string { return `epic:${epicId}:${place}`; } -/** Resolve an epic-scoped sandbox under the run root; reject path-escape ids. */ -function epicWorktreeDir(rootSandboxDir: string, epicId: string): string { - if (!epicId || epicId.includes('..') || epicId.includes('/') || epicId.includes('\\')) { - throw new Error(`Invalid epic id: ${epicId}`); +/** Resolve a per-slice sandbox under the run root; reject path-escape ids. */ +function sliceWorktreeDir(rootSandboxDir: string, sliceId: string): string { + if (!sliceId || sliceId.includes('..') || sliceId.includes('/') || sliceId.includes('\\')) { + throw new Error(`Invalid slice id: ${sliceId}`); } - if (epicId === '__epic__') { - throw new Error(`Invalid epic id: ${epicId}`); + if (sliceId === '__epic__') { + throw new Error(`Invalid slice id: ${sliceId}`); } const root = resolve(rootSandboxDir); - const dir = resolve(root, epicId); + const dir = resolve(root, sliceId); if (dir !== root && !dir.startsWith(root + sep)) { - throw new Error(`Invalid epic id: ${epicId}`); + throw new Error(`Invalid slice id: ${sliceId}`); } return dir; } @@ -340,9 +344,9 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, net.addPlace(place); } - // Create epic-scoped sandbox directories (slices in the same epic share one worktree) - for (const epic of plan.epics) { - mkdirSync(epicWorktreeDir(input.sandboxDir, epic.id), { recursive: true }); + // Create per-slice sandbox directories (parallel-safe; deps seeded at fire time) + for (const slice of plan.slices) { + mkdirSync(sliceWorktreeDir(input.sandboxDir, slice.id), { recursive: true }); } // Register transitions with wired fire handlers @@ -362,16 +366,16 @@ 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: epicWorktreeDir(input.sandboxDir, epicId), - reports, - }; const baseToken: Token = { sliceId, epicId }; fire = async (consumed) => { + const actCtx: ActionContext = { + slice, + epic, + plan, + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, slice), + reports, + }; const reportId = await actions[actionKey]!(actCtx); ctx.reportIds.push(reportId); const tok: Token = { ...consumed[0]!, reportId }; @@ -402,7 +406,9 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const retryToken = consumed[1]!; const retryCount = retryToken.retryCount ?? 0; - const result = await testRunner.run(target, epicWorktreeDir(input.sandboxDir, epicId)); + const slice = plan.slices.find((s) => s.id === sliceId)!; + const sandboxDir = seedSliceSandboxFromDeps(input.sandboxDir, slice); + const result = await testRunner.run(target, sandboxDir); const reportId = createReport(reports, { epicId, sliceId, @@ -437,19 +443,19 @@ 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: epicWorktreeDir(input.sandboxDir, epicId), - 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, slice), + reports, + }; const reportId = await actions[actionKey]!(actCtx); ctx.reportIds.push(reportId); const report = reports.getById(reportId); @@ -505,18 +511,17 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const epic = plan.epics.find((e) => e.id === epicId)!; const slice = plan.slices.find((s) => s.id === representativeSliceId)!; // Epic verification runs against a freshly-merged `__epic__//` - // dir built from dependency epic worktrees plus this epic's accumulated worktree. - const epicIdsInMergeOrder = epicIdsForEpicVerifyMerge(plan, epicId); + // dir built from completed slice worktrees (cross-epic slice deps included). + const sliceIdsInMergeOrder = sliceIdsForEpicVerifyMerge(plan, epicId); fire = async () => { - const mergeEpicIds = epicIdsInMergeOrder.filter((eid) => { - if (eid === epicId) return true; - return ctx.epicOutcomes.get(eid)?.status === 'completed'; - }); + const mergeSliceIds = sliceIdsInMergeOrder.filter( + (sid) => ctx.sliceOutcomes.get(sid)?.status === 'completed', + ); const merge = mergeSlicesIntoEpicSandbox({ parentSandboxDir: input.sandboxDir, epicId, - epicIds: mergeEpicIds, + sliceIds: mergeSliceIds, }); ctx.reportIds.push( createReport(reports, { @@ -526,7 +531,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, event: 'epic-sandbox-merged', payload: { epicSandboxDir: merge.epicSandboxDir, - epicIds: mergeEpicIds, + sliceIds: mergeSliceIds, conflicts: merge.conflicts, }, }), From 83c4fd8355f904c04d42be8634a586a2eb52cf80 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Mon, 25 May 2026 19:15:32 +0200 Subject: [PATCH 06/10] Fix slice sandbox re-seed overwriting slice work and leaving rework orphans. Use preserveExisting for post-action test/assess passes; on action/rework reset, drop paths outside the dependency baseline before overlaying deps. Co-authored-by: Cursor --- .../src/epic-sandbox-merge.test.ts | 32 +++++++++++ src/orchestrator/src/epic-sandbox-merge.ts | 54 +++++++++++++++++-- src/orchestrator/src/net-compiler.ts | 8 ++- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index dd914bb3..eedf22af 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -100,6 +100,38 @@ describe('seedSliceSandboxFromDeps', () => { expect(sliceDir).toBe(join(parent, 'help-flag')); expect(readFileSync(join(sliceDir, 'src/cli.ts'), 'utf8')).toBe('version\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, slice); + writeFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'slice edit\n'); + + seedSliceSandboxFromDeps(parent, slice, { preserveExisting: true }); + + expect(readFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'utf8')).toBe('slice edit\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, slice); + writeFileSync(join(parent, 'help-flag', 'src/stale.ts'), 'orphan\n'); + writeFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'bad edit\n'); + + seedSliceSandboxFromDeps(parent, 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', () => { diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index b84548e2..555086d0 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -133,20 +133,64 @@ function copyIntoTree(src: string, dest: string, treeRoot: string): void { cpSync(src, dest, { dereference: false }); } -/** Copy completed dependency slice worktrees into `slice`'s sandbox (plan order). */ -export function seedSliceSandboxFromDeps(parentSandboxDir: string, slice: Slice): string { - const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, slice.id); - mkdirSync(sliceDir, { recursive: true }); +export type SeedSliceSandboxOptions = { + /** Keep slice-owned paths; only add missing dependency files (post-action test/assess). */ + preserveExisting?: boolean; +}; +function collectDepFiles(parentSandboxDir: string, slice: Slice): Map { + const depFiles = new Map(); for (const depId of slice.depends_on) { const depDir = resolveSliceWorktreeDir(parentSandboxDir, depId); if (!existsSync(depDir)) continue; for (const file of walkFiles(depDir)) { const rel = relativePathWithin(depDir, file); - copyIntoTree(file, join(sliceDir, rel), sliceDir); + 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, + slice: Slice, + opts?: SeedSliceSandboxOptions, +): string { + const preserveExisting = opts?.preserveExisting ?? false; + const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, slice.id); + mkdirSync(sliceDir, { recursive: true }); + + const depFiles = collectDepFiles(parentSandboxDir, 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; } diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 287c7350..fe03b0ef 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -407,7 +407,9 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const retryCount = retryToken.retryCount ?? 0; const slice = plan.slices.find((s) => s.id === sliceId)!; - const sandboxDir = seedSliceSandboxFromDeps(input.sandboxDir, slice); + const sandboxDir = seedSliceSandboxFromDeps(input.sandboxDir, slice, { + preserveExisting: true, + }); const result = await testRunner.run(target, sandboxDir); const reportId = createReport(reports, { epicId, @@ -453,7 +455,9 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, slice, epic, plan, - sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, slice), + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, slice, { + preserveExisting: true, + }), reports, }; const reportId = await actions[actionKey]!(actCtx); From 77ff9aa445541e6251b708cee1539e57e839034d Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 26 May 2026 10:21:37 +0200 Subject: [PATCH 07/10] Preserve slice artifacts when action handler seeds deps. The generic action transition was calling seedSliceSandboxFromDeps without preserveExisting, wiping files from prior steps in dependent slices. Co-authored-by: Cursor --- src/orchestrator/src/net-compiler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index fe03b0ef..2be882a7 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -373,7 +373,9 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, slice, epic, plan, - sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, slice), + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, slice, { + preserveExisting: true, + }), reports, }; const reportId = await actions[actionKey]!(actCtx); From 58a46295b04d0bf3902a393b68efdf95870a7169 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 26 May 2026 11:06:23 +0200 Subject: [PATCH 08/10] Align dep seeding with epic merge and prune rework orphans. Seed dependency files in plan declaration order, always drop paths outside dep trees even when preserveExisting is set, and reuse resolveSliceWorktreeDir from net-compiler. Co-authored-by: Cursor --- .../src/epic-sandbox-merge.test.ts | 55 +++++++++++++++++-- src/orchestrator/src/epic-sandbox-merge.ts | 15 +++-- src/orchestrator/src/net-compiler.ts | 26 ++------- 3 files changed, 66 insertions(+), 30 deletions(-) diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index eedf22af..6d908193 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -95,12 +95,29 @@ describe('seedSliceSandboxFromDeps', () => { writeFileSync(join(parent, 'version-flag', 'src/cli.ts'), 'version\n'); const slice = txtLikePlan.slices.find((s) => s.id === 'help-flag')!; - const sliceDir = seedSliceSandboxFromDeps(parent, slice); + 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 prunes rework orphans but keeps slice 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'), 'orphan\n'); + writeFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'slice edit\n'); + + seedSliceSandboxFromDeps(parent, txtLikePlan, slice, { preserveExisting: true }); + + expect(existsSync(join(parent, 'help-flag', 'src/stale.ts'))).toBe(false); + 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); @@ -108,14 +125,42 @@ describe('seedSliceSandboxFromDeps', () => { writeFileSync(join(parent, 'version-flag', 'src/cli.ts'), 'dep\n'); const slice = txtLikePlan.slices.find((s) => s.id === 'help-flag')!; - seedSliceSandboxFromDeps(parent, slice); + seedSliceSandboxFromDeps(parent, txtLikePlan, slice); writeFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'slice edit\n'); - seedSliceSandboxFromDeps(parent, slice, { preserveExisting: true }); + 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); @@ -123,11 +168,11 @@ describe('seedSliceSandboxFromDeps', () => { writeFileSync(join(parent, 'version-flag', 'src/cli.ts'), 'dep\n'); const slice = txtLikePlan.slices.find((s) => s.id === 'help-flag')!; - seedSliceSandboxFromDeps(parent, slice); + 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, slice); + 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'); diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index 555086d0..69544b6b 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -138,9 +138,15 @@ export type SeedSliceSandboxOptions = { preserveExisting?: boolean; }; -function collectDepFiles(parentSandboxDir: string, slice: Slice): Map { +/** 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 slice.depends_on) { + for (const depId of depSliceIdsInPlanOrder(plan, slice)) { const depDir = resolveSliceWorktreeDir(parentSandboxDir, depId); if (!existsSync(depDir)) continue; @@ -167,6 +173,7 @@ function pruneEmptyDirs(rootDir: string, dir: string = rootDir): void { /** Copy completed dependency slice worktrees into `slice`'s sandbox (plan order). */ export function seedSliceSandboxFromDeps( parentSandboxDir: string, + plan: Plan, slice: Slice, opts?: SeedSliceSandboxOptions, ): string { @@ -174,9 +181,9 @@ export function seedSliceSandboxFromDeps( const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, slice.id); mkdirSync(sliceDir, { recursive: true }); - const depFiles = collectDepFiles(parentSandboxDir, slice); + const depFiles = collectDepFiles(parentSandboxDir, plan, slice); - if (!preserveExisting && depFiles.size > 0 && existsSync(sliceDir)) { + if (depFiles.size > 0 && existsSync(sliceDir)) { for (const file of walkFiles(sliceDir)) { const rel = relativePathWithin(sliceDir, file); if (!depFiles.has(rel)) { diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 2be882a7..9a2100ce 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -6,10 +6,10 @@ // --------------------------------------------------------------------------- import { mkdirSync } from 'node:fs'; -import { resolve, sep } from 'node:path'; import { mergeSlicesIntoEpicSandbox, + resolveSliceWorktreeDir, seedSliceSandboxFromDeps, sliceIdsForEpicVerifyMerge, } from './epic-sandbox-merge.js'; @@ -31,22 +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 sliceWorktreeDir(rootSandboxDir: string, sliceId: string): string { - if (!sliceId || sliceId.includes('..') || sliceId.includes('/') || sliceId.includes('\\')) { - throw new Error(`Invalid slice id: ${sliceId}`); - } - if (sliceId === '__epic__') { - 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. @@ -346,7 +330,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, // Create per-slice sandbox directories (parallel-safe; deps seeded at fire time) for (const slice of plan.slices) { - mkdirSync(sliceWorktreeDir(input.sandboxDir, slice.id), { recursive: true }); + mkdirSync(resolveSliceWorktreeDir(input.sandboxDir, slice.id), { recursive: true }); } // Register transitions with wired fire handlers @@ -373,7 +357,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, slice, epic, plan, - sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, slice, { + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { preserveExisting: true, }), reports, @@ -409,7 +393,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const retryCount = retryToken.retryCount ?? 0; const slice = plan.slices.find((s) => s.id === sliceId)!; - const sandboxDir = seedSliceSandboxFromDeps(input.sandboxDir, slice, { + const sandboxDir = seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { preserveExisting: true, }); const result = await testRunner.run(target, sandboxDir); @@ -457,7 +441,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, slice, epic, plan, - sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, slice, { + sandboxDir: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { preserveExisting: true, }), reports, From 8305e695811f920962a5496faa45ce0386b1d8e0 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 27 May 2026 22:41:52 +0200 Subject: [PATCH 09/10] Skip dep orphan pruning when preserveExisting is set. Re-seeding before assess/run-tests must not delete slice-owned artifacts that are absent from dependency worktrees. Co-authored-by: Cursor --- src/orchestrator/src/epic-sandbox-merge.test.ts | 6 +++--- src/orchestrator/src/epic-sandbox-merge.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index 6d908193..e68d43be 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -101,7 +101,7 @@ describe('seedSliceSandboxFromDeps', () => { expect(readFileSync(join(sliceDir, 'src/cli.ts'), 'utf8')).toBe('version\n'); }); - it('preserveExisting prunes rework orphans but keeps slice edits on dep paths', () => { + 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 }); @@ -109,12 +109,12 @@ describe('seedSliceSandboxFromDeps', () => { 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/stale.ts'), 'slice-owned\n'); writeFileSync(join(parent, 'help-flag', 'src/cli.ts'), 'slice edit\n'); seedSliceSandboxFromDeps(parent, txtLikePlan, slice, { preserveExisting: true }); - expect(existsSync(join(parent, 'help-flag', 'src/stale.ts'))).toBe(false); + 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'); }); diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index 69544b6b..5b36417c 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -183,7 +183,7 @@ export function seedSliceSandboxFromDeps( const depFiles = collectDepFiles(parentSandboxDir, plan, slice); - if (depFiles.size > 0 && existsSync(sliceDir)) { + if (!preserveExisting && depFiles.size > 0 && existsSync(sliceDir)) { for (const file of walkFiles(sliceDir)) { const rel = relativePathWithin(sliceDir, file); if (!depFiles.has(rel)) { From 78bf6ee726dfeb2a2e656c00c474c5db2a219664 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 27 May 2026 23:11:51 +0200 Subject: [PATCH 10/10] Guard cyclic slice depends_on in epic verify merge. visitSliceDeps now tracks visited slice ids so malformed plans with circular slice.depends_on cannot blow the stack. Co-authored-by: Cursor --- src/orchestrator/src/epic-sandbox-merge.test.ts | 12 ++++++++++++ src/orchestrator/src/epic-sandbox-merge.ts | 3 +++ 2 files changed, 15 insertions(+) diff --git a/src/orchestrator/src/epic-sandbox-merge.test.ts b/src/orchestrator/src/epic-sandbox-merge.test.ts index e68d43be..24856ff6 100644 --- a/src/orchestrator/src/epic-sandbox-merge.test.ts +++ b/src/orchestrator/src/epic-sandbox-merge.test.ts @@ -63,6 +63,18 @@ describe('epicIdsForEpicVerifyMerge', () => { 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', () => { diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index 5b36417c..e026e7ab 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -40,7 +40,10 @@ export function epicIdsForEpicVerifyMerge(plan: Plan, epicId: string): string[] 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);