From 3fd6a4a8a4851ffafc1d389a5ef4453d1b4700da Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:45:45 -0400 Subject: [PATCH 1/7] fix(scope): prevent overlapping creates from reusing slice IDs Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- packages/cli/src/commands/scope.ts | 178 ++++++++++++----------- packages/cli/src/lib/scope/scope-fs.ts | 23 +++ packages/cli/test/scope-commands.test.ts | 112 +++++++++++++- 3 files changed, 225 insertions(+), 88 deletions(-) diff --git a/packages/cli/src/commands/scope.ts b/packages/cli/src/commands/scope.ts index 5d409ab028..af02a7405a 100644 --- a/packages/cli/src/commands/scope.ts +++ b/packages/cli/src/commands/scope.ts @@ -48,6 +48,7 @@ import { moveSlice, rollbackMovedSlice, nextSliceNN, + withSliceCreationGuard, NOTES_FILE_PRECEDENCE, pad2, readFrontmatter, @@ -334,100 +335,103 @@ function buildSliceCreateCommand(): Command { }); } const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace }); - const mission = findMission(missionsRoot, missionName); - const nn = nextSliceNN(mission.absPath); - const sliceFolder = `${pad2(nn)}-${slug}`; - const sliceAbs = path.join(mission.absPath, "slices", sliceFolder); - if (fs.existsSync(sliceAbs)) { - throw new ScopeCliError({ - fact: `Slice folder ${sliceAbs} already exists.`, - consequence: "Refusing to overwrite.", - action: "Pick a different slug, or rm -rf the existing folder first.", - }); - } - const title = opts.title ?? titleFromSlug(slug); - const intent = opts.intent ?? title; - const compositionEdit = planMissionMembershipAdd( - mission.absPath, - `slices/${sliceFolder}/slice.yaml`, - nextMissionMembershipOrder(mission.absPath), - ); - const missionId = ensureMissionId(mission, missionsRoot); - const id = sliceIdFromMission(missionId, nn); - const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : []; - for (const dependency of dependsOn) { - if (dependency === id) { - throw new ScopeCliError({ - fact: `Slice ${id} cannot depend on itself.`, - consequence: "Slice not created.", - action: "Use a different sibling slice ID, or omit --depends-on.", - }); - } - if (!isSliceDotId(dependency) || !dependency.startsWith(`${missionId}.`)) { + const missionPath = findMission(missionsRoot, missionName).absPath; + const payload = withSliceCreationGuard(missionPath, () => { + const mission = findMission(missionsRoot, missionName); + const nn = nextSliceNN(mission.absPath); + const sliceFolder = `${pad2(nn)}-${slug}`; + const sliceAbs = path.join(mission.absPath, "slices", sliceFolder); + if (fs.existsSync(sliceAbs)) { throw new ScopeCliError({ - fact: `Dependency "${dependency}" is not a sibling slice dot-ID under ${missionId}.`, - consequence: "Slice not created.", - action: `Use a sibling ID shaped like ${missionId}., or omit --depends-on.`, + fact: `Slice folder ${sliceAbs} already exists.`, + consequence: "Refusing to overwrite.", + action: "Pick a different slug, or rm -rf the existing folder first.", }); } - } - const createdDate = todayDateISO(); - const body = renderSliceTemplate(kind, { - id, - slice_number: pad2(nn), - slug, - mission: mission.name, - title, - created_date: createdDate, - intent, - depends_on: dependsOn, - }); - const proofBody = renderSliceProofTemplate({ id, title }); - const originalMissionNode = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null; - const readmePath = path.join(sliceAbs, "SPEC.md"); - try { - // Persist the parent id in the same rollback boundary as the child - // and composition membership. Validation above performs no writes. - ensureMissionIdPersisted(mission, missionsRoot); - fs.mkdirSync(sliceAbs, { recursive: true }); - fs.mkdirSync(path.join(sliceAbs, "proof"), { recursive: true }); - // New scaffolds author SPEC.md; existing README-backed nodes are never rewritten. - const readmeOnly = Boolean(opts.readmeOnly); - if (readmeOnly) { - const markerBody = body.replace( - /^(---\n)/, - `---\nprogress_rail: readme-only\n`, - ); - fs.writeFileSync(readmePath, markerBody, "utf8"); - } else { - fs.writeFileSync(readmePath, body, "utf8"); - const progressPath = path.join(sliceAbs, "PROGRESS.md"); - fs.writeFileSync(progressPath, renderSliceProgressTemplate(title), "utf8"); + const title = opts.title ?? titleFromSlug(slug); + const intent = opts.intent ?? title; + const compositionEdit = planMissionMembershipAdd( + mission.absPath, + `slices/${sliceFolder}/slice.yaml`, + nextMissionMembershipOrder(mission.absPath), + ); + const missionId = ensureMissionId(mission, missionsRoot); + const id = sliceIdFromMission(missionId, nn); + const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : []; + for (const dependency of dependsOn) { + if (dependency === id) { + throw new ScopeCliError({ + fact: `Slice ${id} cannot depend on itself.`, + consequence: "Slice not created.", + action: "Use a different sibling slice ID, or omit --depends-on.", + }); + } + if (!isSliceDotId(dependency) || !dependency.startsWith(`${missionId}.`)) { + throw new ScopeCliError({ + fact: `Dependency "${dependency}" is not a sibling slice dot-ID under ${missionId}.`, + consequence: "Slice not created.", + action: `Use a sibling ID shaped like ${missionId}., or omit --depends-on.`, + }); + } } - fs.writeFileSync(path.join(sliceAbs, "slice.yaml"), SLICE_MANIFEST, "utf8"); - fs.writeFileSync(path.join(sliceAbs, "PROOF.md"), proofBody, "utf8"); - applyMissionCompositionEdits(compositionEdit ? [compositionEdit] : []); - } catch (error) { - fs.rmSync(sliceAbs, { recursive: true, force: true }); - if (originalMissionNode !== null && mission.readmePath) fs.writeFileSync(mission.readmePath, originalMissionNode, "utf8"); - throw error; - } - const payload = { - ok: true, - slice: { - mission: mission.name, - name: sliceFolder, + const createdDate = todayDateISO(); + const body = renderSliceTemplate(kind, { id, - path: sliceAbs, - readmePath, - template: kind, - }, - }; + slice_number: pad2(nn), + slug, + mission: mission.name, + title, + created_date: createdDate, + intent, + depends_on: dependsOn, + }); + const proofBody = renderSliceProofTemplate({ id, title }); + const originalMissionNode = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null; + const readmePath = path.join(sliceAbs, "SPEC.md"); + try { + // Persist the parent id in the same rollback boundary as the child + // and composition membership. Validation above performs no writes. + ensureMissionIdPersisted(mission, missionsRoot); + fs.mkdirSync(sliceAbs, { recursive: true }); + fs.mkdirSync(path.join(sliceAbs, "proof"), { recursive: true }); + // New scaffolds author SPEC.md; existing README-backed nodes are never rewritten. + const readmeOnly = Boolean(opts.readmeOnly); + if (readmeOnly) { + const markerBody = body.replace( + /^(---\n)/, + `---\nprogress_rail: readme-only\n`, + ); + fs.writeFileSync(readmePath, markerBody, "utf8"); + } else { + fs.writeFileSync(readmePath, body, "utf8"); + const progressPath = path.join(sliceAbs, "PROGRESS.md"); + fs.writeFileSync(progressPath, renderSliceProgressTemplate(title), "utf8"); + } + fs.writeFileSync(path.join(sliceAbs, "slice.yaml"), SLICE_MANIFEST, "utf8"); + fs.writeFileSync(path.join(sliceAbs, "PROOF.md"), proofBody, "utf8"); + applyMissionCompositionEdits(compositionEdit ? [compositionEdit] : []); + } catch (error) { + fs.rmSync(sliceAbs, { recursive: true, force: true }); + if (originalMissionNode !== null && mission.readmePath) fs.writeFileSync(mission.readmePath, originalMissionNode, "utf8"); + throw error; + } + return { + ok: true, + slice: { + mission: mission.name, + name: sliceFolder, + id, + path: sliceAbs, + readmePath, + template: kind, + }, + }; + }); emit(out, payload, json, [ - `Created ${mission.name}/slices/${sliceFolder}`, - ` id: ${id}`, + `Created ${payload.slice.mission}/slices/${payload.slice.name}`, + ` id: ${payload.slice.id}`, ` template: ${kind}`, - ` path: ${sliceAbs}`, + ` path: ${payload.slice.path}`, ]); } catch (err) { fail(err, json, out); diff --git a/packages/cli/src/lib/scope/scope-fs.ts b/packages/cli/src/lib/scope/scope-fs.ts index 43bc685276..089911d26a 100644 --- a/packages/cli/src/lib/scope/scope-fs.ts +++ b/packages/cli/src/lib/scope/scope-fs.ts @@ -498,6 +498,29 @@ function findOwningMission(missionsRoot: string, slicePath: string): string | nu // Auto-numbering // --------------------------------------------------------------------- +/** Prevent overlapping slice creates in one mission from reusing an ordinal. + * Only an acquired guard is removed; interrupted creators require inspection, + * never automatic stale-lock stealing. This does not serialize ship or move. */ +export function withSliceCreationGuard(missionAbsPath: string, create: () => T): T { + const guard = path.join(missionAbsPath, ".openrig-slice-create-lock"); + try { + fs.mkdirSync(guard); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + throw new ScopeCliError({ + fact: `A slice creation guard already exists for ${missionAbsPath}.`, + consequence: "Slice not created; existing work was not changed.", + action: "Retry after the other create finishes. If it was interrupted, inspect the guard before removing it.", + }); + } + // Release before the command's outer error handler can call process.exit. + try { + return create(); + } finally { + fs.rmdirSync(guard); + } +} + /** Find the next available NN for a mission's slices/ folder. Scans * BOTH slices/ AND closed/ so numbers are never reused (§3.2). */ export function nextSliceNN(missionAbsPath: string): number { diff --git a/packages/cli/test/scope-commands.test.ts b/packages/cli/test/scope-commands.test.ts index c5e2415758..20dcfc5046 100644 --- a/packages/cli/test/scope-commands.test.ts +++ b/packages/cli/test/scope-commands.test.ts @@ -6,7 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { Command } from "commander"; import YAML from "yaml"; @@ -213,6 +215,114 @@ describe("rig scope slice create", () => { beforeEach(() => { env = seedSubstrate(); }); afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); }); + async function publicCreate(args: string[]): Promise { + const home = path.join(env.root, "owned-cli-home"); + fs.mkdirSync(home, { recursive: true }); + const command = [ + fileURLToPath(new URL("../dist/index.js", import.meta.url)), + "scope", "slice", "create", ...args, + "--workspace", path.dirname(env.missionsRoot), "--json", + ]; + const options = { encoding: "utf8" as const, timeout: 15000, env: { + PATH: process.env.PATH, HOME: home, TMPDIR: env.root, + OPENRIG_HOME: path.join(home, ".openrig"), CODEX_HOME: path.join(home, "codex"), + CLAUDE_CONFIG_DIR: path.join(home, "claude"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, + } }; + try { + const result = await promisify(execFile)(process.execPath, command, options); + return { exitCode: 0, stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const failure = error as Error & { code: unknown; stdout: string; stderr: string }; + if (typeof failure.code !== "number") throw error; + return { exitCode: failure.code, stdout: failure.stdout, stderr: failure.stderr }; + } + } + + it("refuses an overlapping create without changing its guard or mission", async () => { + const mission = path.join(env.missionsRoot, "release-0.3.2"); + const guard = path.join(mission, ".openrig-slice-create-lock"); + writeFile(path.join(guard, "owned-by-other"), "existing creator\n"); + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + const result = await publicCreate(["release-0.3.2", "overlap"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error.action).toMatch(/retry/i); + expect(fs.readFileSync(path.join(guard, "owned-by-other"), "utf8")).toBe("existing creator\n"); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + expect(fs.readdirSync(path.join(mission, "slices"))).toEqual(["01-existing"]); + }); + + it("concurrent public creates return unique IDs or explicit retry refusals", async () => { + const mission = path.join(env.missionsRoot, "release-0.3.2"); + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + const results = await Promise.all(Array.from({ length: 8 }, async (_, index) => { + const result = await publicCreate(["release-0.3.2", `parallel-${index}`]); + const payload = JSON.parse(result.stdout); + if (result.exitCode !== 0) { + expect(result.exitCode).toBe(1); + expect(payload.ok).toBe(false); + expect(payload.error.fact).toMatch(/creation guard already exists/); + expect(payload.error.action).toMatch(/retry/i); + return null; + } + expect(payload.ok).toBe(true); + expect(readFrontmatter(payload.slice.readmePath).id).toBe(payload.slice.id); + return payload.slice.id as string; + })); + const ids = results.filter((id) => id !== null); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + expect(fs.readdirSync(path.join(mission, "slices"))).toHaveLength(ids.length + 1); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); + }); + + it("an acquired guard in another mission does not prevent creation", async () => { + const guard = path.join(env.missionsRoot, "backlog", ".openrig-slice-create-lock"); + writeFile(path.join(guard, "owned-by-other"), "existing creator\n"); + const result = await publicCreate(["release-0.3.2", "independent"]); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).slice.id).toBe("OPR.0.3.2.2"); + expect(fs.readFileSync(path.join(guard, "owned-by-other"), "utf8")).toBe("existing creator\n"); + expect(fs.existsSync(path.join(env.missionsRoot, "release-0.3.2", ".openrig-slice-create-lock"))).toBe(false); + }); + + it("releases a create guard after validation failure without consuming a number", async () => { + const mission = path.join(env.missionsRoot, "release-0.3.2"); + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + const failed = await publicCreate([ + "release-0.3.2", "invalid", "--depends-on", "OPR.0.3.2.2", + ]); + expect(failed.exitCode).toBe(1); + expect(JSON.parse(failed.stdout).error.fact).toMatch(/cannot depend on itself/); + expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + expect(fs.readdirSync(path.join(mission, "slices"))).toEqual(["01-existing"]); + const retried = await publicCreate(["release-0.3.2", "retry"]); + expect(JSON.parse(retried.stdout).slice.id).toBe("OPR.0.3.2.2"); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "releases a create guard after an actual filesystem refusal and permits retry", + async () => { + const mission = path.join(env.missionsRoot, "release-0.3.2"); + const slices = path.join(mission, "slices"); + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + fs.chmodSync(slices, 0o500); + try { + const failed = await publicCreate(["release-0.3.2", "unwritable"]); + expect(failed.exitCode).toBe(1); + expect(failed.stderr).toMatch(/EACCES/); + expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + expect(fs.readdirSync(slices)).toEqual(["01-existing"]); + } finally { + fs.chmodSync(slices, 0o700); + } + const retried = await publicCreate(["release-0.3.2", "retry"]); + expect(JSON.parse(retried.stdout).slice.id).toBe("OPR.0.3.2.2"); + }, + ); + it("HG-3: auto-finds next NN, never reuses numbers", async () => { // Add a closed slice 04 so existing NNs are 01 + 04. Next must be 05. writeFile( From 88a476126985bf59ff42c72e9fb50b2d5df587e3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:35:47 -0400 Subject: [PATCH 2/7] fix(scope): preserve creation outcome when guard cleanup fails Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- packages/cli/src/lib/scope/scope-fs.ts | 7 ++- packages/cli/test/scope-commands.test.ts | 56 +++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/scope/scope-fs.ts b/packages/cli/src/lib/scope/scope-fs.ts index 089911d26a..4dc59df91a 100644 --- a/packages/cli/src/lib/scope/scope-fs.ts +++ b/packages/cli/src/lib/scope/scope-fs.ts @@ -517,7 +517,12 @@ export function withSliceCreationGuard(missionAbsPath: string, create: () => try { return create(); } finally { - fs.rmdirSync(guard); + try { + fs.rmdirSync(guard); + } catch (releaseError) { + const detail = releaseError instanceof Error ? releaseError.message : String(releaseError); + process.stderr.write(`Warning: could not remove slice creation guard ${guard}: ${detail}. Inspect the guard before retrying.\n`); + } } } diff --git a/packages/cli/test/scope-commands.test.ts b/packages/cli/test/scope-commands.test.ts index 20dcfc5046..06ad3affff 100644 --- a/packages/cli/test/scope-commands.test.ts +++ b/packages/cli/test/scope-commands.test.ts @@ -13,7 +13,7 @@ import { Command } from "commander"; import YAML from "yaml"; import { scopeCommand } from "../src/commands/scope.js"; -import { readFrontmatter } from "../src/lib/scope/scope-fs.js"; +import { readFrontmatter, withSliceCreationGuard } from "../src/lib/scope/scope-fs.js"; function mktemp(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-cmd-")); @@ -238,6 +238,60 @@ describe("rig scope slice create", () => { } } + it("preserves a successful result when the creation guard cannot be removed", () => { + const mission = path.join(env.missionsRoot, "release-0.3.2"); + const guard = path.join(mission, ".openrig-slice-create-lock"); + const retained = path.join(guard, "inspection-required"); + const result = { id: "successful-result" }; + const warning = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + expect(withSliceCreationGuard(mission, () => { + fs.writeFileSync(retained, "inspect these bytes\n"); + return result; + })).toBe(result); + expect(fs.readFileSync(retained, "utf8")).toBe("inspect these bytes\n"); + const stderr = warning.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(stderr).toContain(guard); + expect(stderr).toContain("ENOTEMPTY"); + expect(stderr).toMatch(/inspect/i); + } finally { + warning.mockRestore(); + } + }); + + it("preserves the original filesystem error when guard removal also fails", () => { + const mission = path.join(env.missionsRoot, "release-0.3.2"); + const guard = path.join(mission, ".openrig-slice-create-lock"); + const retained = path.join(guard, "inspection-required"); + let originalError: unknown; + let caught: unknown; + const warning = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + try { + withSliceCreationGuard(mission, () => { + fs.writeFileSync(retained, "inspect these bytes\n"); + try { + fs.readFileSync(path.join(mission, "missing-input")); + } catch (error) { + originalError = error; + throw error; + } + }); + } catch (error) { + caught = error; + } + expect(originalError).toMatchObject({ code: "ENOENT" }); + expect(caught).toBe(originalError); + expect(fs.readFileSync(retained, "utf8")).toBe("inspect these bytes\n"); + const stderr = warning.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(stderr).toContain(guard); + expect(stderr).toContain("ENOTEMPTY"); + expect(stderr).toMatch(/inspect/i); + } finally { + warning.mockRestore(); + } + }); + it("refuses an overlapping create without changing its guard or mission", async () => { const mission = path.join(env.missionsRoot, "release-0.3.2"); const guard = path.join(mission, ".openrig-slice-create-lock"); From d266cb2f0fcc23f9ff7fb052b38cda7e308d0629 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:57:24 -0400 Subject: [PATCH 3/7] fix(cli): reconcile completed slice scaffolds after concurrent creates Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- packages/cli/src/commands/scope.ts | 162 ++++++----- .../cli/src/lib/scope/mission-composition.ts | 69 ++++- packages/cli/src/lib/scope/scope-fs.ts | 40 +-- packages/cli/test/scope-commands.test.ts | 168 +---------- packages/cli/test/scope-slice-claims.test.ts | 267 ++++++++++++++++++ 5 files changed, 438 insertions(+), 268 deletions(-) create mode 100644 packages/cli/test/scope-slice-claims.test.ts diff --git a/packages/cli/src/commands/scope.ts b/packages/cli/src/commands/scope.ts index af02a7405a..8ec83f8590 100644 --- a/packages/cli/src/commands/scope.ts +++ b/packages/cli/src/commands/scope.ts @@ -48,7 +48,7 @@ import { moveSlice, rollbackMovedSlice, nextSliceNN, - withSliceCreationGuard, + tryClaimSliceNN, NOTES_FILE_PRECEDENCE, pad2, readFrontmatter, @@ -79,6 +79,7 @@ import { deriveScopeTrust } from "../lib/scope/trust.js"; import { capabilityDeltaExpiryFindings } from "../lib/scope/capability-delta.js"; import { applyMissionCompositionEdits, + appendMissionMembership, nextMissionMembershipOrder, planMissionMembershipAdd, planMissionMembershipRemove, @@ -335,29 +336,18 @@ function buildSliceCreateCommand(): Command { }); } const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace }); - const missionPath = findMission(missionsRoot, missionName).absPath; - const payload = withSliceCreationGuard(missionPath, () => { - const mission = findMission(missionsRoot, missionName); - const nn = nextSliceNN(mission.absPath); - const sliceFolder = `${pad2(nn)}-${slug}`; - const sliceAbs = path.join(mission.absPath, "slices", sliceFolder); - if (fs.existsSync(sliceAbs)) { - throw new ScopeCliError({ - fact: `Slice folder ${sliceAbs} already exists.`, - consequence: "Refusing to overwrite.", - action: "Pick a different slug, or rm -rf the existing folder first.", - }); - } - const title = opts.title ?? titleFromSlug(slug); - const intent = opts.intent ?? title; - const compositionEdit = planMissionMembershipAdd( - mission.absPath, - `slices/${sliceFolder}/slice.yaml`, - nextMissionMembershipOrder(mission.absPath), - ); - const missionId = ensureMissionId(mission, missionsRoot); - const id = sliceIdFromMission(missionId, nn); - const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : []; + const mission = findMission(missionsRoot, missionName); + let nn = nextSliceNN(mission.absPath); + const title = opts.title ?? titleFromSlug(slug); + const intent = opts.intent ?? title; + const missionId = ensureMissionId(mission, missionsRoot); + let id = sliceIdFromMission(missionId, nn); + const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : []; + // Validate before reserving any number; re-check self-dependency if a + // competing creator claimed the initial candidate. + let sliceFolder: string; + let sliceAbs: string; + for (;;) { for (const dependency of dependsOn) { if (dependency === id) { throw new ScopeCliError({ @@ -374,59 +364,83 @@ function buildSliceCreateCommand(): Command { }); } } - const createdDate = todayDateISO(); - const body = renderSliceTemplate(kind, { - id, - slice_number: pad2(nn), - slug, - mission: mission.name, - title, - created_date: createdDate, - intent, - depends_on: dependsOn, - }); - const proofBody = renderSliceProofTemplate({ id, title }); - const originalMissionNode = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null; - const readmePath = path.join(sliceAbs, "SPEC.md"); - try { - // Persist the parent id in the same rollback boundary as the child - // and composition membership. Validation above performs no writes. - ensureMissionIdPersisted(mission, missionsRoot); - fs.mkdirSync(sliceAbs, { recursive: true }); - fs.mkdirSync(path.join(sliceAbs, "proof"), { recursive: true }); - // New scaffolds author SPEC.md; existing README-backed nodes are never rewritten. - const readmeOnly = Boolean(opts.readmeOnly); - if (readmeOnly) { - const markerBody = body.replace( - /^(---\n)/, - `---\nprogress_rail: readme-only\n`, - ); - fs.writeFileSync(readmePath, markerBody, "utf8"); - } else { - fs.writeFileSync(readmePath, body, "utf8"); - const progressPath = path.join(sliceAbs, "PROGRESS.md"); - fs.writeFileSync(progressPath, renderSliceProgressTemplate(title), "utf8"); - } - fs.writeFileSync(path.join(sliceAbs, "slice.yaml"), SLICE_MANIFEST, "utf8"); - fs.writeFileSync(path.join(sliceAbs, "PROOF.md"), proofBody, "utf8"); - applyMissionCompositionEdits(compositionEdit ? [compositionEdit] : []); - } catch (error) { + sliceFolder = `${pad2(nn)}-${slug}`; + sliceAbs = path.join(mission.absPath, "slices", sliceFolder); + if (fs.existsSync(sliceAbs)) { + throw new ScopeCliError({ + fact: `Slice folder ${sliceAbs} already exists.`, + consequence: "Refusing to overwrite.", + action: "Pick a different slug, or rm -rf the existing folder first.", + }); + } + // Validate an explicit manifest before reserving an ordinal. The + // post-scaffold write re-plans from the latest manifest and disk. + planMissionMembershipAdd( + mission.absPath, `slices/${pad2(nn)}-${slug}/slice.yaml`, + ); + if (tryClaimSliceNN(mission.absPath, nn)) break; + nn++; + id = sliceIdFromMission(missionId, nn); + } + const createdDate = todayDateISO(); + const body = renderSliceTemplate(kind, { + id, + slice_number: pad2(nn), + slug, + mission: mission.name, + title, + created_date: createdDate, + intent, + depends_on: dependsOn, + }); + const proofBody = renderSliceProofTemplate({ id, title }); + const originalMissionNode = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null; + const readmePath = path.join(sliceAbs, "SPEC.md"); + const proofPath = path.join(sliceAbs, "PROOF.md"); + let scaffoldCompleted = false; + try { + // Persist the parent id before building the scaffold. Incomplete + // scaffolds roll back; the ordinal remains reserved even then. + ensureMissionIdPersisted(mission, missionsRoot); + fs.mkdirSync(sliceAbs, { recursive: true }); + fs.mkdirSync(path.join(sliceAbs, "proof"), { recursive: true }); + // New scaffolds author SPEC.md; existing README-backed nodes are never rewritten. + const readmeOnly = Boolean(opts.readmeOnly); + if (readmeOnly) { + const markerBody = body.replace( + /^(---\n)/, + `---\nprogress_rail: readme-only\n`, + ); + fs.writeFileSync(readmePath, markerBody, "utf8"); + } else { + fs.writeFileSync(readmePath, body, "utf8"); + const progressPath = path.join(sliceAbs, "PROGRESS.md"); + fs.writeFileSync(progressPath, renderSliceProgressTemplate(title), "utf8"); + } + fs.writeFileSync(path.join(sliceAbs, "slice.yaml"), SLICE_MANIFEST, "utf8"); + fs.writeFileSync(proofPath, proofBody, "utf8"); + scaffoldCompleted = true; + await appendMissionMembership(mission.absPath, `slices/${sliceFolder}/slice.yaml`); + } catch (error) { + // PROOF.md is the final scaffold file. Once complete, leave this + // slice available for another creator's manifest union to repair. + if (!scaffoldCompleted) { fs.rmSync(sliceAbs, { recursive: true, force: true }); if (originalMissionNode !== null && mission.readmePath) fs.writeFileSync(mission.readmePath, originalMissionNode, "utf8"); - throw error; } - return { - ok: true, - slice: { - mission: mission.name, - name: sliceFolder, - id, - path: sliceAbs, - readmePath, - template: kind, - }, - }; - }); + throw error; + } + const payload = { + ok: true, + slice: { + mission: mission.name, + name: sliceFolder, + id, + path: sliceAbs, + readmePath, + template: kind, + }, + }; emit(out, payload, json, [ `Created ${payload.slice.mission}/slices/${payload.slice.name}`, ` id: ${payload.slice.id}`, diff --git a/packages/cli/src/lib/scope/mission-composition.ts b/packages/cli/src/lib/scope/mission-composition.ts index 69ed22afa3..04c95af1ec 100644 --- a/packages/cli/src/lib/scope/mission-composition.ts +++ b/packages/cli/src/lib/scope/mission-composition.ts @@ -30,11 +30,12 @@ interface LoadedComposition { export function planMissionMembershipAdd( missionPath: string, ref: string, - order: number, + order?: number, ): MissionCompositionEdit | null { const loaded = loadMissionComposition(missionPath); if (!loaded) return null; assertSafeRelative(ref); + order ??= (loaded.members.at(-1)?.order ?? 0) + 10; if (!Number.isInteger(order)) fail(`Membership order for ${ref} must be an integer.`); if (loaded.members.some((member) => path.normalize(member.ref) === path.normalize(ref))) { fail(`Mission composition already contains ${ref}.`); @@ -47,6 +48,70 @@ export function planMissionMembershipAdd( return renderEdit(loaded, members); } +/** Repair every completed scaffold into a fresh membership union, then verify + * this creator's entry. Rename is not compare-and-swap: a later writer can + * still supersede a verified snapshot, so completed scaffolds stay on disk. */ +export async function appendMissionMembership( + missionPath: string, + ref: string, +): Promise { + const attempts = 10; + for (let attempt = 0; attempt < attempts; attempt++) { + const edit = planCompletedSliceMembership(missionPath, ref); + // README-backed legacy missions do not have a composition manifest. + if (!edit) return; + try { + applyMissionCompositionEdits([edit]); + const current = loadMissionComposition(missionPath); + if (current?.members.some((member) => path.normalize(member.ref) === path.normalize(ref))) return; + } catch (error) { + if (!(error instanceof MissionCompositionChangedError)) throw error; + } + if (attempt < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 10 + Math.floor(Math.random() * 91))); + } + } + throw new ScopeCliError({ + fact: `Could not verify membership for ${ref} after ${attempts} attempts.`, + consequence: "The complete slice scaffold was kept on disk; this create did not report success.", + action: "Inspect mission.yaml, then run a later slice create to repair completed scaffold membership.", + }); +} + +/** Preserve authored members and add only complete slice scaffolds, plus the + * caller's completed entry. Existing active flags and orders remain intact. */ +function planCompletedSliceMembership(missionPath: string, ref: string): MissionCompositionEdit | null { + const loaded = loadMissionComposition(missionPath); + if (!loaded) return null; + assertSafeRelative(ref); + const members = [...loaded.members]; + const present = new Set(members.map((member) => path.normalize(member.ref))); + const completed: string[] = []; + const slicesPath = path.join(missionPath, "slices"); + if (fs.existsSync(slicesPath)) { + for (const entry of fs.readdirSync(slicesPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const slicePath = path.join(slicesPath, entry.name); + if (fs.existsSync(path.join(slicePath, "PROOF.md")) && fs.existsSync(path.join(slicePath, "slice.yaml"))) { + completed.push(`slices/${entry.name}/slice.yaml`); + } + } + } + completed.sort(); + completed.push(ref); + let order = Math.max(0, ...members.map((member) => member.order)); + for (const completedRef of completed) { + const normalized = path.normalize(completedRef); + if (present.has(normalized)) continue; + order += 10; + members.push({ ref: completedRef, order, active: true }); + present.add(normalized); + } + return renderEdit(loaded, members.sort((a, b) => a.order - b.order)); +} + +class MissionCompositionChangedError extends ScopeCliError {} + export function planMissionMembershipRemove( missionPath: string, ref: string, @@ -73,7 +138,7 @@ export function applyMissionCompositionEdits(edits: MissionCompositionEdit[]): v // overwrite another agent's authored membership or partially change a move. for (const edit of unique) { if (fs.readFileSync(edit.manifestPath, "utf8") !== edit.original) { - throw new ScopeCliError({ + throw new MissionCompositionChangedError({ fact: `Mission composition changed since this edit was planned: ${edit.manifestPath}.`, consequence: "Scope composition was not changed.", action: "Read the current mission.yaml and retry the scope operation.", diff --git a/packages/cli/src/lib/scope/scope-fs.ts b/packages/cli/src/lib/scope/scope-fs.ts index 4dc59df91a..ea8a17b3e8 100644 --- a/packages/cli/src/lib/scope/scope-fs.ts +++ b/packages/cli/src/lib/scope/scope-fs.ts @@ -498,44 +498,32 @@ function findOwningMission(missionsRoot: string, slicePath: string): string | nu // Auto-numbering // --------------------------------------------------------------------- -/** Prevent overlapping slice creates in one mission from reusing an ordinal. - * Only an acquired guard is removed; interrupted creators require inspection, - * never automatic stale-lock stealing. This does not serialize ship or move. */ -export function withSliceCreationGuard(missionAbsPath: string, create: () => T): T { - const guard = path.join(missionAbsPath, ".openrig-slice-create-lock"); +/** Claim one slice ordinal exclusively. Interrupted creates retain only their + * number, rather than blocking every later create in the mission. */ +export function tryClaimSliceNN(missionAbsPath: string, nn: number): boolean { + const slices = path.join(missionAbsPath, "slices"); + fs.mkdirSync(slices, { recursive: true }); + let claim: number; try { - fs.mkdirSync(guard); + claim = fs.openSync(path.join(slices, `.nn-${pad2(nn)}`), "wx"); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - throw new ScopeCliError({ - fact: `A slice creation guard already exists for ${missionAbsPath}.`, - consequence: "Slice not created; existing work was not changed.", - action: "Retry after the other create finishes. If it was interrupted, inspect the guard before removing it.", - }); - } - // Release before the command's outer error handler can call process.exit. - try { - return create(); - } finally { - try { - fs.rmdirSync(guard); - } catch (releaseError) { - const detail = releaseError instanceof Error ? releaseError.message : String(releaseError); - process.stderr.write(`Warning: could not remove slice creation guard ${guard}: ${detail}. Inspect the guard before retrying.\n`); - } + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; } + fs.closeSync(claim); + return true; } /** Find the next available NN for a mission's slices/ folder. Scans - * BOTH slices/ AND closed/ so numbers are never reused (§3.2). */ + * BOTH slices/ AND closed/, including ordinal claims, so numbers are never reused (§3.2). */ export function nextSliceNN(missionAbsPath: string): number { let max = 0; for (const subdir of ["slices", "closed"]) { const root = path.join(missionAbsPath, subdir); if (!fs.existsSync(root)) continue; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const m = SLICE_DIRNAME_RE.exec(entry.name); + const marker = subdir === "slices" ? /^\.nn-(\d+)$/.exec(entry.name) : null; + const m = marker ?? (entry.isDirectory() ? SLICE_DIRNAME_RE.exec(entry.name) : null); if (!m) continue; const n = Number(m[1]); if (Number.isFinite(n) && n > max) max = n; diff --git a/packages/cli/test/scope-commands.test.ts b/packages/cli/test/scope-commands.test.ts index 06ad3affff..c5e2415758 100644 --- a/packages/cli/test/scope-commands.test.ts +++ b/packages/cli/test/scope-commands.test.ts @@ -6,14 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { execFile, execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; +import { execFileSync } from "node:child_process"; import { Command } from "commander"; import YAML from "yaml"; import { scopeCommand } from "../src/commands/scope.js"; -import { readFrontmatter, withSliceCreationGuard } from "../src/lib/scope/scope-fs.js"; +import { readFrontmatter } from "../src/lib/scope/scope-fs.js"; function mktemp(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-cmd-")); @@ -215,168 +213,6 @@ describe("rig scope slice create", () => { beforeEach(() => { env = seedSubstrate(); }); afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); }); - async function publicCreate(args: string[]): Promise { - const home = path.join(env.root, "owned-cli-home"); - fs.mkdirSync(home, { recursive: true }); - const command = [ - fileURLToPath(new URL("../dist/index.js", import.meta.url)), - "scope", "slice", "create", ...args, - "--workspace", path.dirname(env.missionsRoot), "--json", - ]; - const options = { encoding: "utf8" as const, timeout: 15000, env: { - PATH: process.env.PATH, HOME: home, TMPDIR: env.root, - OPENRIG_HOME: path.join(home, ".openrig"), CODEX_HOME: path.join(home, "codex"), - CLAUDE_CONFIG_DIR: path.join(home, "claude"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, - } }; - try { - const result = await promisify(execFile)(process.execPath, command, options); - return { exitCode: 0, stdout: result.stdout, stderr: result.stderr }; - } catch (error) { - const failure = error as Error & { code: unknown; stdout: string; stderr: string }; - if (typeof failure.code !== "number") throw error; - return { exitCode: failure.code, stdout: failure.stdout, stderr: failure.stderr }; - } - } - - it("preserves a successful result when the creation guard cannot be removed", () => { - const mission = path.join(env.missionsRoot, "release-0.3.2"); - const guard = path.join(mission, ".openrig-slice-create-lock"); - const retained = path.join(guard, "inspection-required"); - const result = { id: "successful-result" }; - const warning = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - try { - expect(withSliceCreationGuard(mission, () => { - fs.writeFileSync(retained, "inspect these bytes\n"); - return result; - })).toBe(result); - expect(fs.readFileSync(retained, "utf8")).toBe("inspect these bytes\n"); - const stderr = warning.mock.calls.map(([chunk]) => String(chunk)).join(""); - expect(stderr).toContain(guard); - expect(stderr).toContain("ENOTEMPTY"); - expect(stderr).toMatch(/inspect/i); - } finally { - warning.mockRestore(); - } - }); - - it("preserves the original filesystem error when guard removal also fails", () => { - const mission = path.join(env.missionsRoot, "release-0.3.2"); - const guard = path.join(mission, ".openrig-slice-create-lock"); - const retained = path.join(guard, "inspection-required"); - let originalError: unknown; - let caught: unknown; - const warning = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - try { - try { - withSliceCreationGuard(mission, () => { - fs.writeFileSync(retained, "inspect these bytes\n"); - try { - fs.readFileSync(path.join(mission, "missing-input")); - } catch (error) { - originalError = error; - throw error; - } - }); - } catch (error) { - caught = error; - } - expect(originalError).toMatchObject({ code: "ENOENT" }); - expect(caught).toBe(originalError); - expect(fs.readFileSync(retained, "utf8")).toBe("inspect these bytes\n"); - const stderr = warning.mock.calls.map(([chunk]) => String(chunk)).join(""); - expect(stderr).toContain(guard); - expect(stderr).toContain("ENOTEMPTY"); - expect(stderr).toMatch(/inspect/i); - } finally { - warning.mockRestore(); - } - }); - - it("refuses an overlapping create without changing its guard or mission", async () => { - const mission = path.join(env.missionsRoot, "release-0.3.2"); - const guard = path.join(mission, ".openrig-slice-create-lock"); - writeFile(path.join(guard, "owned-by-other"), "existing creator\n"); - const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); - const result = await publicCreate(["release-0.3.2", "overlap"]); - expect(result.exitCode).toBe(1); - expect(JSON.parse(result.stdout).error.action).toMatch(/retry/i); - expect(fs.readFileSync(path.join(guard, "owned-by-other"), "utf8")).toBe("existing creator\n"); - expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); - expect(fs.readdirSync(path.join(mission, "slices"))).toEqual(["01-existing"]); - }); - - it("concurrent public creates return unique IDs or explicit retry refusals", async () => { - const mission = path.join(env.missionsRoot, "release-0.3.2"); - const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); - const results = await Promise.all(Array.from({ length: 8 }, async (_, index) => { - const result = await publicCreate(["release-0.3.2", `parallel-${index}`]); - const payload = JSON.parse(result.stdout); - if (result.exitCode !== 0) { - expect(result.exitCode).toBe(1); - expect(payload.ok).toBe(false); - expect(payload.error.fact).toMatch(/creation guard already exists/); - expect(payload.error.action).toMatch(/retry/i); - return null; - } - expect(payload.ok).toBe(true); - expect(readFrontmatter(payload.slice.readmePath).id).toBe(payload.slice.id); - return payload.slice.id as string; - })); - const ids = results.filter((id) => id !== null); - expect(ids.length).toBeGreaterThan(0); - expect(new Set(ids).size).toBe(ids.length); - expect(fs.readdirSync(path.join(mission, "slices"))).toHaveLength(ids.length + 1); - expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); - expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); - }); - - it("an acquired guard in another mission does not prevent creation", async () => { - const guard = path.join(env.missionsRoot, "backlog", ".openrig-slice-create-lock"); - writeFile(path.join(guard, "owned-by-other"), "existing creator\n"); - const result = await publicCreate(["release-0.3.2", "independent"]); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout).slice.id).toBe("OPR.0.3.2.2"); - expect(fs.readFileSync(path.join(guard, "owned-by-other"), "utf8")).toBe("existing creator\n"); - expect(fs.existsSync(path.join(env.missionsRoot, "release-0.3.2", ".openrig-slice-create-lock"))).toBe(false); - }); - - it("releases a create guard after validation failure without consuming a number", async () => { - const mission = path.join(env.missionsRoot, "release-0.3.2"); - const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); - const failed = await publicCreate([ - "release-0.3.2", "invalid", "--depends-on", "OPR.0.3.2.2", - ]); - expect(failed.exitCode).toBe(1); - expect(JSON.parse(failed.stdout).error.fact).toMatch(/cannot depend on itself/); - expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); - expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); - expect(fs.readdirSync(path.join(mission, "slices"))).toEqual(["01-existing"]); - const retried = await publicCreate(["release-0.3.2", "retry"]); - expect(JSON.parse(retried.stdout).slice.id).toBe("OPR.0.3.2.2"); - }); - - it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( - "releases a create guard after an actual filesystem refusal and permits retry", - async () => { - const mission = path.join(env.missionsRoot, "release-0.3.2"); - const slices = path.join(mission, "slices"); - const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); - fs.chmodSync(slices, 0o500); - try { - const failed = await publicCreate(["release-0.3.2", "unwritable"]); - expect(failed.exitCode).toBe(1); - expect(failed.stderr).toMatch(/EACCES/); - expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); - expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); - expect(fs.readdirSync(slices)).toEqual(["01-existing"]); - } finally { - fs.chmodSync(slices, 0o700); - } - const retried = await publicCreate(["release-0.3.2", "retry"]); - expect(JSON.parse(retried.stdout).slice.id).toBe("OPR.0.3.2.2"); - }, - ); - it("HG-3: auto-finds next NN, never reuses numbers", async () => { // Add a closed slice 04 so existing NNs are 01 + 04. Next must be 05. writeFile( diff --git a/packages/cli/test/scope-slice-claims.test.ts b/packages/cli/test/scope-slice-claims.test.ts new file mode 100644 index 0000000000..118c2d3f0d --- /dev/null +++ b/packages/cli/test/scope-slice-claims.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; +import { readFrontmatter } from "../src/lib/scope/scope-fs.js"; + +interface Result { + exitCode: number; + stdout: string; + stderr: string; +} + +describe("public slice creation ordinal claims", () => { + let root: string; + let workspace: string; + let mission: string; + let ownedEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-slice-claims-")); + workspace = path.join(root, "internal-docs"); + mission = path.join(workspace, "missions", "release-0.3.2"); + const home = path.join(root, "owned-home"); + fs.mkdirSync(home); + ownedEnv = { + PATH: process.env.PATH, HOME: home, TMPDIR: root, + OPENRIG_HOME: path.join(home, ".openrig"), + CODEX_HOME: path.join(home, "codex"), CLAUDE_CONFIG_DIR: path.join(home, "claude"), + GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, + }; + execFileSync("git", ["-C", root, "init", "-q"], { env: ownedEnv }); + fs.mkdirSync(path.join(mission, "slices", "01-existing"), { recursive: true }); + fs.writeFileSync(path.join(mission, "README.md"), "---\nid: OPR.0.3.2\n---\n# existing mission\n"); + fs.writeFileSync(path.join(mission, "slices", "01-existing", "README.md"), "---\nid: OPR.0.3.2.1\n---\n# existing slice\n"); + }); + + afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); + + async function create(slug: string, extra: string[] = [], preload?: string): Promise { + const args = [ + ...(preload ? ["--import", preload] : []), + fileURLToPath(new URL("../dist/index.js", import.meta.url)), + "scope", "slice", "create", "release-0.3.2", slug, ...extra, + "--workspace", workspace, "--json", + ]; + try { + const result = await promisify(execFile)(process.execPath, args, { + env: ownedEnv, encoding: "utf8", timeout: 15000, + }); + return { exitCode: 0, ...result }; + } catch (error) { + const result = error as Error & { code: unknown; stdout: string; stderr: string }; + if (typeof result.code !== "number") throw error; + return { exitCode: result.code, stdout: result.stdout, stderr: result.stderr }; + } + } + + function successful(results: Result[]) { + expect(results.map((result) => ({ code: result.exitCode, stdout: result.stdout, stderr: result.stderr }))) + .toEqual(results.map((result) => ({ code: 0, stdout: result.stdout, stderr: "" }))); + const slices = results.map((result) => JSON.parse(result.stdout).slice as { + id: string; name: string; readmePath: string; path: string; + }); + expect(new Set(slices.map((slice) => slice.id)).size).toBe(slices.length); + expect(new Set(slices.map((slice) => Number(slice.name.split("-")[0]))).size).toBe(slices.length); + for (const slice of slices) { + expect(readFrontmatter(slice.readmePath).id).toBe(slice.id); + expect(fs.readFileSync(path.join(slice.path, "slice.yaml"), "utf8")).toContain("kind: slice"); + } + return slices; + } + + it("a stale ordinal claim skips only its number and survives later creates", async () => { + const marker = path.join(mission, "slices", ".nn-02"); + fs.writeFileSync(marker, "interrupted creator\n", { flag: "wx" }); + const first = successful([await create("after-interruption")])[0]!; + const second = successful([await create("next")])[0]!; + expect(first.id).toBe("OPR.0.3.2.3"); + expect(second.id).toBe("OPR.0.3.2.4"); + expect(fs.readFileSync(marker, "utf8")).toBe("interrupted creator\n"); + }); + + it("four eight-process README-backed batches create unique slices without refusals", async () => { + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + const results: Result[] = []; + for (let batch = 0; batch < 4; batch++) { + results.push(...await Promise.all(Array.from({ length: 8 }, (_, index) => create(`readme-${batch}-${index}`)))); + } + const ids = successful(results).map((slice) => slice.id); + expect(new Set(ids).size).toBe(32); + expect(fs.readdirSync(path.join(mission, "slices"), { withFileTypes: true }).filter((entry) => entry.isDirectory())).toHaveLength(33); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); + }, 30000); + + it("twenty eight-process manifest-backed batches retain every member after each batch", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, "# authored mission comment\n" + YAML.stringify({ + kind: "mission", composition: { slices: [{ ref, order: 10, active: true }] }, + })); + const results: Result[] = []; + for (let batch = 0; batch < 20; batch++) { + results.push(...await Promise.all(Array.from({ length: 8 }, (_, index) => create(`manifest-${batch}-${index}`)))); + const slices = successful(results); + const refs = [ref, ...slices.map((slice) => `slices/${slice.name}/slice.yaml`)]; + const bytes = fs.readFileSync(manifest, "utf8"); + const members = (YAML.parse(bytes) as { composition: { slices: Array<{ ref: string; order: number; active: boolean }> } }).composition.slices; + expect(members.map((member) => member.ref).sort()).toEqual([...refs].sort()); + expect(new Set(members.map((member) => member.order)).size).toBe(members.length); + expect(members.every((member) => member.active)).toBe(true); + expect(bytes).toContain("# authored mission comment"); + } + expect(new Set(successful(results).map((slice) => slice.id)).size).toBe(160); + expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); + }, 180000); + + it("a later create repairs complete missing membership without adopting incomplete scaffolds", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, "# preserve authored comment\n" + YAML.stringify({ + kind: "mission", composition: { slices: [{ ref, order: 70, active: false }] }, + })); + for (const name of ["02-complete", "03-incomplete"]) { + fs.mkdirSync(path.join(mission, "slices", name)); + fs.writeFileSync(path.join(mission, "slices", name, "slice.yaml"), "kind: slice\n"); + fs.writeFileSync(path.join(mission, "slices", name, "SPEC.md"), "# scaffold\n"); + } + const proof = path.join(mission, "slices", "02-complete", "PROOF.md"); + fs.writeFileSync(proof, "# completed evidence\n"); + const created = successful([await create("repair")])[0]!; + const bytes = fs.readFileSync(manifest, "utf8"); + const members = YAML.parse(bytes).composition.slices; + expect(members).toContainEqual({ ref, order: 70, active: false }); + expect(members.map((member: { ref: string }) => member.ref).sort()).toEqual([ + ref, "slices/02-complete/slice.yaml", `slices/${created.name}/slice.yaml`, + ].sort()); + expect(bytes).toContain("# preserve authored comment"); + expect(fs.readFileSync(proof, "utf8")).toBe("# completed evidence\n"); + expect(fs.existsSync(path.join(mission, "slices", "03-incomplete", "SPEC.md"))).toBe(true); + }); + + it("bounded verify failure reports nonzero and retains a complete scaffold for the next create", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [{ ref, order: 10, active: true }] } })); + const injected = path.join(root, "drop-own-member.mjs"); + const writes = path.join(root, "injected-write-count"); + fs.writeFileSync(injected, ` + import fs from "node:fs"; + import { createRequire } from "node:module"; + const require = createRequire(${JSON.stringify(new URL("../../../package.json", import.meta.url).href)}); + const YAML = require("yaml"); + const original = fs.renameSync.bind(fs); + fs.renameSync = (source, target) => { + original(source, target); + if (String(target) !== ${JSON.stringify(manifest)}) return; + const doc = YAML.parse(fs.readFileSync(target, "utf8")); + doc.composition.slices = doc.composition.slices.filter((member) => !member.ref.includes("-lost/slice.yaml")); + fs.writeFileSync(target, YAML.stringify(doc)); + fs.appendFileSync(${JSON.stringify(writes)}, "write\\n"); + }; + `); + const result = await create("lost", [], injected); + expect(result.exitCode).toBe(1); + expect(result.stdout, result.stderr).not.toBe(""); + expect(JSON.parse(result.stdout).error.fact).toContain("after 10 attempts"); + expect(JSON.parse(result.stdout).error.consequence).toContain("scaffold was kept"); + expect(fs.readFileSync(writes, "utf8").trim().split("\n")).toHaveLength(10); + const kept = path.join(mission, "slices", "02-lost"); + for (const file of ["SPEC.md", "slice.yaml", "PROGRESS.md", "PROOF.md"]) expect(fs.existsSync(path.join(kept, file))).toBe(true); + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).not.toContain("slices/02-lost/slice.yaml"); + const recovered = successful([await create("recover")])[0]!; + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref).sort()).toEqual([ + ref, "slices/02-lost/slice.yaml", `slices/${recovered.name}/slice.yaml`, + ].sort()); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an actual manifest EACCES preserves the complete slice for later repair", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [{ ref, order: 10, active: true }] } })); + fs.chmodSync(mission, 0o500); + try { + const result = await create("write-denied"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EACCES"); + const kept = path.join(mission, "slices", "02-write-denied"); + for (const file of ["SPEC.md", "slice.yaml", "PROOF.md"]) expect(fs.existsSync(path.join(kept, file))).toBe(true); + } finally { + fs.chmodSync(mission, 0o700); + } + successful([await create("repair-write")]); + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).toContain("slices/02-write-denied/slice.yaml"); + }); + + it("a partial final PROOF write rolls back the incomplete scaffold and retains only its ordinal", async () => { + const injected = path.join(root, "partial-proof-write.mjs"); + fs.writeFileSync(injected, ` + import fs from "node:fs"; + const original = fs.writeFileSync.bind(fs); + fs.writeFileSync = (target, data, options) => { + if (String(target).endsWith("/PROOF.md")) { + original(target, "# partial proof", options); + throw Object.assign(new Error("injected partial PROOF write"), { code: "EIO" }); + } + return original(target, data, options); + }; + `); + const before = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + const failed = await create("partial", [], injected); + expect(failed.exitCode).toBe(1); + expect(failed.stdout + failed.stderr).toContain("injected partial PROOF write"); + expect(fs.existsSync(path.join(mission, "slices", "02-partial"))).toBe(false); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(before); + expect(fs.existsSync(path.join(mission, "slices", ".nn-02"))).toBe(true); + expect(successful([await create("after-partial")])[0]!.id).toBe("OPR.0.3.2.3"); + }); + + it("an old mission guard is preserved but does not block a new creator", async () => { + const guard = path.join(mission, ".openrig-slice-create-lock"); + fs.mkdirSync(guard); + fs.writeFileSync(path.join(guard, "inspection-required"), "interrupted guard\n"); + successful([await create("ignores-old-lock")]); + expect(fs.readFileSync(path.join(guard, "inspection-required"), "utf8")).toBe("interrupted guard\n"); + }); + + it("serial creates retain ordinary numbering and scaffold output", async () => { + const ids: string[] = []; + for (let index = 0; index < 4; index++) ids.push(successful([await create(`serial-${index}`)])[0]!.id); + expect(ids).toEqual(["OPR.0.3.2.2", "OPR.0.3.2.3", "OPR.0.3.2.4", "OPR.0.3.2.5"]); + }); + + it("validation failures do not claim a number or change authored bytes", async () => { + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + for (const extra of [["--template", "invalid"], ["--depends-on", "other.1"], ["--depends-on", "OPR.0.3.2.2"]]) { + expect((await create("invalid", extra)).exitCode).toBe(1); + expect(fs.readdirSync(path.join(mission, "slices"))).toEqual(["01-existing"]); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + } + expect(successful([await create("valid")])[0]!.id).toBe("OPR.0.3.2.2"); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an actual EACCES claim failure leaves no scaffold or consumed number", async () => { + const slices = path.join(mission, "slices"); + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + fs.chmodSync(slices, 0o500); + try { + const result = await create("denied"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EACCES"); + expect(fs.readdirSync(slices)).toEqual(["01-existing"]); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + } finally { + fs.chmodSync(slices, 0o700); + } + expect(successful([await create("retry")])[0]!.id).toBe("OPR.0.3.2.2"); + }); +}); From 751265e1545db80177502590fbb40f52510839f6 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:31:00 -0400 Subject: [PATCH 4/7] fix(scope): repair only unfinished slice registrations Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- packages/cli/src/commands/scope.ts | 9 ++- .../cli/src/lib/scope/mission-composition.ts | 32 ++++---- packages/cli/src/lib/scope/scope-fs.ts | 16 +--- packages/cli/src/lib/scope/slice-claims.ts | 77 +++++++++++++++++++ packages/cli/test/scope-slice-claims.test.ts | 60 ++++++++++++++- 5 files changed, 154 insertions(+), 40 deletions(-) create mode 100644 packages/cli/src/lib/scope/slice-claims.ts diff --git a/packages/cli/src/commands/scope.ts b/packages/cli/src/commands/scope.ts index 8ec83f8590..3eccf66ad4 100644 --- a/packages/cli/src/commands/scope.ts +++ b/packages/cli/src/commands/scope.ts @@ -378,7 +378,7 @@ function buildSliceCreateCommand(): Command { planMissionMembershipAdd( mission.absPath, `slices/${pad2(nn)}-${slug}/slice.yaml`, ); - if (tryClaimSliceNN(mission.absPath, nn)) break; + if (tryClaimSliceNN(mission.absPath, nn, `slices/${sliceFolder}/slice.yaml`)) break; nn++; id = sliceIdFromMission(missionId, nn); } @@ -418,12 +418,15 @@ function buildSliceCreateCommand(): Command { fs.writeFileSync(progressPath, renderSliceProgressTemplate(title), "utf8"); } fs.writeFileSync(path.join(sliceAbs, "slice.yaml"), SLICE_MANIFEST, "utf8"); - fs.writeFileSync(proofPath, proofBody, "utf8"); + // Publish the completion sentinel only after its entire contents exist. + const proofTemporary = `${proofPath}.openrig-${process.pid}.tmp`; + fs.writeFileSync(proofTemporary, proofBody, { encoding: "utf8", flag: "wx" }); + fs.renameSync(proofTemporary, proofPath); scaffoldCompleted = true; await appendMissionMembership(mission.absPath, `slices/${sliceFolder}/slice.yaml`); } catch (error) { // PROOF.md is the final scaffold file. Once complete, leave this - // slice available for another creator's manifest union to repair. + // pending registration available for a later creator to repair. if (!scaffoldCompleted) { fs.rmSync(sliceAbs, { recursive: true, force: true }); if (originalMissionNode !== null && mission.readmePath) fs.writeFileSync(mission.readmePath, originalMissionNode, "utf8"); diff --git a/packages/cli/src/lib/scope/mission-composition.ts b/packages/cli/src/lib/scope/mission-composition.ts index 04c95af1ec..60353346bb 100644 --- a/packages/cli/src/lib/scope/mission-composition.ts +++ b/packages/cli/src/lib/scope/mission-composition.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { parseDocument } from "yaml"; import { ScopeCliError } from "./types.js"; +import { pendingCompletedSliceRefs, markSliceRegistrationsVerified } from "./slice-claims.js"; import { LifecycleManifestValidationError, validateMissionComposition, @@ -48,7 +49,7 @@ export function planMissionMembershipAdd( return renderEdit(loaded, members); } -/** Repair every completed scaffold into a fresh membership union, then verify +/** Repair only positively unfinished registrations into a fresh union, then verify * this creator's entry. Rename is not compare-and-swap: a later writer can * still supersede a verified snapshot, so completed scaffolds stay on disk. */ export async function appendMissionMembership( @@ -59,11 +60,17 @@ export async function appendMissionMembership( for (let attempt = 0; attempt < attempts; attempt++) { const edit = planCompletedSliceMembership(missionPath, ref); // README-backed legacy missions do not have a composition manifest. - if (!edit) return; + if (!edit) { + markSliceRegistrationsVerified(missionPath, [ref]); + return; + } try { applyMissionCompositionEdits([edit]); const current = loadMissionComposition(missionPath); - if (current?.members.some((member) => path.normalize(member.ref) === path.normalize(ref))) return; + if (current?.members.some((member) => path.normalize(member.ref) === path.normalize(ref))) { + markSliceRegistrationsVerified(missionPath, current.members.map((member) => member.ref)); + return; + } } catch (error) { if (!(error instanceof MissionCompositionChangedError)) throw error; } @@ -74,11 +81,11 @@ export async function appendMissionMembership( throw new ScopeCliError({ fact: `Could not verify membership for ${ref} after ${attempts} attempts.`, consequence: "The complete slice scaffold was kept on disk; this create did not report success.", - action: "Inspect mission.yaml, then run a later slice create to repair completed scaffold membership.", + action: "Inspect mission.yaml, then run a later slice create to repair unfinished registration.", }); } -/** Preserve authored members and add only complete slice scaffolds, plus the +/** Preserve authored members and add only positively pending complete scaffolds, plus the * caller's completed entry. Existing active flags and orders remain intact. */ function planCompletedSliceMembership(missionPath: string, ref: string): MissionCompositionEdit | null { const loaded = loadMissionComposition(missionPath); @@ -86,18 +93,7 @@ function planCompletedSliceMembership(missionPath: string, ref: string): Mission assertSafeRelative(ref); const members = [...loaded.members]; const present = new Set(members.map((member) => path.normalize(member.ref))); - const completed: string[] = []; - const slicesPath = path.join(missionPath, "slices"); - if (fs.existsSync(slicesPath)) { - for (const entry of fs.readdirSync(slicesPath, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const slicePath = path.join(slicesPath, entry.name); - if (fs.existsSync(path.join(slicePath, "PROOF.md")) && fs.existsSync(path.join(slicePath, "slice.yaml"))) { - completed.push(`slices/${entry.name}/slice.yaml`); - } - } - } - completed.sort(); + const completed = pendingCompletedSliceRefs(missionPath); completed.push(ref); let order = Math.max(0, ...members.map((member) => member.order)); for (const completedRef of completed) { @@ -107,7 +103,7 @@ function planCompletedSliceMembership(missionPath: string, ref: string): Mission members.push({ ref: completedRef, order, active: true }); present.add(normalized); } - return renderEdit(loaded, members.sort((a, b) => a.order - b.order)); + return renderEdit(loaded, members); } class MissionCompositionChangedError extends ScopeCliError {} diff --git a/packages/cli/src/lib/scope/scope-fs.ts b/packages/cli/src/lib/scope/scope-fs.ts index ea8a17b3e8..0bea67175c 100644 --- a/packages/cli/src/lib/scope/scope-fs.ts +++ b/packages/cli/src/lib/scope/scope-fs.ts @@ -498,21 +498,7 @@ function findOwningMission(missionsRoot: string, slicePath: string): string | nu // Auto-numbering // --------------------------------------------------------------------- -/** Claim one slice ordinal exclusively. Interrupted creates retain only their - * number, rather than blocking every later create in the mission. */ -export function tryClaimSliceNN(missionAbsPath: string, nn: number): boolean { - const slices = path.join(missionAbsPath, "slices"); - fs.mkdirSync(slices, { recursive: true }); - let claim: number; - try { - claim = fs.openSync(path.join(slices, `.nn-${pad2(nn)}`), "wx"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; - throw error; - } - fs.closeSync(claim); - return true; -} +export { tryClaimSliceNN } from "./slice-claims.js"; /** Find the next available NN for a mission's slices/ folder. Scans * BOTH slices/ AND closed/, including ordinal claims, so numbers are never reused (§3.2). */ diff --git a/packages/cli/src/lib/scope/slice-claims.ts b/packages/cli/src/lib/scope/slice-claims.ts new file mode 100644 index 0000000000..fd0c3aae98 --- /dev/null +++ b/packages/cli/src/lib/scope/slice-claims.ts @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const SCHEMA = "openrig.slice-claim/v1"; +interface SliceClaim { schema: typeof SCHEMA; ref: string; state: "pending" | "verified" } + +/** One persistent claim reserves the ordinal and records unfinished registration. */ +export function tryClaimSliceNN(missionPath: string, nn: number, ref: string): boolean { + const slices = path.join(missionPath, "slices"); + fs.mkdirSync(slices, { recursive: true }); + let fd: number; + try { + fd = fs.openSync(path.join(slices, `.nn-${String(nn).padStart(2, "0")}`), "wx"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + try { + fs.writeFileSync(fd, JSON.stringify({ schema: SCHEMA, ref, state: "pending" } satisfies SliceClaim) + "\n"); + } finally { + fs.closeSync(fd); + } + return true; +} + +function readClaim(missionPath: string, marker: string): SliceClaim | null { + const ordinal = /^\.nn-(\d+)$/.exec(marker); + if (!ordinal) return null; + const file = path.join(missionPath, "slices", marker); + if (!fs.lstatSync(file).isFile()) return null; + let claim: unknown; + try { claim = JSON.parse(fs.readFileSync(file, "utf8")); } + catch (error) { + if (error instanceof SyntaxError) return null; // Legacy/partial marker reserves only a number. + throw error; + } + if (!claim || typeof claim !== "object") return null; + const value = claim as Partial; + if (value.schema !== SCHEMA || (value.state !== "pending" && value.state !== "verified") || typeof value.ref !== "string") return null; + const folder = /^slices\/(\d+)-[^/\\]+\/slice\.yaml$/.exec(value.ref); + if (!folder || Number(folder[1]) !== Number(ordinal[1])) return null; + return value as SliceClaim; +} + +/** Older unlisted folders and verified registrations are never adopted. */ +export function pendingCompletedSliceRefs(missionPath: string): string[] { + const slices = path.join(missionPath, "slices"); + if (!fs.existsSync(slices)) return []; + const refs: string[] = []; + for (const marker of fs.readdirSync(slices)) { + const claim = readClaim(missionPath, marker); + if (claim?.state !== "pending") continue; + const slice = path.dirname(path.join(missionPath, claim.ref)); + if (fs.existsSync(path.join(slice, "PROOF.md")) && fs.existsSync(path.join(slice, "slice.yaml"))) refs.push(claim.ref); + } + return refs.sort(); +} + +/** Retire pending evidence after observing registration, including a repaired peer. + * A stale manifest rename after this observation is not automatically repaired. */ +export function markSliceRegistrationsVerified(missionPath: string, refs: string[]): void { + const registered = new Set(refs.map((ref) => path.normalize(ref))); + const slices = path.join(missionPath, "slices"); + for (const marker of fs.readdirSync(slices)) { + const claim = readClaim(missionPath, marker); + if (claim?.state !== "pending" || !registered.has(path.normalize(claim.ref))) continue; + const file = path.join(slices, marker); + const temporary = `${file}.${process.pid}-${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify({ ...claim, state: "verified" }) + "\n", { flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } + } +} diff --git a/packages/cli/test/scope-slice-claims.test.ts b/packages/cli/test/scope-slice-claims.test.ts index 118c2d3f0d..0759aab08e 100644 --- a/packages/cli/test/scope-slice-claims.test.ts +++ b/packages/cli/test/scope-slice-claims.test.ts @@ -120,7 +120,7 @@ describe("public slice creation ordinal claims", () => { expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); }, 180000); - it("a later create repairs complete missing membership without adopting incomplete scaffolds", async () => { + it("a later create repairs only pending complete registrations and preserves authored membership", async () => { const ref = "slices/01-existing/slice.yaml"; fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); const manifest = path.join(mission, "mission.yaml"); @@ -132,6 +132,8 @@ describe("public slice creation ordinal claims", () => { fs.writeFileSync(path.join(mission, "slices", name, "slice.yaml"), "kind: slice\n"); fs.writeFileSync(path.join(mission, "slices", name, "SPEC.md"), "# scaffold\n"); } + fs.writeFileSync(path.join(mission, "slices", ".nn-02"), JSON.stringify({ schema: "openrig.slice-claim/v1", ref: "slices/02-complete/slice.yaml", state: "pending" })); + fs.writeFileSync(path.join(mission, "slices", ".nn-03"), JSON.stringify({ schema: "openrig.slice-claim/v1", ref: "slices/03-incomplete/slice.yaml", state: "pending" })); const proof = path.join(mission, "slices", "02-complete", "PROOF.md"); fs.writeFileSync(proof, "# completed evidence\n"); const created = successful([await create("repair")])[0]!; @@ -146,6 +148,40 @@ describe("public slice creation ordinal claims", () => { expect(fs.existsSync(path.join(mission, "slices", "03-incomplete", "SPEC.md"))).toBe(true); }); + it("verified and repaired registrations stay removed after manual membership removal", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + const authored = { ref, order: 70, active: false }; + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [authored] } })); + const first = successful([await create("registered")])[0]!; + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [authored] } })); + const next = successful([await create("after-removal")])[0]!; + const members = YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices; + expect(members).toEqual([authored, { ref: `slices/${next.name}/slice.yaml`, order: 80, active: true }]); + expect(JSON.parse(fs.readFileSync(path.join(mission, "slices", ".nn-02"), "utf8")).state).toBe("verified"); + expect(fs.existsSync(path.join(first.path, "PROOF.md"))).toBe(true); + }); + + it("older complete unlisted folders without pending evidence remain unlisted", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + const authored = { ref, order: 70, active: false }; + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [authored] } })); + for (const name of ["02-older", "03-legacy-marker", "04-verified"]) { + fs.mkdirSync(path.join(mission, "slices", name)); + fs.writeFileSync(path.join(mission, "slices", name, "slice.yaml"), "kind: slice\n"); + fs.writeFileSync(path.join(mission, "slices", name, "PROOF.md"), "# complete\n"); + } + fs.writeFileSync(path.join(mission, "slices", ".nn-03"), ""); + fs.writeFileSync(path.join(mission, "slices", ".nn-04"), JSON.stringify({ schema: "openrig.slice-claim/v1", ref: "slices/04-verified/slice.yaml", state: "verified" })); + const next = successful([await create("new")])[0]!; + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices).toEqual([ + authored, { ref: `slices/${next.name}/slice.yaml`, order: 80, active: true }, + ]); + }); + it("bounded verify failure reports nonzero and retains a complete scaffold for the next create", async () => { const ref = "slices/01-existing/slice.yaml"; fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); @@ -181,6 +217,12 @@ describe("public slice creation ordinal claims", () => { expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref).sort()).toEqual([ ref, "slices/02-lost/slice.yaml", `slices/${recovered.name}/slice.yaml`, ].sort()); + expect(JSON.parse(fs.readFileSync(path.join(mission, "slices", ".nn-02"), "utf8")).state).toBe("verified"); + const repairedDoc = YAML.parse(fs.readFileSync(manifest, "utf8")); + repairedDoc.composition.slices = repairedDoc.composition.slices.filter((member: { ref: string }) => member.ref !== "slices/02-lost/slice.yaml"); + fs.writeFileSync(manifest, YAML.stringify(repairedDoc)); + successful([await create("after-repaired-removal")]); + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).not.toContain("slices/02-lost/slice.yaml"); }); it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an actual manifest EACCES preserves the complete slice for later repair", async () => { @@ -202,14 +244,23 @@ describe("public slice creation ordinal claims", () => { expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).toContain("slices/02-write-denied/slice.yaml"); }); - it("a partial final PROOF write rolls back the incomplete scaffold and retains only its ordinal", async () => { + it("a partial final PROOF write cannot be adopted by a real competing creator", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [{ ref, order: 10, active: true }] } })); + const observed = path.join(root, "competing-manifest"); + const competitorArgs = [fileURLToPath(new URL("../dist/index.js", import.meta.url)), "scope", "slice", "create", "release-0.3.2", "competitor", "--workspace", workspace, "--json"]; const injected = path.join(root, "partial-proof-write.mjs"); fs.writeFileSync(injected, ` import fs from "node:fs"; + import { execFileSync } from "node:child_process"; const original = fs.writeFileSync.bind(fs); fs.writeFileSync = (target, data, options) => { - if (String(target).endsWith("/PROOF.md")) { + if (/\\/PROOF\\.md(?:\\.openrig-.*\\.tmp)?$/.test(String(target))) { original(target, "# partial proof", options); + execFileSync(process.execPath, ${JSON.stringify(competitorArgs)}, { env: ${JSON.stringify(ownedEnv)}, timeout: 15000 }); + original(${JSON.stringify(observed)}, fs.readFileSync(${JSON.stringify(manifest)}, "utf8")); throw Object.assign(new Error("injected partial PROOF write"), { code: "EIO" }); } return original(target, data, options); @@ -222,7 +273,8 @@ describe("public slice creation ordinal claims", () => { expect(fs.existsSync(path.join(mission, "slices", "02-partial"))).toBe(false); expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(before); expect(fs.existsSync(path.join(mission, "slices", ".nn-02"))).toBe(true); - expect(successful([await create("after-partial")])[0]!.id).toBe("OPR.0.3.2.3"); + expect(YAML.parse(fs.readFileSync(observed, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).not.toContain("slices/02-partial/slice.yaml"); + expect(successful([await create("after-partial")])[0]!.id).toBe("OPR.0.3.2.4"); }); it("an old mission guard is preserved but does not block a new creator", async () => { From 465e94f6db2e26e37356bca9fa95123f66c90879 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:35:24 -0400 Subject: [PATCH 5/7] fix(scope): preserve shared parent state on scaffold failure Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- packages/cli/src/commands/scope.ts | 4 +- packages/cli/test/scope-slice-claims.test.ts | 39 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/scope.ts b/packages/cli/src/commands/scope.ts index 3eccf66ad4..d99dbe4e95 100644 --- a/packages/cli/src/commands/scope.ts +++ b/packages/cli/src/commands/scope.ts @@ -394,7 +394,6 @@ function buildSliceCreateCommand(): Command { depends_on: dependsOn, }); const proofBody = renderSliceProofTemplate({ id, title }); - const originalMissionNode = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null; const readmePath = path.join(sliceAbs, "SPEC.md"); const proofPath = path.join(sliceAbs, "PROOF.md"); let scaffoldCompleted = false; @@ -429,7 +428,8 @@ function buildSliceCreateCommand(): Command { // pending registration available for a later creator to repair. if (!scaffoldCompleted) { fs.rmSync(sliceAbs, { recursive: true, force: true }); - if (originalMissionNode !== null && mission.readmePath) fs.writeFileSync(mission.readmePath, originalMissionNode, "utf8"); + // The parent identity may already be shared by another creator. + // Rolling this child back must not undo it or authored parent edits. } throw error; } diff --git a/packages/cli/test/scope-slice-claims.test.ts b/packages/cli/test/scope-slice-claims.test.ts index 0759aab08e..53c616fd53 100644 --- a/packages/cli/test/scope-slice-claims.test.ts +++ b/packages/cli/test/scope-slice-claims.test.ts @@ -277,6 +277,45 @@ describe("public slice creation ordinal claims", () => { expect(successful([await create("after-partial")])[0]!.id).toBe("OPR.0.3.2.4"); }); + it.each([false, true])("an incomplete creator preserves concurrent parent edits (parent initially has ID: %s)", async (hasId) => { + const parent = path.join(mission, "README.md"); + if (!hasId) fs.writeFileSync(parent, "---\nintent: existing mission\n---\n# existing mission\n"); + const expectedParent = path.join(root, "concurrent-parent-bytes"); + const competitorOutput = path.join(root, "competitor-output.json"); + const competitorArgs = [fileURLToPath(new URL("../dist/index.js", import.meta.url)), "scope", "slice", "create", "release-0.3.2", "successful", "--workspace", workspace, "--json"]; + const injected = path.join(root, "fail-scaffold-after-competitor.mjs"); + fs.writeFileSync(injected, ` + import fs from "node:fs"; + import { execFileSync } from "node:child_process"; + const original = fs.writeFileSync.bind(fs); + fs.writeFileSync = (target, data, options) => { + if (String(target) === ${JSON.stringify(path.join(mission, "slices", "02-failed", "SPEC.md"))}) { + const output = execFileSync(process.execPath, ${JSON.stringify(competitorArgs)}, { env: ${JSON.stringify(ownedEnv)}, encoding: "utf8", timeout: 15000 }); + original(${JSON.stringify(competitorOutput)}, output); + const current = fs.readFileSync(${JSON.stringify(parent)}, "utf8") + "\\nA concurrent authored edit.\\n"; + original(${JSON.stringify(parent)}, current); + original(${JSON.stringify(expectedParent)}, current); + throw Object.assign(new Error("injected incomplete scaffold failure"), { code: "EIO" }); + } + return original(target, data, options); + }; + `); + + const failed = await create("failed", [], injected); + + expect(failed.exitCode).toBe(1); + expect(failed.stdout + failed.stderr).toContain("injected incomplete scaffold failure"); + expect(fs.readFileSync(parent, "utf8")).toBe(fs.readFileSync(expectedParent, "utf8")); + expect(readFrontmatter(parent).id).toBe("OPR.0.3.2"); + const competitor = JSON.parse(fs.readFileSync(competitorOutput, "utf8")).slice; + expect(competitor.id).toBe("OPR.0.3.2.3"); + expect(readFrontmatter(competitor.readmePath).id).toBe(competitor.id); + expect(fs.existsSync(path.join(competitor.path, "PROOF.md"))).toBe(true); + expect(fs.existsSync(path.join(mission, "slices", "02-failed"))).toBe(false); + expect(fs.existsSync(path.join(mission, "slices", ".nn-02"))).toBe(true); + expect(successful([await create("after-failure")])[0]!.id).toBe("OPR.0.3.2.4"); + }); + it("an old mission guard is preserved but does not block a new creator", async () => { const guard = path.join(mission, ".openrig-slice-create-lock"); fs.mkdirSync(guard); From cee29342a7473a28751c9a6821bc9f83fa02bfec Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:19:00 -0400 Subject: [PATCH 6/7] test(cli): match slice concurrency checks to admitted guarantees Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- packages/cli/test/scope-slice-claims.test.ts | 85 ++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/scope-slice-claims.test.ts b/packages/cli/test/scope-slice-claims.test.ts index 53c616fd53..500b9ca507 100644 --- a/packages/cli/test/scope-slice-claims.test.ts +++ b/packages/cli/test/scope-slice-claims.test.ts @@ -97,26 +97,52 @@ describe("public slice creation ordinal claims", () => { expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); }, 30000); - it("twenty eight-process manifest-backed batches retain every member after each batch", async () => { + it("twenty eight-process manifest-backed batches preserve the admitted concurrency contract", async () => { const ref = "slices/01-existing/slice.yaml"; fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const authored = { ref, order: 10, active: false }; const manifest = path.join(mission, "mission.yaml"); fs.writeFileSync(manifest, "# authored mission comment\n" + YAML.stringify({ - kind: "mission", composition: { slices: [{ ref, order: 10, active: true }] }, + kind: "mission", composition: { slices: [authored] }, })); + const unrelated = path.join(mission, "slices", "02-older-unlisted"); + fs.mkdirSync(unrelated); + fs.writeFileSync(path.join(unrelated, "slice.yaml"), "kind: slice\n"); + fs.writeFileSync(path.join(unrelated, "PROOF.md"), "# unrelated authored proof\n"); const results: Result[] = []; for (let batch = 0; batch < 20; batch++) { results.push(...await Promise.all(Array.from({ length: 8 }, (_, index) => create(`manifest-${batch}-${index}`)))); const slices = successful(results); const refs = [ref, ...slices.map((slice) => `slices/${slice.name}/slice.yaml`)]; + for (const slice of slices) { + for (const file of ["SPEC.md", "PROGRESS.md", "slice.yaml", "PROOF.md"]) { + expect(fs.readFileSync(path.join(slice.path, file), "utf8")).not.toBe(""); + } + expect(fs.statSync(path.join(slice.path, "proof")).isDirectory()).toBe(true); + const ordinal = slice.name.split("-")[0]!; + expect(JSON.parse(fs.readFileSync(path.join(mission, "slices", `.nn-${ordinal}`), "utf8"))).toEqual({ + schema: "openrig.slice-claim/v1", ref: `slices/${slice.name}/slice.yaml`, state: "verified", + }); + } const bytes = fs.readFileSync(manifest, "utf8"); const members = (YAML.parse(bytes) as { composition: { slices: Array<{ ref: string; order: number; active: boolean }> } }).composition.slices; - expect(members.map((member) => member.ref).sort()).toEqual([...refs].sort()); + expect(members).toContainEqual(authored); + expect(new Set(members.map((member) => member.ref)).size).toBe(members.length); expect(new Set(members.map((member) => member.order)).size).toBe(members.length); - expect(members.every((member) => member.active)).toBe(true); + expect(members.every((member) => Number.isInteger(member.order) && typeof member.active === "boolean")).toBe(true); + expect(members.map((member) => member.order)).toEqual([...members.map((member) => member.order)].sort((a, b) => a - b)); + expect(members.every((member) => refs.includes(member.ref) && fs.statSync(path.join(mission, member.ref)).isFile())).toBe(true); + expect(members.filter((member) => member.ref !== ref).every((member) => member.active)).toBe(true); expect(bytes).toContain("# authored mission comment"); + const present = new Set(members.map((member) => member.ref)); + const omitted = slices.filter((slice) => !present.has(`slices/${slice.name}/slice.yaml`)).map((slice) => ({ + ref: `slices/${slice.name}/slice.yaml`, + claim: JSON.parse(fs.readFileSync(path.join(mission, "slices", `.nn-${slice.name.split("-")[0]}`), "utf8")), + })); + if (omitted.length) console.info("Accepted stale-write membership observations:", JSON.stringify({ batch, omitted })); } expect(new Set(successful(results).map((slice) => slice.id)).size).toBe(160); + expect(fs.readFileSync(path.join(unrelated, "PROOF.md"), "utf8")).toBe("# unrelated authored proof\n"); expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); }, 180000); @@ -316,6 +342,42 @@ describe("public slice creation ordinal claims", () => { expect(successful([await create("after-failure")])[0]!.id).toBe("OPR.0.3.2.4"); }); + it.each([false, true])("an isolated incomplete failure retains parent persistence (parent initially has ID: %s)", async (hasId) => { + const parent = path.join(mission, "README.md"); + if (!hasId) fs.writeFileSync(parent, "---\nintent: authored mission\n---\n# existing mission\n"); + const originalParent = fs.readFileSync(parent, "utf8"); + const writtenParent = path.join(root, "persisted-parent-bytes"); + const injected = path.join(root, "isolated-incomplete-failure.mjs"); + fs.writeFileSync(injected, ` + import fs from "node:fs"; + const original = fs.writeFileSync.bind(fs); + fs.writeFileSync = (target, data, options) => { + if (String(target) === ${JSON.stringify(path.join(mission, "slices", "02-failed", "SPEC.md"))}) { + original(${JSON.stringify(writtenParent)}, fs.readFileSync(${JSON.stringify(parent)}, "utf8")); + throw Object.assign(new Error("isolated incomplete scaffold failure"), { code: "EIO" }); + } + return original(target, data, options); + }; + `); + const failed = await create("failed", [], injected); + expect(failed.exitCode).toBe(1); + expect(failed.stdout + failed.stderr).toContain("isolated incomplete scaffold failure"); + const bytes = fs.readFileSync(parent, "utf8"); + expect(bytes).toBe(fs.readFileSync(writtenParent, "utf8")); + if (hasId) expect(bytes).toBe(originalParent); + else { + expect(bytes).not.toBe(originalParent); + expect(readFrontmatter(parent).id).toEqual(expect.any(String)); + expect(readFrontmatter(parent).id).not.toBe(""); + expect(readFrontmatter(parent).intent).toBe("authored mission"); + expect(bytes).toContain("# existing mission"); + } + expect(fs.existsSync(path.join(mission, "slices", "02-failed"))).toBe(false); + expect(JSON.parse(fs.readFileSync(path.join(mission, "slices", ".nn-02"), "utf8"))).toEqual({ + schema: "openrig.slice-claim/v1", ref: "slices/02-failed/slice.yaml", state: "pending", + }); + }); + it("an old mission guard is preserved but does not block a new creator", async () => { const guard = path.join(mission, ".openrig-slice-create-lock"); fs.mkdirSync(guard); @@ -325,8 +387,21 @@ describe("public slice creation ordinal claims", () => { }); it("serial creates retain ordinary numbering and scaffold output", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + const authored = { ref, order: 10, active: false }; + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [authored] } })); const ids: string[] = []; - for (let index = 0; index < 4; index++) ids.push(successful([await create(`serial-${index}`)])[0]!.id); + const refs = [ref]; + for (let index = 0; index < 4; index++) { + const slice = successful([await create(`serial-${index}`)])[0]!; + ids.push(slice.id); + refs.push(`slices/${slice.name}/slice.yaml`); + const members = YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices; + expect(members.map((member: { ref: string }) => member.ref)).toEqual(refs); + expect(members[0]).toEqual(authored); + } expect(ids).toEqual(["OPR.0.3.2.2", "OPR.0.3.2.3", "OPR.0.3.2.4", "OPR.0.3.2.5"]); }); From 25c9bd5c3369084976ee0b0164ddd24226fb2be6 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:19:43 -0400 Subject: [PATCH 7/7] test(cli): observe isolated parent persistence writes Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- packages/cli/test/scope-slice-claims.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/test/scope-slice-claims.test.ts b/packages/cli/test/scope-slice-claims.test.ts index 500b9ca507..f957403f5b 100644 --- a/packages/cli/test/scope-slice-claims.test.ts +++ b/packages/cli/test/scope-slice-claims.test.ts @@ -347,11 +347,13 @@ describe("public slice creation ordinal claims", () => { if (!hasId) fs.writeFileSync(parent, "---\nintent: authored mission\n---\n# existing mission\n"); const originalParent = fs.readFileSync(parent, "utf8"); const writtenParent = path.join(root, "persisted-parent-bytes"); + const parentWrites = path.join(root, "parent-write-count"); const injected = path.join(root, "isolated-incomplete-failure.mjs"); fs.writeFileSync(injected, ` import fs from "node:fs"; const original = fs.writeFileSync.bind(fs); fs.writeFileSync = (target, data, options) => { + if (String(target) === ${JSON.stringify(parent)}) fs.appendFileSync(${JSON.stringify(parentWrites)}, "write\\n"); if (String(target) === ${JSON.stringify(path.join(mission, "slices", "02-failed", "SPEC.md"))}) { original(${JSON.stringify(writtenParent)}, fs.readFileSync(${JSON.stringify(parent)}, "utf8")); throw Object.assign(new Error("isolated incomplete scaffold failure"), { code: "EIO" }); @@ -364,6 +366,8 @@ describe("public slice creation ordinal claims", () => { expect(failed.stdout + failed.stderr).toContain("isolated incomplete scaffold failure"); const bytes = fs.readFileSync(parent, "utf8"); expect(bytes).toBe(fs.readFileSync(writtenParent, "utf8")); + const writes = fs.existsSync(parentWrites) ? fs.readFileSync(parentWrites, "utf8").trim().split("\n").length : 0; + expect(writes).toBe(hasId ? 0 : 1); if (hasId) expect(bytes).toBe(originalParent); else { expect(bytes).not.toBe(originalParent);