diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..de1ed3ce --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,101 @@ +# Scope cards — cook-artifact-lifecycle (FE-883) + +Execution queue for `cook-artifact-lifecycle` (FE-883, branch +`ka/fe-883-orchestrator-improvements`, on FE-864). + +**Reality check (corrected after basing on FE-864, the current seam):** the +brownfield git-merge composer already exists — `run-artifact.ts` (commit +871ef087): `commitSliceWorktree` + `foldSliceBranches` do a real `git merge-tree` +3-way fold of per-slice branches in dependency order, fail-closed on conflicts, +pure plumbing (I135-K preserved). It was deliberately left **unwired** pending "a +live-run check of the dependency-seed interaction". So FE-883 is *wire the +existing composer*, not *build it*. + +This matches the Slice-1 spike decision (2026-06-18): git-merge for brownfield +(common ancestor → real 3-way), file-copy union for greenfield (no common +ancestor), elevate collisions to a first-class outcome. + +--- + +## Slice 1 — wire the run-artifact composer into the live path + +Status: **in progress.** + +### Sub-steps + +``` +✓ 1a (done, commit 2357f941) — composer correct under dependency-seeding. The + deferred "live-run check" failed: a dependent slice extending a dep-seeded file + false-conflicted because slice branches share no inter-slice ancestry. Fix: + commit each slice recording its dependency commits as parents, so the fold's + merge-base is the dependency. Regression test added; unfaithful happy-path test + corrected. (epic-sandbox-merge.ts file-copy untouched.) + +✓ mechanism (commits fadb1b52, 5e1d8d32) — proved + factored the fold so both + 1b and 1c can use it: foldToCommit (fold N slice commits onto a base, fail-closed, + no ref write) + materializeFoldedWorktree (fold + `git worktree add --detach`, + rework-safe). Tests pin: 3-way merge of different-hunk edits to one file keeps + both; the fold materializes on disk in a verify worktree. + +✓ 1c DECISION (2026-06-18): verify against the folded tree (option i). One + composition path → the tree verified == the tree shipped; no verify≠ship gap on + same-file edits. The worktree-checkout unknown is de-risked by materializeFoldedWorktree. + +✓ 1b/1c INTEGRATION (done, commit d92ce38b) — engine wired end-to-end: + - net-compiler verify-epic: brownfield uses materializeEpicVerifyTree (commit + slices dep-order → fold → detached worktree at __epic__// → relink + node_modules); fold conflict → fail the epic (passed:false report → fail sibling). + Greenfield keeps the file-copy union. + - cook-cli promotion: brownfield calls harvestCookRun; fold conflicts → fatal run + outcome. I135-K preserved (all plumbing). + - commitSliceWorktree made idempotent so promotion reuses the commits verify made. + - Stale epic-sandbox-merge.ts TODO updated; SPEC I124-K amended (plan.mode fork). + - Full orchestrator suite green (672). Single-slice brownfield-smoke exercises the + engine plumbing; a *multi-slice* end-to-end engine test is still a gap to add. + +○ 1d (remaining) — retire the now-dead promoteBrownfieldRun + BrownfieldPromoteOptions. + Blocked on rewriting the landCookBranch test fixture (repoWithPromotedCook uses + promoteBrownfieldRun to build a promoted branch — rebuild it via harvestCookRun or + a plain commit). mergeSlicesIntoEpicSandbox STAYS (it is the greenfield composer). +``` + +### Acceptance Criteria (slice-level) + +``` +✓ dep-seed — a dependent slice extending a dep-seeded file folds clean (done, 1a) +○ brownfield-3way — two brownfield slices editing different hunks of the same + pre-existing file both survive promotion (the file-copy union drops one) +○ brownfield-conflict — a true overlapping-hunk conflict surfaces as a fatal run + outcome, not a buried event field +○ checkout-untouched — promotion still never touches the user's branch / tree / + index (I135-K) +○ greenfield-unchanged — serial-greenfield shared-tree + parallel-greenfield + file-copy paths preserved +``` + +### Verification Approach + +``` +- Inner: run-artifact.test.ts (done), promote-run.test.ts, epic-sandbox-merge.test.ts +- Middle: brownfield-smoke.integration.test.ts — seeded repo, overlapping slices +- Outer: dogfood a multi-slice brownfield cook with an intentional file overlap +``` + +--- + +## Slice 2 — worktree + branch GC / lifecycle (light) — `next` after Slice 1 + +A finished run reclaims its worktrees + `brunch/{run,slice}/*` refs instead of the +operator-owned cleanup `worktree.ts` documents. Ref-set depends on Slice 1's final +branch topology, so scope after it lands. Keep-on-failure for inspection; promoted +artifact survives GC. + +## Slice 3 — per-slice build-cache write isolation (candidate) + +May instead be an FE-879 follow-on (FE-879 owns `SHAREABLE_TOP_LEVEL_ENTRIES`). +Decide ownership before scoping. + +## Out of scope (noted) + +- Sync `git worktree add` serialization (`epic-sandbox-merge.ts:288`) — perf, not + correctness; FE-879 laziness already bounds worktree count. diff --git a/memory/PLAN.md b/memory/PLAN.md index c5afc673..fcd215f8 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -74,6 +74,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### Parallel / Low-conflict +- `cook-artifact-lifecycle` — **FE-883**, branch `ka/fe-883-orchestrator-improvements` (on FE-864). Wire the already-built `run-artifact.ts` git-merge composer (871ef087) into the live promotion/verify path, replacing the file-copy union; then worktree/branch GC. Slice 1a (composer correct under dep-seeding) landed; wiring + GC remain. Execution queue in `memory/CARDS.md`. - `first-run-provider-setup` — provider/key UX and runtime seam can progress independently of semantic-stack work. - `workspace-gitignore-assist` — small workspace hygiene surface with low overlap. - `productized-web-research` — waits on prompt/context scenario substrate for probe quality, but can remain separate from semantic schema work. @@ -475,6 +476,21 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Traceability:** Requirement 49; D166-K (extend to brownfield), A49; cook-codebase-mode promotion follow-on. - **Design docs:** `docs/design/orchestrator.md`; SPEC §A49. +### cook-artifact-lifecycle + +- **Name:** Cook artifact lifecycle — wire the git-merge composer + worktree/branch GC +- **Linear:** FE-883 +- **Kind:** structural (Slice 1 amends I124-K on wiring); hardening +- **Status:** in progress (2026-06-18) — branch `ka/fe-883-orchestrator-improvements` on FE-864; queue in `memory/CARDS.md`. Slice 1a landed (`2357f941`). +- **Objective:** Wire the already-built `run-artifact.ts` composer (`commitSliceWorktree` + `foldSliceBranches`, 871ef087) into cook's live promotion/verify path, replacing the file-copy union (`mergeSlicesIntoEpicSandbox` / `promoteBrownfieldRun` cpSync) whose declaration-order last-slice-wins silently drops same-file/different-hunk edits. Brownfield only (common ancestor → real 3-way); greenfield keeps the file-copy union (no common ancestor). Then GC of run worktrees + `brunch/{run,slice}/*` refs. +- **Slice 1a (done, `2357f941`):** resolved the dependency-seed interaction the composer was left unwired for — slice commits now record their dependency commits as parents so the fold's merge-base is the dependency (a dep-seeded file reads as an edit, not an add/add false conflict). Regression test + unfaithful happy-path test corrected. Composer correctness only; still unwired. +- **Remaining:** 1b wire into promotion + cook-cli (preserve I135-K), 1c verify-epic consistency (design fork: verify against the folded tree vs keep file-copy + elevate collisions), 1d delete the superseded file-copy path + the stale `epic-sandbox-merge.ts:226` TODO. Then Slice 2 (GC). +- **Main risk (1a) — RESOLVED:** see Slice 1a. Open: 1c is a design fork on verify-epic consistency. +- **Acceptance / verification:** see `memory/CARDS.md`. +- **Depends on:** `brownfield-promotion` FE-877 (done), the FE-864 composer 871ef087 (done), FE-879 lazy/shared-`node_modules` (done). +- **Traceability:** Requirement 49; I124-K (file-copy → fork on `plan.mode` on wiring), I135-K (promotion checkout-untouched, preserved); A49. Precursor to Horizon `parallel-merge-conflict-reconciliation`. +- **Design docs:** `docs/design/orchestrator.md`; SPEC §A49. + ### brunch-ship - **Name:** Brunch ship — one-shot autonomous spec→feature wrapper diff --git a/memory/SPEC.md b/memory/SPEC.md index 123d4719..93b32759 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -271,7 +271,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 `/.brunch/cook/runs//worktree/`. Slice layout follows policy (D165-K): serial greenfield runs all slices in the single shared run tree (verify-epic in place, no `__epic__`); parallel greenfield and brownfield isolate per slice and merge into `__epic__//`. Brownfield clones the cwd repo and preserves the source repo's HEAD and tracked-file state byte-identically; greenfield never clones the source. | worktree.test.ts, brownfield-smoke.integration.test.ts, engine-contract.test.ts | Requirement 49; D159-K, D164-K, D165-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 | +| I124-K | Epic verification runs against a freshly-rebuilt `/__epic__//` dir holding the merge of its completed slices' worktrees; per-slice worktrees are not mutated by the merge. **The composer forks on `plan.mode` (FE-883):** **brownfield** composes by a `git merge-tree` 3-way fold of committed per-slice branches in dependency order (slice commits carry their dependency commits as parents so dep-seeded files merge as edits, not add/add) — different-hunk edits to one file both survive, and a real conflict is **fail-closed** (the epic fails / promotion halts, not silent last-slice-wins). This is the *same* fold promotion ships, so the verified tree equals the shipped tree. **Greenfield** keeps the file-copy union (no common ancestor for a 3-way): later slices in declaration order win on path collisions, reported via the `epic-sandbox-merged` event. | epic-sandbox-merge.test.ts, run-artifact.test.ts, engine-contract.test.ts | Requirement 49; D159-K (FE-883) | | I125-K | Topology output-place candidates are fully declared in `HandlerDescriptor` via typed `Guard` predicates; `wireHandlers` introduces no new output places at fire time. Pure consumers can enumerate the reachable output-place set per transition from topology data alone via `enumerateCandidateOutputs(transition)`. Halt paths (budget exhaustion, verify-epic failure) and token transforms (reportId attach, retry/rework count propagation) remain runtime concerns and are explicitly not covered by this invariant. | topology.test.ts, engine-contract.test.ts | Requirements 46, 47, 48; D155-K (FE-747) | | I126-K | The cook evaluator observes, never produces: `evaluate-done` runs with read-only tools (`toolsForAction('evaluate-done') === 'read'`) so it cannot mutate the sandbox during evaluation, and per-slice `done` reflects real execution of the slice's verification targets — ≥1 target and every target passing via the shared `runVerification` seam (one `TestRunner`; `evaluate-done`, `verify-epic`, and the net `run-tests` path share it — FE-872 unification; `evaluateVerificationTargets` / private `runTest` deleted) — rather than an LLM verdict. | pi-actions.test.ts, engine-contract.test.ts, brownfield-smoke.integration.test.ts | Requirements 46–50; D161-K (FE-813) | | I127-K | Brunch's Petrinaut stream markings are count-only (`Marking = Record`): the static reducer and the live bus produce per-place token counts in each firing's arc-scoped consume/produce delta (A99; `initialState` is the single full marking), with no `TokenColour[]` arm. The wire `NetDefinition` is plain-graph (no `colorId` or other SDCPN fields), so slice/colour identity has no wire carrier — identity fold only. The projected definition validates against the mirrored `brunchNetDefinitionSchema` under `.strict()`. | petrinaut-stream-export.test.ts (arc-scoped delta oracle + strict-schema validation), petrinaut-stream-bus.test.ts (replay-equivalence) | Requirement 48; D162-K, D163-K (FE-819) | diff --git a/src/orchestrator/src/cook-cli.ts b/src/orchestrator/src/cook-cli.ts index 20ebad41..939a1ac7 100644 --- a/src/orchestrator/src/cook-cli.ts +++ b/src/orchestrator/src/cook-cli.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { cookBannerLines, cookSummaryLines } from './cook-report.js'; +import { type CookFinishLand, cookBannerLines, cookFinishLines, cookSummaryLines } from './cook-report.js'; import { createOrchestrator } from './engine.js'; import { type MergeConflict, mergeCompletedSlicesIntoTree } from './epic-sandbox-merge.js'; import { FileReportSink } from './file-report-sink.js'; @@ -15,7 +15,8 @@ import { createPiActions } from './pi-actions.js'; import { loadPlan } from './plan-loader.js'; import type { CookBus } from './presenter.js'; import { resolveToolchain } from './project-profile.js'; -import { landCookBranch, promoteBrownfieldRun, promoteGreenfieldRun } from './promote-run.js'; +import { landCookBranch, promoteGreenfieldRun } from './promote-run.js'; +import { harvestCookRun } from './run-artifact.js'; import { brunchRef } from './run-refs.js'; import { parseSpecId, resolveLatestSpecPlanPath, specPlanPath, specsRootDir } from './spec-plan-paths.js'; import { ToolchainTestRunner } from './test-runner.js'; @@ -564,44 +565,53 @@ export async function runCook(opts: CookOptions, bus: CookBus): Promise { line(''); } else { try { - const source = promotionSourceDir({ - sliceLayout, - sandboxDir, - runDir, - plan, - completedSliceIds: result.slices.filter((s) => s.status === 'completed').map((s) => s.sliceId), - }); - for (const c of source.conflicts) { - line(` ! merge conflict on ${c.path} (slices ${c.slices.join(', ')}; kept ${c.winner})`); - } - const promoted = promoting(`promoting → ${brunchRef.run(runId)}`, () => - promoteBrownfieldRun({ + const completedSliceIds = result.slices + .filter((s) => s.status === 'completed') + .map((s) => s.sliceId); + // Compose by git merge-tree fold (FE-883): per-slice history, fail-closed + // on real conflicts, all plumbing (the user's checkout is never touched). + const artifact = promoting(`promoting → ${brunchRef.run(runId)}`, () => + harvestCookRun({ sourceDir: sandbox.sourceDir, - sourceTreeDir: source.dir, + parentSandboxDir: sandboxDir, runId, + plan, + completedSliceIds, }), ); + if (artifact.conflicts.length > 0) { + for (const c of artifact.conflicts) { + line(` ✗ merge conflict in slice ${c.sliceId} on ${c.paths.join(', ')}`); + } + line( + ` ✗ promotion halted at ${artifact.branch} @ ${artifact.head.slice(0, 8)} — resolve the conflict and re-run`, + ); + line(''); + recordCookExitStatus(false); + return; + } + let land: CookFinishLand | undefined; if (opts.landBranch) { - const landed = promoting(`landing → ${promoted.branch} into the active branch`, () => + const landed = promoting(`landing → ${artifact.branch} into the active branch`, () => landCookBranch({ sourceDir: sandbox.sourceDir, runId }), ); if (landed.kind === 'landed') { - line(` ✓ promoted + landed ${promoted.branch} onto ${landed.branch} (${landed.mode})`); + land = { kind: 'landed', branch: landed.branch, mode: landed.mode }; } else if (landed.kind === 'refused') { - line( - ` ✓ promoted → ${promoted.branch} @ ${promoted.commit.slice(0, 8)} (not landed: working tree ${landed.reason}; merge it when ready)`, - ); + land = { kind: 'refused', reason: landed.reason }; } else { - line( - ` ✓ promoted → ${promoted.branch} @ ${promoted.commit.slice(0, 8)} (not landed: merge conflict on ${landed.branch}; resolve with \`git merge ${promoted.branch}\`)`, - ); + land = { kind: 'conflict', branch: landed.branch }; } - } else { - line( - ` ✓ promoted → ${promoted.branch} @ ${promoted.commit.slice(0, 8)} (merge it into your branch when ready)`, - ); } - line(''); + for (const l of cookFinishLines({ + shape: 'brownfield', + dir: sandbox.sourceDir, + branch: artifact.branch, + commit: artifact.head, + ...(land ? { land } : {}), + })) { + line(l); + } } catch (err) { const reason = `promotion failed: ${err instanceof Error ? err.message : String(err)}`; line(` ✗ ${reason}`); @@ -635,8 +645,14 @@ export async function runCook(opts: CookOptions, bus: CookBus): Promise { force: opts.force, }), ); - line(` ✓ promoted → ${promoted.target} (${promoted.branch} @ ${promoted.commit.slice(0, 8)})`); - line(''); + for (const l of cookFinishLines({ + shape: 'greenfield', + dir: promoted.target, + branch: promoted.branch, + commit: promoted.commit, + })) { + line(l); + } } catch (err) { const reason = `promotion failed: ${err instanceof Error ? err.message : String(err)}`; line(` ✗ ${reason}`); diff --git a/src/orchestrator/src/cook-report.test.ts b/src/orchestrator/src/cook-report.test.ts index 0eaf7984..d21632f6 100644 --- a/src/orchestrator/src/cook-report.test.ts +++ b/src/orchestrator/src/cook-report.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { cookBannerLines, cookSummaryLines } from './cook-report.js'; +import { cookBannerLines, cookFinishLines, cookSummaryLines } from './cook-report.js'; describe('cookBannerLines', () => { it('renders the cook banner block byte-for-byte', () => { @@ -92,3 +92,98 @@ describe('cookSummaryLines', () => { ]); }); }); + +describe('cookFinishLines', () => { + it('renders a brownfield promotion (not landed) with merge-when-ready next steps', () => { + expect( + cookFinishLines({ + shape: 'brownfield', + dir: '/repo', + branch: 'brunch/run/abc', + commit: '8004de40c0ffee', + }), + ).toEqual([ + ' ──────────────────────────────────────', + ' ✓ cook → promoted', + '', + ' dir /repo', + ' branch brunch/run/abc', + ' commit 8004de40', + '', + ' next — merge into your branch when ready', + ' git log --oneline brunch/run/abc -10', + ' git merge brunch/run/abc', + '', + ]); + }); + + it('renders a brownfield run that landed into the active branch', () => { + expect( + cookFinishLines({ + shape: 'brownfield', + dir: '/repo', + branch: 'brunch/run/abc', + commit: '8004de40c0ffee', + land: { kind: 'landed', branch: 'main', mode: 'fast-forward' }, + }), + ).toEqual([ + ' ──────────────────────────────────────', + ' ✓ cook → promoted + landed', + '', + ' dir /repo', + ' branch brunch/run/abc', + ' commit 8004de40', + ' landed main (fast-forward)', + '', + ' next — landed on main', + ' git log --oneline -10', + '', + ]); + }); + + it('renders a brownfield run whose land was refused on a dirty tree', () => { + expect( + cookFinishLines({ + shape: 'brownfield', + dir: '/repo', + branch: 'brunch/run/abc', + commit: '8004de40c0ffee', + land: { kind: 'refused', reason: 'dirty' }, + }), + ).toEqual([ + ' ──────────────────────────────────────', + ' ✓ cook → promoted', + '', + ' dir /repo', + ' branch brunch/run/abc', + ' commit 8004de40', + '', + ' next — not landed (working tree dirty); merge when ready', + ' git merge brunch/run/abc', + '', + ]); + }); + + it('renders a greenfield promotion with cd-into-the-target next steps', () => { + expect( + cookFinishLines({ + shape: 'greenfield', + dir: '/out/app', + branch: 'main', + commit: '8004de40c0ffee', + }), + ).toEqual([ + ' ──────────────────────────────────────', + ' ✓ cook → promoted', + '', + ' dir /out/app', + ' branch main', + ' commit 8004de40', + '', + ' next', + ' cd /out/app', + ' git log -1', + '', + ]); + }); +}); diff --git a/src/orchestrator/src/cook-report.ts b/src/orchestrator/src/cook-report.ts index c4fd9eb7..842464fa 100644 --- a/src/orchestrator/src/cook-report.ts +++ b/src/orchestrator/src/cook-report.ts @@ -61,3 +61,62 @@ export function cookSummaryLines(input: CookSummaryInput): string[] { lines.push('', ` ${input.reportCount} events → ${input.reportsPath}`, ''); return lines; } + +/** How a brownfield `--land` resolved, when `--land` was passed. */ +export type CookFinishLand = + | { kind: 'landed'; branch: string; mode: string } + | { kind: 'refused'; reason: string } + | { kind: 'conflict'; branch: string }; + +export type CookFinishInput = { + /** Brownfield results live in the repo (merge when ready); greenfield is a fresh tree. */ + shape: 'brownfield' | 'greenfield'; + /** Navigable directory the result landed in (repo root, or the --out target). */ + dir: string; + branch: string; + /** Full commit hash; rendered as its 8-char short form. */ + commit: string; + land?: CookFinishLand; +}; + +/** + * Final block printed when a run promotes: where it landed, the commit, and + * copy-paste commands to drive the next action. Pure + golden-tested for the + * same reason as the other builders here — the strings are the contract. + * Halt/conflict/error paths keep their own inline lines; this is the success seam. + */ +export function cookFinishLines(input: CookFinishInput): string[] { + const landed = input.land?.kind === 'landed' ? input.land : undefined; + const lines: string[] = [ + ' ──────────────────────────────────────', + ` ✓ cook → ${landed ? 'promoted + landed' : 'promoted'}`, + '', + ` ${'dir'.padEnd(6)} ${input.dir}`, + ` ${'branch'.padEnd(6)} ${input.branch}`, + ` ${'commit'.padEnd(6)} ${input.commit.slice(0, 8)}`, + ]; + if (landed) lines.push(` ${'landed'.padEnd(6)} ${landed.branch} (${landed.mode})`); + + const next: string[] = []; + let hint = ''; + if (input.shape === 'greenfield') { + next.push(`cd ${input.dir}`, 'git log -1'); + } else if (landed) { + hint = `landed on ${landed.branch}`; + next.push('git log --oneline -10'); + } else if (input.land?.kind === 'refused') { + hint = `not landed (working tree ${input.land.reason}); merge when ready`; + next.push(`git merge ${input.branch}`); + } else if (input.land?.kind === 'conflict') { + hint = 'not landed (merge conflict); resolve manually'; + next.push(`git merge ${input.branch}`); + } else { + hint = 'merge into your branch when ready'; + next.push(`git log --oneline ${input.branch} -10`, `git merge ${input.branch}`); + } + + lines.push('', ` next${hint ? ` — ${hint}` : ''}`); + for (const cmd of next) lines.push(` ${cmd}`); + lines.push(''); + return lines; +} diff --git a/src/orchestrator/src/epic-sandbox-merge.ts b/src/orchestrator/src/epic-sandbox-merge.ts index 5807bcc2..d6ebcf2e 100644 --- a/src/orchestrator/src/epic-sandbox-merge.ts +++ b/src/orchestrator/src/epic-sandbox-merge.ts @@ -96,7 +96,7 @@ function assertSafePathSegment(id: string, label: string): void { } } -function resolveEpicSandboxDir(parentSandboxDir: string, epicId: string): string { +export function resolveEpicSandboxDir(parentSandboxDir: string, epicId: string): string { assertSafePathSegment(epicId, 'epic id'); const parent = resolve(parentSandboxDir); const epicRoot = resolve(parent, EPIC_MERGE_SEGMENT); @@ -223,13 +223,13 @@ function assertSliceWorktreePathAvailable(parentSandboxDir: string, sliceId: str * slice worktree — `git worktree add` would fail with "already exists." The * caller must remove the prior worktree first if re-seeding. * - * TODO(cook-artifact-lifecycle follow-on, separate frontier): the slice branch - * exists but is never committed to. After this lands, a future frontier should - * add slice-completion commits, replace `mergeSlicesIntoEpicSandbox`'s file-copy - * with a git merge of slice branches into an epic branch, and surface real - * merge conflicts (today's file-copy is silent last-slice-wins). That work - * earns the "discoverable cook artifact" criterion via `git merge brunch/run/` - * promotion semantics. + * Brownfield slice branches are now committed and folded by `run-artifact.ts` + * (`commitSliceWorktree` + the `merge-tree` fold), wired into both verify-epic + * and promotion (FE-883) — real conflicts surface fail-closed instead of the old + * silent last-slice-wins. `mergeSlicesIntoEpicSandbox` below is the **greenfield** + * composer only: greenfield slices share no common ancestor, so a 3-way merge has + * no base to merge against and the file-copy union (declaration-order-wins, + * collisions reported) is the right tool there. */ export function seedSliceFromParentWorktree( parentSandboxDir: string, @@ -277,7 +277,7 @@ export function seedSliceFromParentWorktree( * 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 = new Set(['node_modules']); +export const SHAREABLE_TOP_LEVEL_ENTRIES: ReadonlySet = new Set(['node_modules']); /** * Idempotent codebase-mode slice worktree provisioning: create the git worktree diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 6afda0f9..f12213fc 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -16,6 +16,7 @@ import type { NetBlueprint, TokenSeed, TransitionSkeleton } from './net-blueprin import { PetriNet } from './petri-net.js'; import type { Token } from './petri-net.js'; import { createReport } from './report-helpers.js'; +import { materializeEpicVerifyTree } from './run-artifact.js'; import { runVerification } from './test-runner.js'; import type { ActionContext, OrchestratorInput, Plan, RunCtx, RunPolicy, Slice } from './types.js'; @@ -871,25 +872,65 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const mergeSliceIds = sliceIdsInMergeOrder.filter( (sid) => ctx.sliceOutcomes.get(sid)?.status === 'completed', ); - const merge = mergeSlicesIntoEpicSandbox({ - parentSandboxDir: input.sandboxDir, - epicId, - sliceIds: mergeSliceIds, - }); - ctx.reportIds.push( - createReport(reports, { + if (input.sandboxMode === 'codebase') { + // Brownfield: compose by git merge-tree fold so verify-epic runs + // against the same tree promotion will ship — not a file-copy union + // that silently last-slice-wins on same-file edits (FE-883). + const folded = materializeEpicVerifyTree({ + parentSandboxDir: input.sandboxDir, + runId: runId!, + plan, + sliceIds: mergeSliceIds, epicId, - sliceId: '', - actor: 'orchestrator', - event: 'epic-sandbox-merged', - payload: { - epicSandboxDir: merge.epicSandboxDir, - sliceIds: mergeSliceIds, - conflicts: merge.conflicts, - }, - }), - ); - epicSandboxDir = merge.epicSandboxDir; + }); + ctx.reportIds.push( + createReport(reports, { + epicId, + sliceId: '', + actor: 'orchestrator', + event: 'epic-sandbox-merged', + payload: { + epicSandboxDir: folded.epicSandboxDir, + sliceIds: mergeSliceIds, + conflicts: folded.conflicts, + }, + }), + ); + // Fail-closed: a real cross-slice conflict leaves a partial tree; + // fail the epic rather than verify it. Routes via the fail sibling. + if (folded.conflicts.length > 0) { + const failId = createReport(reports, { + epicId, + sliceId: '', + actor: 'orchestrator', + event: 'epic-verified', + payload: { passed: false, reason: 'merge-conflict', conflicts: folded.conflicts }, + }); + ctx.reportIds.push(failId); + return [{ place: intermediatePlace, token: { ...inputToken, reportId: failId } }]; + } + epicSandboxDir = folded.epicSandboxDir; + } else { + const merge = mergeSlicesIntoEpicSandbox({ + parentSandboxDir: input.sandboxDir, + epicId, + sliceIds: mergeSliceIds, + }); + ctx.reportIds.push( + createReport(reports, { + epicId, + sliceId: '', + actor: 'orchestrator', + event: 'epic-sandbox-merged', + payload: { + epicSandboxDir: merge.epicSandboxDir, + sliceIds: mergeSliceIds, + conflicts: merge.conflicts, + }, + }), + ); + epicSandboxDir = merge.epicSandboxDir; + } } else { epicSandboxDir = input.sandboxDir; } diff --git a/src/orchestrator/src/presenter/phase.test.ts b/src/orchestrator/src/presenter/phase.test.ts index ccb67d4d..b43159cb 100644 --- a/src/orchestrator/src/presenter/phase.test.ts +++ b/src/orchestrator/src/presenter/phase.test.ts @@ -50,6 +50,9 @@ describe('nextPhase', () => { it('advances to plate on a promotion line and to serve on a completed run', () => { expect(nextPhase('cook', { kind: 'line', text: ' ✓ promoted → cook/abc @ 1234abcd' })).toBe('plate'); + // The finish block's phrasing puts `promoted` after the phase word; it must still light plate. + expect(nextPhase('cook', { kind: 'line', text: ' ✓ cook → promoted' })).toBe('plate'); + expect(nextPhase('cook', { kind: 'line', text: ' ✓ cook → promoted + landed' })).toBe('plate'); expect(nextPhase('plate', { kind: 'cook-done', ok: true })).toBe('serve'); }); diff --git a/src/orchestrator/src/presenter/phase.ts b/src/orchestrator/src/presenter/phase.ts index 7385f479..38e51d39 100644 --- a/src/orchestrator/src/presenter/phase.ts +++ b/src/orchestrator/src/presenter/phase.ts @@ -43,7 +43,10 @@ function phaseFor(event: CookEvent, ctx?: PhaseContext): BrigadePhase | undefine return ctx.epics.every((e) => ctx.verdictedEpics?.has(e)) ? 'taste' : undefined; } case 'line': - return /^\s*✓\s+promoted\b/.test(event.text) ? 'plate' : undefined; + // The promotion line lights plate. Key on the `promoted` token after the + // ✓ rather than its position, so the finish block's `✓ cook → promoted` + // phrasing reads the same as the older `✓ promoted → …` line did. + return /^\s*✓.*\bpromoted\b/.test(event.text) ? 'plate' : undefined; case 'cook-done': // ship→serve: the run completed (emitted after promotion). A halted run // does not ship, so it never lights serve. diff --git a/src/orchestrator/src/run-artifact.test.ts b/src/orchestrator/src/run-artifact.test.ts index de25c3ae..f54990ac 100644 --- a/src/orchestrator/src/run-artifact.test.ts +++ b/src/orchestrator/src/run-artifact.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -12,6 +12,8 @@ import { dependencyOrder, foldSliceBranches, harvestCookRun, + materializeEpicVerifyTree, + materializeFoldedWorktree, type SliceCommit, } from './run-artifact.js'; import type { Plan, Slice } from './types.js'; @@ -106,6 +108,38 @@ describe('foldSliceBranches (git merge-tree plumbing)', () => { expect(folded).not.toContain('B1'); }); + it('3-way merges different-hunk edits to the same file (the file-copy union would drop one)', () => { + // The headline correctness win over the file-copy union: two independent + // slices edit different lines of the same pre-existing file. A whole-file + // last-slice-wins copy keeps only one; the merge-tree fold keeps both. + const a = sliceBranch('a', (d) => writeFileSync(join(d, 'base.txt'), 'A1\nl2\nl3\n')); + const b = sliceBranch('b', (d) => writeFileSync(join(d, 'base.txt'), 'l1\nl2\nB3\n')); + + const artifact = foldSliceBranches({ sourceDir: repo, runId: 'r1', slices: [a, b] }); + + expect(artifact.conflicts).toEqual([]); + const folded = gitC(repo, 'show', `${brunchRef.run('r1')}:base.txt`); // gitC trims trailing newline + expect(folded).toBe('A1\nl2\nB3'); // both edits survive + }); + + it('materializes the fold as a worktree on disk (verify against the shipped tree)', () => { + // 1c: verify-epic runs tests against the same merged tree promotion ships. + // Different-hunk edits to the same file must both be present on disk in the + // checked-out verify worktree (the file-copy union would drop one). + const a = sliceBranch('a', (d) => writeFileSync(join(d, 'base.txt'), 'A1\nl2\nl3\n')); + const b = sliceBranch('b', (d) => writeFileSync(join(d, 'base.txt'), 'l1\nl2\nB3\n')); + const dest = join(repo, '__verify__'); + + const { conflicts } = materializeFoldedWorktree({ sourceDir: repo, base, slices: [a, b], destDir: dest }); + + expect(conflicts).toEqual([]); + expect(readFileSync(join(dest, 'base.txt'), 'utf8')).toBe('A1\nl2\nB3\n'); + // Re-runnable (rework): a second materialize over the same dest must not throw. + expect(() => + materializeFoldedWorktree({ sourceDir: repo, base, slices: [a, b], destDir: dest }), + ).not.toThrow(); + }); + it('squash collapses the fold into a single commit off the base', () => { const a = sliceBranch('a', (d) => writeFileSync(join(d, 'a.txt'), 'A\n')); const b = sliceBranch('b', (d) => writeFileSync(join(d, 'b.txt'), 'B\n')); @@ -154,10 +188,11 @@ describe('harvestCookRun (commit slice worktrees + fold)', () => { seedSliceWorktree('a', (d) => writeFileSync(join(d, 'a.txt'), 'A\n')); seedSliceWorktree('b', (d) => writeFileSync(join(d, 'b.txt'), 'B\n')); + // Disjoint siblings (the dependency-seeded case has its own test below). const plan: Plan = { mode: 'greenfield', epics: [{ id: 'e', summary: 'E', depends_on: [], verification: [] }], - slices: [slice('a'), slice('b', ['a'])], + slices: [slice('a'), slice('b')], }; const run: CompletedRun = { sourceDir: source, @@ -175,10 +210,66 @@ describe('harvestCookRun (commit slice worktrees + fold)', () => { expect(files).toEqual(['a.txt', 'b.txt', 'base.txt']); }); + it('dependency-seeded: a dependent slice that extends a dep file folds clean (no false conflict)', () => { + // The dep-seed interaction the composer was left unwired for (871ef087). In a + // real run, slice B (depends on A) has A's completed output copied into its + // worktree by seedSliceSandboxFromDeps, then B extends it. Both slice branches + // are rooted at the run base, so neither has the other as an ancestor. + seedSliceWorktree('a', (d) => writeFileSync(join(d, 'lib.ts'), 'export const a = 1;\n')); + seedSliceWorktree('b', (d) => + // dep-seeded A output, then B's own extension on top of it + writeFileSync(join(d, 'lib.ts'), 'export const a = 1;\nexport const b = 2;\n'), + ); + + const plan: Plan = { + mode: 'brownfield', + epics: [{ id: 'e', summary: 'E', depends_on: [], verification: [] }], + slices: [slice('a'), slice('b', ['a'])], + }; + const run: CompletedRun = { + sourceDir: source, + parentSandboxDir: parent, + runId: 'r1', + plan, + completedSliceIds: ['a', 'b'], + }; + + const artifact = harvestCookRun(run); + + // Desired: B's evolution of the dep-seeded file wins, no spurious conflict. + expect(artifact.conflicts).toEqual([]); + const lib = gitC(source, 'show', `${brunchRef.run('r1')}:lib.ts`); + expect(lib).toContain('export const b = 2;'); + }); + it('skips a slice that produced no changes', () => { seedSliceWorktree('a', (d) => writeFileSync(join(d, 'a.txt'), 'A\n')); seedSliceWorktree('noop', () => {}); // wrote nothing expect(commitSliceWorktree({ parentSandboxDir: parent, slice: slice('noop') })).toBeNull(); }); + + it('reuses slices already committed by a prior verify pass (verify → promote)', () => { + // verify-epic commits the slice worktrees (materializeEpicVerifyTree); the + // later promotion harvest must reuse those commits, not see a clean worktree + // and drop the slice as empty. + seedSliceWorktree('a', (d) => writeFileSync(join(d, 'a.txt'), 'A\n')); + const plan: Plan = { + mode: 'brownfield', + epics: [{ id: 'e', summary: 'E', depends_on: [], verification: [] }], + slices: [slice('a')], + }; + + materializeEpicVerifyTree({ parentSandboxDir: parent, runId: 'r1', plan, sliceIds: ['a'], epicId: 'e' }); + const artifact = harvestCookRun({ + sourceDir: source, + parentSandboxDir: parent, + runId: 'r1', + plan, + completedSliceIds: ['a'], + }); + + expect(artifact.commits.map((c) => c.sliceId)).toEqual(['a']); + expect(gitC(source, 'ls-tree', '-r', '--name-only', brunchRef.run('r1')).split('\n')).toContain('a.txt'); + }); }); diff --git a/src/orchestrator/src/run-artifact.ts b/src/orchestrator/src/run-artifact.ts index 21af5877..f8c3b5bd 100644 --- a/src/orchestrator/src/run-artifact.ts +++ b/src/orchestrator/src/run-artifact.ts @@ -10,9 +10,15 @@ // are nested under the run worktree, so an in-worktree merge would be a footgun. import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; import { resolve } from 'node:path'; -import { resolveSliceWorktreeDir } from './epic-sandbox-merge.js'; +import { linkSharedTopLevelEntries } from './cow-copy.js'; +import { + resolveEpicSandboxDir, + resolveSliceWorktreeDir, + SHAREABLE_TOP_LEVEL_ENTRIES, +} from './epic-sandbox-merge.js'; import { brunchRef } from './run-refs.js'; import type { Plan, Slice } from './types.js'; @@ -119,14 +125,45 @@ export function dependencyOrder(plan: Plan, completedSliceIds: readonly string[] * Commit a slice's worktree to its `brunch/slice//` branch. * Returns the commit handle, or null when the slice produced no changes (nothing * to fold). Runs only in the slice's own throwaway worktree. + * + * `parents` carries the commits of this slice's completed dependencies. The slice + * worktree was seeded with its deps' output (`seedSliceSandboxFromDeps`), so + * recording that ancestry makes the run fold compute the right merge base: a + * dep-seeded file then reads as an edit the dependent slice evolved, not an + * add/add conflict against the run base. Without it the fold false-conflicts on + * every dep-modified file — the dependency-seed interaction this composer was + * left unwired for (871ef087). */ -export function commitSliceWorktree(opts: { parentSandboxDir: string; slice: Slice }): SliceCommit | null { +export function commitSliceWorktree(opts: { + parentSandboxDir: string; + slice: Slice; + parents?: readonly string[]; +}): SliceCommit | null { const sliceDir = resolveSliceWorktreeDir(opts.parentSandboxDir, opts.slice.id); - git(['add', '-A'], sliceDir); - if (git(['diff', '--cached', '--name-only'], sliceDir) === '') return null; const title = sliceTitle(opts.slice); - git([...COMMIT_IDENTITY, 'commit', '-q', '-m', `brunch(${opts.slice.id}): ${title}`], sliceDir); - return { sliceId: opts.slice.id, commit: git(['rev-parse', 'HEAD'], sliceDir), title }; + git(['add', '-A'], sliceDir); + if (git(['diff', '--cached', '--name-only'], sliceDir) === '') { + // Nothing new to stage. If a prior verify-epic already committed this slice + // (its tip is our `brunch():` commit), reuse it — verify and promotion + // both harvest the same worktrees, and the second pass must not lose the + // first's commit. Otherwise the slice genuinely produced no changes. + const headMsg = git(['log', '-1', '--format=%s'], sliceDir); + if (headMsg.startsWith(`brunch(${opts.slice.id}):`)) { + return { sliceId: opts.slice.id, commit: git(['rev-parse', 'HEAD'], sliceDir), title }; + } + return null; + } + // commit-tree (not `git commit`) so the slice commit can carry its dependency + // commits as additional parents alongside the run base. + const base = git(['rev-parse', 'HEAD'], sliceDir); + const tree = git(['write-tree'], sliceDir); + const parentArgs = ['-p', base, ...(opts.parents ?? []).flatMap((p) => ['-p', p])]; + const commit = git( + [...COMMIT_IDENTITY, 'commit-tree', tree, ...parentArgs, '-m', `brunch(${opts.slice.id}): ${title}`], + sliceDir, + ); + git(['update-ref', 'HEAD', commit], sliceDir); + return { sliceId: opts.slice.id, commit, title }; } /** @@ -149,6 +186,37 @@ export function foldSliceBranches(opts: { const ref = `refs/heads/${branch}`; const base = git(['rev-parse', '--verify', ref], sourceDir); + const folded = foldToCommit({ sourceDir, base, slices: opts.slices }); + let head = folded.head; + + // per-slice-then-merge: publish the folded chain of merge nodes. + // squash: collapse the final tree into a single commit off the base instead. + if (granularity === 'squash' && folded.commits.length > 0) { + const tree = git(['rev-parse', `${head}^{tree}`], sourceDir); + head = git([...COMMIT_IDENTITY, 'commit-tree', tree, '-p', base, '-m', `cook: ${opts.runId}`], sourceDir); + } + if (head !== base) git(['update-ref', ref, head, base], sourceDir); + + return { + branch, + head, + commits: granularity === 'squash' ? [] : folded.commits, + conflicts: folded.conflicts, + }; +} + +/** + * Core fold: `merge-tree`-fold the slice commits onto `base` in dependency order, + * one merge node per slice, fail-closed at the first real conflict (leaving the + * last clean tip). Writes no refs — callers decide whether to publish the result + * (promotion → run branch) or check it out (verify-epic → a worktree). + */ +function foldToCommit(opts: { sourceDir: string; base: string; slices: readonly SliceCommit[] }): { + head: string; + commits: SliceCommit[]; + conflicts: SliceConflict[]; +} { + const { sourceDir, base } = opts; let head = base; const commits: SliceCommit[] = []; const conflicts: SliceConflict[] = []; @@ -157,9 +225,9 @@ export function foldSliceBranches(opts: { const merged = mergeTree(sourceDir, head, slice.commit); if (!merged.ok) { conflicts.push({ sliceId: slice.sliceId, paths: merged.paths }); - break; // fail-closed: stop, leave the run branch at the last clean tip + break; // fail-closed: stop at the last clean tip } - const node = git( + head = git( [ ...COMMIT_IDENTITY, 'commit-tree', @@ -173,19 +241,39 @@ export function foldSliceBranches(opts: { ], sourceDir, ); - head = node; commits.push(slice); } - // per-slice-then-merge: publish the folded chain of merge nodes. - // squash: collapse the final tree into a single commit off the base instead. - if (granularity === 'squash' && commits.length > 0) { - const tree = git(['rev-parse', `${head}^{tree}`], sourceDir); - head = git([...COMMIT_IDENTITY, 'commit-tree', tree, '-p', base, '-m', `cook: ${opts.runId}`], sourceDir); - } - if (head !== base) git(['update-ref', ref, head, base], sourceDir); + return { head, commits, conflicts }; +} - return { branch, head, commits: granularity === 'squash' ? [] : commits, conflicts }; +/** + * Materialize the fold of `slices` onto `base` as a detached git worktree at + * `destDir`, so verify-epic runs tests against the *same* merged tree promotion + * will ship — not a file-copy union that can diverge on same-file edits. The fold + * is fail-closed: on a real conflict the worktree is the last clean tip and the + * conflicts are returned. Re-creatable across reworks (a prior worktree at destDir + * is removed first). Caller relinks shareable gitignored entries (node_modules) + * since the fold tree carries only tracked content. + */ +export function materializeFoldedWorktree(opts: { + sourceDir: string; + base: string; + slices: readonly SliceCommit[]; + destDir: string; +}): { conflicts: SliceConflict[] } { + const sourceDir = resolve(opts.sourceDir); + const folded = foldToCommit({ sourceDir, base: opts.base, slices: opts.slices }); + if (existsSync(opts.destDir)) { + try { + git(['worktree', 'remove', '--force', opts.destDir], sourceDir); + } catch { + rmSync(opts.destDir, { recursive: true, force: true }); + } + } + git(['worktree', 'prune'], sourceDir); + git(['worktree', 'add', '--quiet', '--detach', opts.destDir, folded.head], sourceDir); + return { conflicts: folded.conflicts }; } /** @@ -194,11 +282,38 @@ export function foldSliceBranches(opts: { * The 90% brownfield path. Fail-closed on real conflicts; the partial run branch * stays inspectable and the `conflicts` report names what to resolve by hand. */ +/** + * Commit the given slices' worktrees in dependency order, recording each slice's + * already-committed dependency commits as parents (so the fold's merge-base is the + * dependency, not the run base). Shared by run promotion and epic verification. + */ +function commitSlicesInDependencyOrder(opts: { + parentSandboxDir: string; + plan: Plan; + sliceIds: readonly string[]; +}): SliceCommit[] { + const ordered = dependencyOrder(opts.plan, opts.sliceIds); + const commitBySlice = new Map(); + const slices: SliceCommit[] = []; + for (const slice of ordered) { + const parents = slice.depends_on + .map((depId) => commitBySlice.get(depId)) + .filter((c): c is string => c !== undefined); + const sc = commitSliceWorktree({ parentSandboxDir: opts.parentSandboxDir, slice, parents }); + if (sc) { + commitBySlice.set(slice.id, sc.commit); + slices.push(sc); + } + } + return slices; +} + export function harvestCookRun(run: CompletedRun, opts?: { granularity?: CommitGranularity }): RunArtifact { - const ordered = dependencyOrder(run.plan, run.completedSliceIds); - const slices = ordered - .map((slice) => commitSliceWorktree({ parentSandboxDir: run.parentSandboxDir, slice })) - .filter((c): c is SliceCommit => c !== null); + const slices = commitSlicesInDependencyOrder({ + parentSandboxDir: run.parentSandboxDir, + plan: run.plan, + sliceIds: run.completedSliceIds, + }); return foldSliceBranches({ sourceDir: run.sourceDir, runId: run.runId, @@ -206,3 +321,40 @@ export function harvestCookRun(run: CompletedRun, opts?: { granularity?: CommitG ...(opts?.granularity ? { granularity: opts.granularity } : {}), }); } + +/** + * Verify-epic composition (brownfield): commit the epic's completed slices and + * materialize their fold as a detached worktree at `__epic__//`, so + * verify-epic runs tests against the *same* merged tree promotion will ship — + * replacing the file-copy union that silently last-slice-wins on same-file edits. + * The fold is fail-closed; a non-empty `conflicts` means the materialized tree is + * the last clean tip and the epic should fail rather than verify a partial tree. + * Relinks shareable gitignored entries (node_modules) so tests can run. + */ +export function materializeEpicVerifyTree(opts: { + /** The run worktree: shares the object store, holds slice worktrees, owns brunch/run/. */ + parentSandboxDir: string; + runId: string; + plan: Plan; + /** The epic's completed slices (+ cross-epic deps), any order. */ + sliceIds: readonly string[]; + epicId: string; +}): { epicSandboxDir: string; conflicts: SliceConflict[] } { + const parentSandboxDir = resolve(opts.parentSandboxDir); + const epicSandboxDir = resolveEpicSandboxDir(parentSandboxDir, opts.epicId); + const slices = commitSlicesInDependencyOrder({ + parentSandboxDir, + plan: opts.plan, + sliceIds: opts.sliceIds, + }); + const base = git(['rev-parse', '--verify', `refs/heads/${brunchRef.run(opts.runId)}`], parentSandboxDir); + const { conflicts } = materializeFoldedWorktree({ + sourceDir: parentSandboxDir, + base, + slices, + destDir: epicSandboxDir, + }); + // The fold tree carries only tracked content; relink shared deps for the test run. + linkSharedTopLevelEntries(parentSandboxDir, epicSandboxDir, SHAREABLE_TOP_LEVEL_ENTRIES); + return { epicSandboxDir, conflicts }; +}