Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Open http://localhost:5173.
| Variable | Required | Description |
|---|---|---|
| `ANTHROPIC_API_KEY` | Yes | Anthropic API key |
| `ANTHROPIC_MODEL` | No | Interviewer model (default: `claude-sonnet-4-20250514`) |
| `ANTHROPIC_MODEL` | No | Interviewer model (default: `claude-opus-4-6`) |
| `OBSERVER_MODEL` | No | Observer model (default: `claude-haiku-4-5-20251001`) |
| `BRUNCH_DB` | No | Override the default project-local SQLite path for dev workflows |
| `BRUNCH_PORT` | No | Backend port override |
Expand Down
29 changes: 17 additions & 12 deletions src/orchestrator/src/cook-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export function parseCookArgs(args: string[]): CookOptions {
verbose = true;
} else if (!arg.startsWith('-')) {
dir = arg;
} else {
// Reject unknown flags instead of silently ignoring them (e.g. --spec-id
// is not a flag; the spec selector is --spec=<id>).
throw new Error(`Unknown flag "${arg}". Run "brunch --help" for cook usage.`);
}
}

Expand Down Expand Up @@ -426,28 +430,21 @@ export async function runCook(opts: CookOptions, bus: CookBus): Promise<void> {
cliFlag: opts.petrinautUrl,
env: { PETRINAUT_URL: process.env.PETRINAUT_URL },
});
if ('error' in resolvedUrl) {
line(resolvedUrl.error);
process.exit(1);
}
// Throw, never process.exit — the caller (withCookBus) must dispose the
// presenter (unmount Ink) before the error is printed, or the TUI hangs.
if ('error' in resolvedUrl) throw new Error(resolvedUrl.error);
petrinautUrl = resolvedUrl.url;
streamPort = resolvePetrinautStreamPort({ PORT: process.env.PORT });
}

const resolved = resolveCookPlan(opts.dir, opts.specId);
if (resolved.kind === 'error') {
line(resolved.message);
process.exit(1);
}
if (resolved.kind === 'error') throw new Error(resolved.message);

const plan = loadPlan(resolved.planPath);

// Worktree strategy follows the plan's spec-derived mode, not its location.
const sandbox = resolveSandboxPlan(plan.mode, resolved.sourceDir);
if (sandbox.kind === 'error') {
line(sandbox.message);
process.exit(1);
}
if (sandbox.kind === 'error') throw new Error(sandbox.message);

// Single shared tree only for serial greenfield (parallel would race on it);
// every other case isolates slices per-slice.
Expand Down Expand Up @@ -483,6 +480,12 @@ export async function runCook(opts: CookOptions, bus: CookBus): Promise<void> {
// Seed the presenter's elapsed clock; per-action progress carries no
// pre-formatted timing — the presenter owns it (I136-K).
bus.emit({ kind: 'cook-start', runStart });
// Seed the slice grid up front so queued work is visible before it starts.
bus.emit({
kind: 'run-shape',
epics: plan.epics.map((e) => ({ id: e.id })),
slices: plan.slices.map((s) => ({ id: s.id, epicId: s.epic_id })),
});
const actions = createPiActions({
verbose: opts.verbose,
emit: (event) => bus.emit(event),
Expand Down Expand Up @@ -615,6 +618,8 @@ export async function runCook(opts: CookOptions, bus: CookBus): Promise<void> {
}
}

// Run complete (after promotion) — lights the brigade's `serve` phase.
bus.emit({ kind: 'cook-done', ok });
recordCookExitStatus(ok);
return;
} finally {
Expand Down
19 changes: 16 additions & 3 deletions src/orchestrator/src/cow-copy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process';
import { cpSync, existsSync, readdirSync } from 'node:fs';
import { cpSync, existsSync, readdirSync, symlinkSync } from 'node:fs';
import { join, resolve } from 'node:path';

/**
Expand All @@ -23,23 +23,36 @@ export function cowCopy(src: string, dest: string): void {
/** Top-level names skipped when CoW-copying into cook sandboxes. */
export const COW_COPY_DEFAULT_EXCLUDE = new Set(['.git', '.brunch']);

const NO_SYMLINKS: ReadonlySet<string> = new Set();

/**
* CoW-copy top-level entries from `sourceDir` that are absent in `destDir`
* Provision top-level entries from `sourceDir` that are absent in `destDir`
* (untracked/gitignored dirs like `node_modules/`, `dist/`). Skips names in
* `exclude` and entries already present in the destination (typically tracked
* files materialized by `git worktree add`).
*
* Names in `symlink` are linked to the source entry instead of copied — used to
* share a single read-only `node_modules/` across slice sandboxes rather than
* paying a CoW copy per slice. Everything else is CoW-copied (lazy on APFS /
* reflink filesystems, deep copy otherwise).
*/
export function copyMissingTopLevelEntries(
sourceDir: string,
destDir: string,
exclude: ReadonlySet<string> = COW_COPY_DEFAULT_EXCLUDE,
symlink: ReadonlySet<string> = NO_SYMLINKS,
): void {
const source = resolve(sourceDir);
const dest = resolve(destDir);
for (const entry of readdirSync(source)) {
if (exclude.has(entry)) continue;
const destPath = join(dest, entry);
if (existsSync(destPath)) continue;
cowCopy(join(source, entry), destPath);
const sourcePath = join(source, entry);
if (symlink.has(entry)) {
symlinkSync(sourcePath, destPath);
} else {
cowCopy(sourcePath, destPath);
}
}
}
72 changes: 67 additions & 5 deletions src/orchestrator/src/epic-sandbox-merge.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { execFileSync } from 'node:child_process';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
rmSync,
symlinkSync,
writeFileSync,
Expand All @@ -14,6 +16,7 @@ import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';

import {
ensureSliceWorktree,
epicIdsForEpicVerifyMerge,
mergeCompletedSlicesIntoTree,
mergeSlicesIntoEpicSandbox,
Expand Down Expand Up @@ -274,19 +277,31 @@ describe('seedSliceFromParentWorktree', () => {
expect(readFileSync(join(sliceDir, 'src/a.ts'), 'utf8')).toBe('export const a = 1;\n');
});

it('untracked content arrives via CoW copy from the parent', () => {
it('untracked content (other than node_modules) arrives via CoW copy from the parent', () => {
const { parent, addUntracked } = makeGitParentWorktree('r2');
// Simulate node_modules / generated artifacts present in the parent
// worktree but NOT tracked by git.
addUntracked('node_modules/dep/index.js', 'module.exports = 1;\n');
// Simulate generated artifacts present in the parent worktree but NOT
// tracked by git. `dist/` is copied (a slice may rebuild it independently).
addUntracked('dist/bundle.js', 'console.log("bundle");\n');

const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan, 'r2');

expect(readFileSync(join(sliceDir, 'node_modules/dep/index.js'), 'utf8')).toBe('module.exports = 1;\n');
expect(lstatSync(join(sliceDir, 'dist')).isSymbolicLink()).toBe(false);
expect(readFileSync(join(sliceDir, 'dist/bundle.js'), 'utf8')).toBe('console.log("bundle");\n');
});

it('shares node_modules via a symlink to the parent rather than copying it', () => {
const { parent, addUntracked } = makeGitParentWorktree('r2b');
addUntracked('node_modules/dep/index.js', 'module.exports = 1;\n');

const sliceDir = seedSliceFromParentWorktree(parent, 'only', singleSlicePlan, 'r2b');

const linkPath = join(sliceDir, 'node_modules');
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(linkPath)).toBe(join(parent, 'node_modules'));
// Resolves transparently for pi-actions reading deps through the link.
expect(readFileSync(join(linkPath, 'dep/index.js'), 'utf8')).toBe('module.exports = 1;\n');
});

it('slice worktree is checked out on a slice-level cook branch', () => {
const { parent } = makeGitParentWorktree('r3');

Expand Down Expand Up @@ -343,6 +358,53 @@ describe('seedSliceFromParentWorktree', () => {
);
});

describe('ensureSliceWorktree', () => {
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});

const singleSlicePlan: Plan = {
mode: 'brownfield',
epics: [{ id: 'e1', summary: '', depends_on: [], verification: [] }],
slices: [{ id: 'only', epic_id: 'e1', definition: '', depends_on: [], verification: [] }],
};

function makeGitParentWorktree(runId: string): string {
const source = mkdtempSync(join(tmpdir(), 'cook-source-'));
dirs.push(source);
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: source });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: source });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: source });
writeFileSync(join(source, 'README.md'), '# project\n');
execFileSync('git', ['add', '.'], { cwd: source });
execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: source });

const runDir = mkdtempSync(join(tmpdir(), 'cook-run-'));
dirs.push(runDir);
const parent = join(runDir, 'worktree');
execFileSync('git', ['worktree', 'add', '-q', '-b', `cook/${runId}`, parent, 'HEAD'], { cwd: source });
return parent;
}

it(
'creates the slice worktree on first call and is a no-op on repeat (rework-safe)',
() => {
const parent = makeGitParentWorktree('r1');

const first = ensureSliceWorktree(parent, 'only', singleSlicePlan, 'r1');
expect(existsSync(join(first, 'README.md'))).toBe(true);

// Second call must not throw (seedSliceFromParentWorktree would, via its
// path-availability assertion) and must return the same dir.
const second = ensureSliceWorktree(parent, 'only', singleSlicePlan, 'r1');
expect(second).toBe(first);
},
GIT_TEST_TIMEOUT_MS,
);
});

describe('mergeSlicesIntoEpicSandbox', () => {
const dirs: string[] = [];
afterEach(() => {
Expand Down
38 changes: 35 additions & 3 deletions src/orchestrator/src/epic-sandbox-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,15 +251,47 @@ export function seedSliceFromParentWorktree(
);

// 2. CoW-copy whatever's in the parent worktree but NOT in the slice
// worktree yet — i.e. untracked / gitignored content (`node_modules/`,
// `dist/`, etc.) that pi-actions might need at runtime.
// worktree yet — i.e. untracked / gitignored content (`dist/`, etc.) that
// pi-actions might need at runtime. `node_modules/` is symlinked to the
// parent's single copy instead of duplicated per slice (see
// SHAREABLE_TOP_LEVEL_ENTRIES); `walkFiles` skips symlinks, so the shared
// tree is never re-walked during dependency seeding, merge, or promotion.
const excludedNames = new Set<string>(['.git', '.brunch', EPIC_MERGE_SEGMENT]);
for (const s of plan.slices) excludedNames.add(s.id);
copyMissingTopLevelEntries(parentSandboxDir, sliceDir, excludedNames);
copyMissingTopLevelEntries(parentSandboxDir, sliceDir, excludedNames, SHAREABLE_TOP_LEVEL_ENTRIES);
Comment thread
cursor[bot] marked this conversation as resolved.

return sliceDir;
}

/**
* Top-level gitignored entries shared across slice sandboxes via symlink rather
* than CoW-copied per slice. `node_modules/` is install output that pi-actions
* read (resolve deps, run tests/build) but do not author, so a single
* parent-owned copy linked into each slice removes N-1 redundant tree copies.
* Build caches under it (`.cache`, `.vite`) become shared too — acceptable for
* cook's transient runs; revisit if a tool needs per-slice write isolation.
*/
const SHAREABLE_TOP_LEVEL_ENTRIES: ReadonlySet<string> = new Set(['node_modules']);

/**
* Idempotent codebase-mode slice worktree provisioning: create the git worktree
* on first call, no-op if it already exists. Called from `resolveSliceCwd` on
* every fire (action, run-tests, assess) and across reworks, so it must tolerate
* repeats. Provisioning is synchronous (`execFileSync`), so concurrent fires of
* distinct slices under the parallel policy serialize on the JS thread — no two
* `git worktree add` invocations against the shared object store overlap.
*/
export function ensureSliceWorktree(
parentSandboxDir: string,
sliceId: string,
plan: Plan,
runId: string,
): string {
const sliceDir = resolveSliceWorktreeDir(parentSandboxDir, sliceId);
if (existsSync(sliceDir)) return sliceDir;
return seedSliceFromParentWorktree(parentSandboxDir, sliceId, plan, runId);
Comment thread
cursor[bot] marked this conversation as resolved.
}

/** Copy completed dependency slice worktrees into `slice`'s sandbox (plan order). */
export function seedSliceSandboxFromDeps(
parentSandboxDir: string,
Expand Down
46 changes: 19 additions & 27 deletions src/orchestrator/src/net-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,9 @@
// 3. compilePlan(input, ctx) → PetriNet (convenience wrapper)
// ---------------------------------------------------------------------------

import { mkdirSync } from 'node:fs';

import {
ensureSliceWorktree,
mergeSlicesIntoEpicSandbox,
resolveSliceWorktreeDir,
seedSliceFromParentWorktree,
seedSliceSandboxFromDeps,
sliceIdsForEpicVerifyMerge,
} from './epic-sandbox-merge.js';
Expand Down Expand Up @@ -556,35 +553,30 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput,
net.addPlace(place);
}

// Runtime filesystem preparation lives in wireHandlers so every action/test
// cwd exists before any transition can fire. This is the one intentional side
// effect in the wiring pass; a future prepareRunFilesystem step can split it
// out if more provisioning responsibilities accumulate.
// Per-slice dirs are parallel-safe; dependency seeding happens at fire time.
// In codebase mode, seed each slice dir with the parent worktree's contents
// (the source repo's HEAD via `git worktree add`) so pi-actions can modify
// existing code instead of writing into an empty dir.
// Per-slice sandboxes are provisioned lazily at fire time (in resolveSliceCwd),
// not eagerly here: a run that touches 2 of 8 slices pays for 2 worktrees, not
// 8. Each slice dir is an independent root, so concurrent fires of distinct
// slices never contend; repeat fires of the same slice (rework) are idempotent.
// 'shared' (serial greenfield): all slices accrete into the run sandbox.
// 'per-slice': each slice gets its own git worktree (codebase) or plain dir
// (greenfield parallel), merged into __epic__ for verification.
// Fail fast on the missing-runId precondition rather than at first fire.
const sliceLayout = input.sliceLayout ?? 'per-slice';
if (input.sandboxMode === 'codebase') {
if (!input.runId) {
throw new Error('codebase mode requires input.runId (used to name slice-level git branches)');
}
for (const slice of plan.slices) {
seedSliceFromParentWorktree(input.sandboxDir, slice.id, plan, input.runId);
}
} else if (sliceLayout === 'per-slice') {
for (const slice of plan.slices) {
mkdirSync(resolveSliceWorktreeDir(input.sandboxDir, slice.id), { recursive: true });
}
const { runId } = input;
if (input.sandboxMode === 'codebase' && !runId) {
throw new Error('codebase mode requires input.runId (used to name slice-level git branches)');
}

const resolveSliceCwd = (slice: Slice): string =>
sliceLayout === 'shared'
? input.sandboxDir
: seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { preserveExisting: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grid stale during run-tests

Medium Severity

After write-code, the slice grid keeps the code step while the net’s deferred run-tests transition runs verification. Slice progress events were added only in pi-actions, not where mechanical runVerification runs, so the TUI misstates what the slice is doing until evaluate-done fires.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e02bfb. Configure here.

const resolveSliceCwd = (slice: Slice): string => {
if (sliceLayout === 'shared') return input.sandboxDir;
// Codebase mode: materialize the slice's git worktree (HEAD checkout +
// symlinked node_modules) on first touch so pi-actions modify existing code
// rather than an empty dir; greenfield per-slice gets a plain dir below.
if (input.sandboxMode === 'codebase') {
ensureSliceWorktree(input.sandboxDir, slice.id, plan, runId!);
}
return seedSliceSandboxFromDeps(input.sandboxDir, plan, slice, { preserveExisting: true });
};

// Register transitions with wired fire handlers
for (const skel of blueprint.transitions) {
Expand Down
Loading
Loading