Skip to content
95 changes: 58 additions & 37 deletions packages/cli/src/commands/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
moveSlice,
rollbackMovedSlice,
nextSliceNN,
tryClaimSliceNN,
NOTES_FILE_PRECEDENCE,
pad2,
readFrontmatter,
Expand Down Expand Up @@ -78,6 +79,7 @@ import { deriveScopeTrust } from "../lib/scope/trust.js";
import { capabilityDeltaExpiryFindings } from "../lib/scope/capability-delta.js";
import {
applyMissionCompositionEdits,
appendMissionMembership,
nextMissionMembershipOrder,
planMissionMembershipAdd,
planMissionMembershipRemove,
Expand Down Expand Up @@ -335,41 +337,50 @@ 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.",
});
}
let nn = nextSliceNN(mission.absPath);
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);
let 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.",
});
// 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({
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}.<n>, or omit --depends-on.`,
});
}
}
if (!isSliceDotId(dependency) || !dependency.startsWith(`${missionId}.`)) {
sliceFolder = `${pad2(nn)}-${slug}`;
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}.<n>, 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.",
});
}
// 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, `slices/${sliceFolder}/slice.yaml`)) break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
nn++;
id = sliceIdFromMission(missionId, nn);
}
const createdDate = todayDateISO();
const body = renderSliceTemplate(kind, {
Expand All @@ -383,11 +394,12 @@ 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;
try {
// Persist the parent id in the same rollback boundary as the child
// and composition membership. Validation above performs no writes.
// 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 });
Expand All @@ -405,11 +417,20 @@ function buildSliceCreateCommand(): Command {
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] : []);
// 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) {
fs.rmSync(sliceAbs, { recursive: true, force: true });
if (originalMissionNode !== null && mission.readmePath) fs.writeFileSync(mission.readmePath, originalMissionNode, "utf8");
// PROOF.md is the final scaffold file. Once complete, leave this
// pending registration available for a later creator to repair.
if (!scaffoldCompleted) {
fs.rmSync(sliceAbs, { recursive: true, force: true });
// The parent identity may already be shared by another creator.
// Rolling this child back must not undo it or authored parent edits.
}
throw error;
}
const payload = {
Expand All @@ -424,10 +445,10 @@ function buildSliceCreateCommand(): Command {
},
};
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);
Expand Down
65 changes: 63 additions & 2 deletions packages/cli/src/lib/scope/mission-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -30,11 +31,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}.`);
Expand All @@ -47,6 +49,65 @@ export function planMissionMembershipAdd(
return renderEdit(loaded, members);
}

/** 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(
missionPath: string,
ref: string,
): Promise<void> {
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) {
markSliceRegistrationsVerified(missionPath, [ref]);
return;
}
try {
applyMissionCompositionEdits([edit]);
const current = loadMissionComposition(missionPath);
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;
}
if (attempt < attempts - 1) {
await new Promise<void>((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 unfinished registration.",
});
}

/** 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);
if (!loaded) return null;
assertSafeRelative(ref);
const members = [...loaded.members];
const present = new Set(members.map((member) => path.normalize(member.ref)));
const completed = pendingCompletedSliceRefs(missionPath);
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

class MissionCompositionChangedError extends ScopeCliError {}

export function planMissionMembershipRemove(
missionPath: string,
ref: string,
Expand All @@ -73,7 +134,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.",
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/lib/scope/scope-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,16 +498,18 @@ function findOwningMission(missionsRoot: string, slicePath: string): string | nu
// Auto-numbering
// ---------------------------------------------------------------------

export { tryClaimSliceNN } from "./slice-claims.js";

/** 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;
Expand Down
77 changes: 77 additions & 0 deletions packages/cli/src/lib/scope/slice-claims.ts
Original file line number Diff line number Diff line change
@@ -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<SliceClaim>;
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 });
}
}
}
Loading
Loading