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
3 changes: 2 additions & 1 deletion memory/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<parentSandboxDir>/__epic__/<epicId>/` 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`.

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions memory/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd>/.cook/runs/<runId>/worktree/`. | integration tests, worktree.test.ts | Requirement 49; D159-K |
| I124-K | Epic verification runs against a freshly-rebuilt `<parentSandboxDir>/__epic__/<epicId>/` 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

Expand Down
65 changes: 44 additions & 21 deletions src/orchestrator/src/engine-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 <parent>/__epic__/<epicId>/ (not per-slice, not parent)', async () => {
const verifyPlan: Plan = {
epics: [
{
Expand All @@ -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 });
}
});
});
205 changes: 205 additions & 0 deletions src/orchestrator/src/epic-sandbox-merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, describe, expect, it } from 'vitest';

import { 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<string, string>): 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([]);
});

it('rejects epic ids that escape the parent sandbox', () => {
const parent = makeParent();
expect(() =>
mergeSlicesIntoEpicSandbox({
parentSandboxDir: parent,
epicId: '..',
sliceIds: [],
}),
).toThrow(/Invalid epic id/);
});

it('rejects reserved __epic__ 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' });
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([]);
});
});
Loading
Loading