feature-factory 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/factory.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // The orchestrator calls this CLI instead of writing control-plane state directly.
5
5
  // Flags are declared per command; unknown options fail rather than becoming missing fields.
6
6
  // Schema validation surrounds every state write.
7
- import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
7
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, realpathSync, renameSync } from "node:fs";
8
8
  import { basename, dirname, join, resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { createHash } from "node:crypto";
@@ -12,17 +12,21 @@ import { isDeepStrictEqual } from "node:util";
12
12
  import { readFileSync } from "node:fs";
13
13
  import { nextAction, nextActionRecord, readRun, readRunUnchecked } from "../state/index.js";
14
14
  import { transition } from "../state/transition.js";
15
+ import { RUN_JSON_LOCK_DIR, withRunJsonLock } from "../core/run-lock.js";
15
16
  import { buildEvidence, deriveReviewReady, EVIDENCE_KEYS, evidenceRef, git, observeAncestry, observeCleanliness, observeTrackedCleanliness, observeWorktree, privilegedPaths, proveInitContainment, resolveWorktree, runBootstrap, unownedPaths } from "../observe/index.js";
16
17
  import { assertPublicationReady, assertReviewBinding, isApproving, observeMergeProof, readEvidence, readReview, readValidatorReview } from "../observe/review.js";
17
18
  import { readRepositoryConfig, RepositoryConfigError } from "../observe/repository-config.js";
18
19
  import { reverifyRepair } from "../observe/repair-reverification.js";
19
- import { archiveReviewAttempt } from "../state/review-archive.js";
20
+ import { readRepairState } from "../observe/repair-record.js";
21
+ import { archiveReviewAttempt, publishAttemptArchive, qualifyAttemptArchive } from "../state/review-archive.js";
22
+ import { hasRetryGrantTransaction, prepareRetryGrantTransaction, reconcileRetryGrantTransaction } from "../state/retry-grant-transaction.js";
20
23
  import { writeProtectedFileAtomic, writeProtectedJsonAtomic } from "../core/atomic-write.js";
21
24
  import { enforceEffectivePushTarget } from "../core/effective-push.js";
22
25
  import { resolveSpawnExecutable } from "../core/executable.js";
23
26
  import { dispatchInitPublication } from "./init-publication.js";
24
- import { dispatchRestore, readRestoreRecord } from "./restore.js";
25
- import { CONTROL_PLANE, SCHEMA_VERSION, GATE_NAMES, GATE_STATUSES, MODES, SLICE_STATUSES, STEP_STATUSES, TERMINAL_STATUSES, repositoryRelativePath, validateRun } from "../state/schema.js";
27
+ import { assertRetryExtensionBindings, dispatchRestore, readRestoreRecord } from "./restore.js";
28
+ import { dispatchSnapshot } from "./snapshot.js";
29
+ import { CONTROL_PLANE, SCHEMA_VERSION, GATE_NAMES, GATE_STATUSES, MODES, RETRY_EXTENSION_SCOPES, SLICE_STATUSES, STEP_STATUSES, TERMINAL_STATUSES, effectiveRetryLimit, repositoryRelativePath, validateRun } from "../state/schema.js";
26
30
  import {
27
31
  claimSessionLock, inspectSessionLock, refreshSessionLock, releaseSessionLock, SESSION_LOCK_FILE, SessionLockHeldError,
28
32
  } from "../state/session-lock.js";
@@ -36,7 +40,9 @@ export const COMMANDS = Object.freeze({
36
40
  status: Object.freeze(["--repo", "--json"]),
37
41
  "amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
38
42
  resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
43
+ "grant-retry": Object.freeze(["--repo", "--scope", "--reason", "--session", "--now", "--json"]),
39
44
  restore: Object.freeze(["--repo", "--from", "--now", "--json"]),
45
+ snapshot: Object.freeze(["--repo", "--json"]),
40
46
  decide: Object.freeze(["--repo", "--text", "--session", "--now", "--json"]),
41
47
  // No --force: `lock <id> steal` is the same operation with a name that says what it
42
48
  // does, and two spellings of "take someone else's lock" is one too many.
@@ -76,6 +82,24 @@ export async function run(argv) {
76
82
  if (!Object.hasOwn(COMMANDS, command)) throw new CliError(`unknown command '${command}' (try --help)`);
77
83
  const { positional, flags } = parse(command, rest);
78
84
  const handler = HANDLERS[command];
85
+ if (["init", "status", "snapshot", "lock", "heartbeat", "effective-push"].includes(command)) return handler(positional, flags);
86
+ const repo = resolve(flags.repo ?? process.cwd()), runId = positional[0];
87
+ if (command === "restore") {
88
+ const live = [join(repo, CONTROL_PLANE, runId), join(repo, ".factory-sandboxes", runId, CONTROL_PLANE, runId)]
89
+ .find((candidate) => existsSync(join(candidate, "run.json")));
90
+ if (!live) { assertNoRetryGrantTransaction(repo, runId, command); return handler(positional, flags); }
91
+ return withRunJsonLock(live, async () => { assertNoRetryGrantTransaction(repo, runId, command); return handler(positional, flags); },
92
+ { nonExpiring: true });
93
+ }
94
+ if (["resume", "observe", "reverify-repair"].includes(command)) { assertNoRetryGrantTransaction(repo, runId, command); return handler(positional, flags); }
95
+ if (typeof runId === "string" && /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(runId)
96
+ && existsSync(join(repo, CONTROL_PLANE, runId, "run.json"))) {
97
+ return withRunJsonLock(join(repo, CONTROL_PLANE, runId), async () => {
98
+ assertNoRetryGrantTransaction(repo, runId, command);
99
+ return handler(positional, flags);
100
+ }, { allowReentrant: true, nonExpiring: true });
101
+ }
102
+ assertNoRetryGrantTransaction(repo, runId, command);
79
103
  return handler(positional, flags);
80
104
  }
81
105
 
@@ -179,18 +203,18 @@ function briefDigestFor(decision, state, runDir) {
179
203
  // conclusion was wrong. Both caught in review.
180
204
  // Every path component is checked with `lstat` and never followed, since `lstat` on the final entry alone
181
205
  // still follows intermediate symlinks.
182
- function planeInventory(root) {
206
+ function planeInventory(root, skipped = new Set()) {
183
207
  const entries = [];
184
208
  const record = (rel, full) => {
185
- // The session lock is liveness, not run state, and it is the one entry in the plane designed to change
186
- // on a timer. Comparing it made every snapshot invalid within one heartbeat: a live park published a
209
+ // The session lock is liveness, not run state. Exact additional root entries are supplied only while a
210
+ // protected transition owns its coordination lock and atomic temp. Comparing factory.lock made every snapshot invalid within one heartbeat: a live park published a
187
211
  // complete, byte-correct plane and `status` reported `park_snapshot: null` eleven seconds later,
188
212
  // because the copy held `heartbeat_at` 23:28:25 and the plane had moved to 23:28:36. The whole point
189
213
  // of the field was to answer "did the driver publish it", and it answered "no" for a snapshot that was
190
214
  // there -- the same disagree-with-your-own-description defect the 0.8.3 work existed to remove,
191
215
  // reintroduced by the check built to remove it. Every existing test publishes and reads back with no
192
216
  // heartbeat in between, which is why three review rounds and a real park all missed it.
193
- if (rel === SESSION_LOCK_FILE) return;
217
+ if (rel === SESSION_LOCK_FILE || skipped.has(rel)) return;
194
218
  const stat = lstatSync(full);
195
219
  const mode = (stat.mode & 0o7777).toString(8);
196
220
  if (stat.isSymbolicLink()) entries.push(`${rel} l ${mode} ${readlinkSync(full)}`);
@@ -204,19 +228,53 @@ function planeInventory(root) {
204
228
  return entries.sort().join("\n");
205
229
  }
206
230
 
207
- function observedParkSnapshot(repo, runId, runDir) {
208
- const container = dirname(repo);
209
- if (basename(container) !== ".factory-sandboxes" || basename(repo) !== runId) return null;
210
- const operatorRoot = dirname(container);
211
- const candidate = join(operatorRoot, CONTROL_PLANE, ".parked", runId);
231
+ function observedParkSnapshot(repo, runId, runDir, liveSkipped = new Set()) {
232
+ const container = dirname(repo), sandbox = basename(container) === ".factory-sandboxes" && basename(repo) === runId;
233
+ const operatorRoot = sandbox ? dirname(container) : repo, candidate = join(operatorRoot, CONTROL_PLANE, ".parked", runId);
212
234
  try {
213
- for (const component of [join(operatorRoot, CONTROL_PLANE), join(operatorRoot, CONTROL_PLANE, ".parked"), candidate]) {
214
- if (!lstatSync(component).isDirectory()) return null;
215
- }
216
- if (planeInventory(candidate) !== planeInventory(runDir)) return null;
235
+ if (hasRetryGrantTransaction(candidate, runId)) return null;
236
+ const live = [join(operatorRoot, CONTROL_PLANE, runId), join(operatorRoot, ".factory-sandboxes", runId, CONTROL_PLANE, runId)]
237
+ .filter((plane) => existsSync(join(plane, "run.json")));
238
+ if (live.length !== 1 || realpathSync(live[0]) !== realpathSync(runDir)) return null;
239
+ assertRetryExtensionBindings(runDir, readRun(runDir));
240
+ assertRetryExtensionBindings(candidate, readRun(candidate));
241
+ for (const component of [join(operatorRoot, CONTROL_PLANE), join(operatorRoot, CONTROL_PLANE, ".parked"), candidate]) if (!lstatSync(component).isDirectory()) return null;
242
+ if (planeInventory(candidate) !== planeInventory(runDir, liveSkipped)) return null;
217
243
  return readFileSync(join(candidate, "run.json")).equals(readFileSync(join(runDir, "run.json"))) ? candidate : null;
218
- } catch {
219
- return null;
244
+ } catch { return null; }
245
+ }
246
+
247
+ function qualifyRetryGrant(repo, runDir, runId, state, sliceId, { requireSnapshot = true } = {}) {
248
+ const slice = state.slices.find((entry) => entry.id === sliceId);
249
+ if (!slice) throw new CliError(`unknown slice '${sliceId}'`);
250
+ const limit = effectiveRetryLimit(state, slice);
251
+ if (slice.status !== "blocked" || slice.attempts !== limit) throw new CliError(`grant-retry requires slice '${sliceId}' blocked at effective retry limit ${limit}`);
252
+ if (!slice.review_ref || !slice.evidence_ref) throw new CliError(`grant-retry requires slice '${sliceId}' recorded review and evidence`);
253
+ const review = readReview(runDir, slice.review_ref), evidence = readEvidence(runDir, slice.evidence_ref, { runId });
254
+ if (review.subject !== sliceId || review.attempt !== slice.attempts || review.verdict !== "REJECT") throw new CliError(`slice '${sliceId}' attempt ${slice.attempts} does not have a matching REJECT review`);
255
+ if (evidence.subject !== sliceId || evidence.attempt !== slice.attempts || evidence.base_ref !== slice.base_ref || review.reviewed_commit !== evidence.commit) throw new CliError(`slice '${sliceId}' REJECT does not bind its recorded evidence and base`);
256
+ const worktree = resolveWorktree(repo, slice.worktree ?? "");
257
+ if (!worktree) throw new CliError(`slice '${sliceId}' recorded worktree is unavailable`);
258
+ const branch = git(worktree, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
259
+ if (!branch.ok || branch.stdout.trim() !== slice.branch) throw new CliError(`slice '${sliceId}' worktree is not on recorded branch '${slice.branch}'`);
260
+ const cleanliness = observeCleanliness(worktree);
261
+ if (!cleanliness.clean) throw new CliError(`slice '${sliceId}' ${cleanliness.reason}`);
262
+ const observed = observeWorktree(worktree, slice.base_ref, { ref: slice.branch });
263
+ if (!observed.diff_observed || observed.commit !== evidence.commit) throw new CliError(`slice '${sliceId}' REJECT does not bind the live branch head`);
264
+ const snapshot = requireSnapshot ? observedParkSnapshot(repo, runId, runDir, new Set([RUN_JSON_LOCK_DIR])) : null;
265
+ if (requireSnapshot && !snapshot) throw new CliError(`grant-retry requires a complete current park snapshot for run '${runId}'`);
266
+ return { slice, limit, review, evidence, worktree, snapshot,
267
+ snapshotDigest: snapshot ? planDigest(Buffer.from(planeInventory(snapshot))) : null };
268
+ }
269
+
270
+ function assertNoRetryGrantTransaction(repo, runId, command) {
271
+ if (typeof runId !== "string" || !/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(runId)) return;
272
+ const canonicalRepo = existsSync(repo) ? realpathSync(repo) : repo;
273
+ const container = dirname(canonicalRepo), sandbox = basename(container) === ".factory-sandboxes" && basename(canonicalRepo) === runId;
274
+ const operatorRoot = sandbox ? dirname(container) : canonicalRepo;
275
+ const canonical = join(operatorRoot, CONTROL_PLANE, ".parked", runId);
276
+ if (hasRetryGrantTransaction(canonical, runId)) {
277
+ throw new CliError(`factory ${command} refuses an interrupted retry-grant transaction for '${runId}'; run factory snapshot first`);
220
278
  }
221
279
  }
222
280
 
@@ -340,7 +398,7 @@ function branchPoint(run) {
340
398
  return base;
341
399
  }
342
400
 
343
- async function writeObservedEvidence({ runDir, runId, subject, attempt, branch, baseRef, worktree, status, blockedReason, claim, testCommand, skipReason, shellCommand, testTimeoutMs }) {
401
+ async function writeObservedEvidence({ repo, runDir, runId, subject, attempt, branch, baseRef, worktree, status, blockedReason, claim, testCommand, skipReason, shellCommand, testTimeoutMs }) {
344
402
  const evidence = buildEvidence({
345
403
  subject, attempt, branch, baseRef, worktree, status, blockedReason, claim, runId,
346
404
  testCommand, skipReason, shellCommand, testTimeoutMs,
@@ -350,7 +408,10 @@ async function writeObservedEvidence({ runDir, runId, subject, attempt, branch,
350
408
  evidence.review_ready = false;
351
409
  evidence.blocked_reason = evidence.blocked_reason ?? `base ${baseRef} is ${ancestry} of HEAD`;
352
410
  }
353
- await writeProtectedJsonAtomic(runDir, evidenceRef(subject), evidence);
411
+ await withRunJsonLock(runDir, async () => {
412
+ assertNoRetryGrantTransaction(repo, runId, "observe");
413
+ await writeProtectedJsonAtomic(runDir, evidenceRef(subject), evidence);
414
+ }, { nonExpiring: true, reentrant: true });
354
415
  return { evidence, ancestry };
355
416
  }
356
417
 
@@ -464,7 +525,7 @@ async function runRepositoryVerifyAttempts({ repo, runDir, runId, run, mergeComm
464
525
  // False-green enforcement: one invocation gets at most two executions, never an unbounded recovery loop.
465
526
  for (let attempt = 1; attempt <= 2; attempt += 1) {
466
527
  const { evidence } = await writeObservedEvidence({
467
- runDir, runId, subject: "test-verifier", attempt, branch: run.branch,
528
+ repo, runDir, runId, subject: "test-verifier", attempt, branch: run.branch,
468
529
  baseRef, worktree: attemptIntegration.worktree, status: "completed", blockedReason: null,
469
530
  claim: null, testCommand: verify.command, skipReason: null, shellCommand: true,
470
531
  testTimeoutMs: verify.timeoutMs,
@@ -502,6 +563,7 @@ async function verifyRecordedMerge({ repo, runDir, runId, mergeCommit }) {
502
563
 
503
564
  const HANDLERS = {
504
565
  restore: dispatchRestore,
566
+ snapshot: async (positional, flags) => emit(flags, await dispatchSnapshot(positional, flags)),
505
567
 
506
568
  async ["reverify-repair"](positional, flags) {
507
569
  if (positional.length !== 2) throw new CliError("factory reverify-repair requires exactly <run-id> <repair-record-id>");
@@ -513,7 +575,7 @@ const HANDLERS = {
513
575
  const at = stamp(flags);
514
576
  const repo = resolve(flags.repo ?? process.cwd());
515
577
  const runDir = runDirFor(flags, runId);
516
- return emit(flags, await reverifyRepair({ repo, runDir, runId, recordId, at }));
578
+ return emit(flags, await reverifyRepair({ repo, runDir, runId, recordId, at, beforeWrite: () => assertNoRetryGrantTransaction(repo, runId, "reverify-repair") }));
517
579
  },
518
580
 
519
581
  "effective-push"(positional) {
@@ -630,6 +692,7 @@ const HANDLERS = {
630
692
  worktree: null,
631
693
  branch: null,
632
694
  attempts: 1,
695
+ extra_attempts: 0,
633
696
  // The ratification point: the gate approved these paths and this test plan,
634
697
  // so they are the set every later merge is judged against and the decision
635
698
  // about whether this slice may ship without an observed test run.
@@ -732,6 +795,93 @@ const HANDLERS = {
732
795
  terminal_result: next.terminal_result, amendment: row.path_amendments.at(-1) });
733
796
  },
734
797
 
798
+ async ["grant-retry"](positional, flags) {
799
+ if (positional.length !== 2) throw new CliError("factory grant-retry requires exactly <run-id> <slice-id>");
800
+ const [runId, sliceId] = positional;
801
+ if (!RETRY_EXTENSION_SCOPES.includes(flags.scope)) throw new CliError(`factory grant-retry requires --scope ${RETRY_EXTENSION_SCOPES.join("|")}`);
802
+ if (typeof flags.reason !== "string" || !flags.reason.trim()) throw new CliError("factory grant-retry requires nonblank --reason <text>");
803
+ if (typeof flags.session !== "string" || !flags.session.trim()) throw new CliError("factory grant-retry requires nonblank --session <id>");
804
+ const runDir = runDirFor(flags, runId), repo = resolve(flags.repo ?? process.cwd());
805
+ const boundBytes = readFileSync(join(runDir, "run.json")), current = validateRun(JSON.parse(boundBytes.toString("utf8")));
806
+ if (current.status !== "needs-human") throw new CliError(`factory grant-retry requires current status needs-human; found '${current.status}'`);
807
+ const owner = assertFreshSessionOwner(runDir, runId, flags.session, "grant-retry"), at = stamp(flags);
808
+ if (Date.parse(at) <= Date.parse(current.updated_at)) throw new CliError("grant-retry must move updated_at forwards");
809
+ const qualified = qualifyRetryGrant(repo, runDir, runId, current, sliceId);
810
+ if (flags.scope === "all") {
811
+ const otherBlocked = current.slices.filter((slice) => slice.id !== sliceId && slice.status === "blocked");
812
+ if (otherBlocked.length) throw new CliError(`grant-retry --scope all refuses while other slices are blocked: ${otherBlocked.map((slice) => slice.id).join(", ")}`);
813
+ const repairs = readRepairState({ runDir, state: current, runId, repo });
814
+ if (repairs.records.some((record) => record.status === "exhausted")) throw new CliError("grant-retry --scope all refuses an exhausted post-merge repair bound to the current run-wide limit");
815
+ }
816
+ const reviewArchive = qualifyAttemptArchive(runDir, qualified.slice.review_ref, `slice '${sliceId}' review`);
817
+ const evidenceArchive = qualifyAttemptArchive(runDir, qualified.slice.evidence_ref, `slice '${sliceId}' evidence`);
818
+ if (!reviewArchive.exists || !evidenceArchive.exists) {
819
+ if (!reviewArchive.exists) await publishAttemptArchive(runDir, reviewArchive);
820
+ if (!evidenceArchive.exists) await publishAttemptArchive(runDir, evidenceArchive);
821
+ throw new CliError(`grant-retry prepared missing immutable attempt archives for slice '${sliceId}'; publish a fresh snapshot and retry; no retry was granted`);
822
+ }
823
+ if (!isDeepStrictEqual(reviewArchive.record, qualified.review) || !isDeepStrictEqual(evidenceArchive.record, qualified.evidence)) throw new CliError(`slice '${sliceId}' attempt archives do not bind the qualified review and evidence`);
824
+ const priorSnapshot = join(dirname(qualified.snapshot), `.prior-${runId}`);
825
+ if (existsSync(priorSnapshot)) throw new CliError(`grant-retry requires snapshot residual '${priorSnapshot}' to be cleared by factory snapshot`);
826
+ const recheck = () => {
827
+ if (!readFileSync(join(runDir, "run.json")).equals(boundBytes)) throw new CliError("grant-retry refused because run.json changed after qualification");
828
+ if (!sameSessionOwner(runDir, owner)) throw new CliError("grant-retry refused because session ownership changed after qualification");
829
+ if (planDigest(Buffer.from(planeInventory(qualified.snapshot))) !== qualified.snapshotDigest) throw new CliError("grant-retry refused because the qualified park snapshot changed");
830
+ const observed = qualifyRetryGrant(repo, runDir, runId, current, sliceId, { requireSnapshot: false });
831
+ if (!isDeepStrictEqual(observed.review, qualified.review) || !isDeepStrictEqual(observed.evidence, qualified.evidence)) throw new CliError("grant-retry refused because the qualified review or evidence changed");
832
+ const observedReviewArchive = qualifyAttemptArchive(runDir, qualified.slice.review_ref, `slice '${sliceId}' review`);
833
+ const observedEvidenceArchive = qualifyAttemptArchive(runDir, qualified.slice.evidence_ref, `slice '${sliceId}' evidence`);
834
+ if (!observedReviewArchive.exists || observedReviewArchive.archive !== reviewArchive.archive
835
+ || !observedEvidenceArchive.exists || observedEvidenceArchive.archive !== evidenceArchive.archive) {
836
+ throw new CliError(`slice '${sliceId}' qualified attempt archives changed before grant-retry commit`);
837
+ }
838
+ if (flags.scope === "all") {
839
+ const repairs = readRepairState({ runDir, state: current, runId, repo });
840
+ if (repairs.records.some((record) => record.status === "exhausted")) {
841
+ throw new CliError("grant-retry --scope all refuses an exhausted post-merge repair bound to the current run-wide limit");
842
+ }
843
+ }
844
+ };
845
+ const mode = flags.scope === "all" ? "grant-retry-all" : "grant-retry-slice";
846
+ const next = await transition(runDir, {
847
+ participants: [{ familyId: "envelope", mode }, { familyId: "slices", mode }],
848
+ reobservers: new Map([["envelope", async () => recheck()]]),
849
+ // Enforcement: the durable fence is published while the qualified snapshot is still canonical.
850
+ // It hides stale recovery authority across process death until the manifest outcome is reconciled.
851
+ finalGuard: ({ source }) => {
852
+ recheck();
853
+ prepareRetryGrantTransaction({ canonical: qualified.snapshot, runDir, runId, source,
854
+ expectedSnapshotDigest: qualified.snapshotDigest });
855
+ },
856
+ commitFailureGuard: () => reconcileRetryGrantTransaction({ canonical: qualified.snapshot, runDir, runId }),
857
+ afterCommit: () => {
858
+ try { return reconcileRetryGrantTransaction({ canonical: qualified.snapshot, runDir, runId }); }
859
+ catch (error) { throw new CliError("grant-retry committed; snapshot revocation cleanup is pending", { cause: error }); }
860
+ },
861
+ apply: (state) => {
862
+ if (!isDeepStrictEqual(state, current)) throw new CliError("grant-retry refused because run state changed after qualification");
863
+ const existing = state.slices.find((slice) => slice.id === sliceId), previousLimit = effectiveRetryLimit(state, existing);
864
+ const row = { ...existing, status: "running", attempts: existing.attempts + 1,
865
+ ...(flags.scope === "slice" ? { extra_attempts: (existing.extra_attempts ?? 0) + 1 } : {}),
866
+ evidence_ref: null, review_ref: null };
867
+ const maxRetries = state.max_retries + (flags.scope === "all" ? 1 : 0), newLimit = maxRetries + (row.extra_attempts ?? 0);
868
+ const audit = { scope: flags.scope, slice_id: sliceId, base_ref: existing.base_ref, attempt: row.attempts, previous_limit: previousLimit,
869
+ new_limit: newLimit, previous_max_retries: state.max_retries, max_retries: maxRetries,
870
+ session: flags.session, reason: flags.reason.trim(), at, snapshot_digest: qualified.snapshotDigest,
871
+ review_ref: reviewArchive.archive, review_sha256: reviewArchive.digest,
872
+ evidence_ref: evidenceArchive.archive, evidence_sha256: evidenceArchive.digest };
873
+ return { ...state, updated_at: at, max_retries: maxRetries,
874
+ retry_extensions: [...(state.retry_extensions ?? []), audit],
875
+ slices: state.slices.map((slice) => slice.id === sliceId ? row : slice) };
876
+ },
877
+ });
878
+ const row = next.slices.find((slice) => slice.id === sliceId), audit = next.retry_extensions.at(-1);
879
+ return emit(flags, { run_id: runId, slice: sliceId, scope: flags.scope, status: next.status,
880
+ attempts: row.attempts, retry_limit: effectiveRetryLimit(next, row), max_retries: next.max_retries,
881
+ review_archive: audit.review_ref, evidence_archive: audit.evidence_ref, park_snapshot: null,
882
+ next_action: "republish park snapshot, then resume explicitly" });
883
+ },
884
+
735
885
  async slice([runId, sliceId, status], flags) {
736
886
  if (!SLICE_STATUSES.includes(status)) throw new CliError(`status must be one of ${SLICE_STATUSES.join(" | ")}`);
737
887
  const runDir = runDirFor(flags, runId);
@@ -751,9 +901,15 @@ const HANDLERS = {
751
901
  let observedBase = null;
752
902
  if (status === "running") {
753
903
  const current = readRun(runDir);
754
- const head = integrationHead(repo, current);
755
- if (!head.commit) throw new CliError(`could not observe the head of '${current.branch}' to bind the slice base`);
756
- observedBase = head.commit;
904
+ const existing = current.slices.find((slice) => slice.id === sliceId);
905
+ if (!existing) throw new CliError(`unknown slice '${sliceId}'`);
906
+ // The base is the historical branch point. Integration HEAD may move after sibling merges,
907
+ // so only a fresh activation observes it; retries preserve the exact recorded value.
908
+ if (existing.status === "pending") {
909
+ const head = integrationHead(repo, current);
910
+ if (!head.commit) throw new CliError(`could not observe the head of '${current.branch}' to bind the slice base`);
911
+ observedBase = existing.base_ref ?? head.commit;
912
+ }
757
913
  }
758
914
  if (status === "merged" && !flags.mergeCommit) throw new CliError("recording a merge requires --merge-commit");
759
915
 
@@ -875,21 +1031,48 @@ const HANDLERS = {
875
1031
  });
876
1032
  }
877
1033
 
1034
+ let retryReviewRef = null;
1035
+ let retryEvidenceRef = null;
878
1036
  const next = await transition(runDir, {
879
1037
  participants: [{ familyId: "slices", mode: status === "merged" ? "merge" : "record" }],
880
1038
  reobservers,
881
1039
  apply: (state) => {
882
1040
  const existing = state.slices.find((slice) => slice.id === sliceId);
883
1041
  if (!existing) throw new Error(`unknown slice '${sliceId}'`);
1042
+ const attempts = flags.attempts === undefined ? existing.attempts : integer(flags.attempts, 1, "--attempts");
1043
+ const advances = attempts === existing.attempts + 1;
1044
+ const blocks = status === "blocked";
1045
+ const closesRejectedReview = existing.status === "review" && (advances || blocks);
1046
+ // Enforcement: only a bound merit REJECT may spend N+1 or terminalize an exhausted review.
1047
+ if (advances && (existing.status !== "review" || status !== "running")) throw new CliError(`slice '${sliceId}' attempts may advance only from review to running`);
1048
+ if (blocks && existing.status !== "review") throw new CliError(`slice '${sliceId}' may block only from review`);
1049
+ const retryLimit = effectiveRetryLimit(state, existing);
1050
+ if (blocks && existing.attempts < retryLimit) throw new CliError(`slice '${sliceId}' cannot block before effective retry limit (${retryLimit})`);
1051
+ if (advances && (flags.worktree !== undefined || flags.branch !== undefined || flags.evidenceRef !== undefined || flags.reviewRef !== undefined)) throw new CliError("a slice retry reuses its recorded worktree, branch, and base; omit worktree, branch, evidence, and review flags");
1052
+ if (closesRejectedReview) {
1053
+ if (!existing.review_ref) throw new CliError(`slice '${sliceId}' retry requires its recorded review`);
1054
+ const review = readReview(runDir, existing.review_ref);
1055
+ if (review.subject !== sliceId || review.attempt !== existing.attempts || review.verdict !== "REJECT") throw new CliError(`slice '${sliceId}' attempt ${existing.attempts} does not have a matching REJECT review`);
1056
+ if (!existing.evidence_ref) throw new CliError(`slice '${sliceId}' retry requires its recorded evidence`);
1057
+ const evidence = readEvidence(runDir, existing.evidence_ref, { runId });
1058
+ const worktree = resolveWorktree(repo, existing.worktree ?? "");
1059
+ if (!worktree) throw new CliError(`slice '${sliceId}' recorded worktree is unavailable`);
1060
+ const observedHead = observeWorktree(worktree, existing.base_ref, { ref: existing.branch }).commit;
1061
+ if (evidence.subject !== sliceId || evidence.attempt !== existing.attempts || evidence.base_ref !== existing.base_ref || review.reviewed_commit !== evidence.commit || observedHead !== evidence.commit) throw new CliError(`slice '${sliceId}' REJECT does not bind its recorded evidence and branch head`);
1062
+ if (advances) {
1063
+ retryReviewRef = existing.review_ref;
1064
+ retryEvidenceRef = existing.evidence_ref;
1065
+ }
1066
+ }
884
1067
  const row = {
885
1068
  ...existing,
886
1069
  status,
887
- attempts: flags.attempts === undefined ? existing.attempts : integer(flags.attempts, 1, "--attempts"),
888
- worktree: flags.worktree ?? existing.worktree,
889
- branch: flags.branch ?? existing.branch,
890
- base_ref: observedBase ?? existing.base_ref,
891
- evidence_ref: flags.evidenceRef ?? existing.evidence_ref,
892
- review_ref: flags.reviewRef ?? existing.review_ref,
1070
+ attempts,
1071
+ worktree: advances ? existing.worktree : flags.worktree ?? existing.worktree,
1072
+ branch: advances ? existing.branch : flags.branch ?? existing.branch,
1073
+ base_ref: advances ? existing.base_ref : observedBase ?? existing.base_ref,
1074
+ evidence_ref: advances ? null : flags.evidenceRef ?? existing.evidence_ref,
1075
+ review_ref: advances ? null : flags.reviewRef ?? existing.review_ref,
893
1076
  merge_commit: flags.mergeCommit ?? existing.merge_commit,
894
1077
  };
895
1078
  return { ...state, updated_at: at, slices: state.slices.map((slice) => (slice.id === sliceId ? row : slice)) };
@@ -902,7 +1085,8 @@ const HANDLERS = {
902
1085
  // expose it — so the documented path could not be followed at all.
903
1086
  if (status === "merged") await verifyRecordedMerge({ repo, runDir, runId, mergeCommit: row.merge_commit });
904
1087
  // Slice attempts are budgeted the same way, so their rejected verdicts vanish the same way.
905
- const sliceReviewArchive = await archiveReviewAttempt(runDir, row.review_ref);
1088
+ const sliceReviewArchive = await archiveReviewAttempt(runDir, retryReviewRef ?? row.review_ref);
1089
+ await archiveReviewAttempt(runDir, retryEvidenceRef ?? row.evidence_ref);
906
1090
  return emit(flags, { ...mergedPayload(runId, sliceId, row), review_archive: sliceReviewArchive });
907
1091
  },
908
1092
 
@@ -960,7 +1144,14 @@ const HANDLERS = {
960
1144
  //
961
1145
  // A subject with no slice row - test-verifier, an agent step - has no ratified
962
1146
  // waiver and so has none: its tests must be observed.
963
- const slice = run.slices.find((entry) => entry.id === subject);
1147
+ const slice = flags.repositoryVerify ? null : run.slices.find((entry) => entry.id === subject);
1148
+ const attempt = flags.attempt === undefined ? 1 : integer(flags.attempt, 1, "--attempt");
1149
+ if (slice && slice.status !== "running") throw new CliError(`slice '${subject}' must be running before observation; found '${slice.status}'`);
1150
+ if (slice && attempt !== slice.attempts) {
1151
+ // Enforcement: writing canonical evidence for another attempt lets a stale review cycle
1152
+ // bypass the retry transition and its max_retries bound.
1153
+ throw new CliError(`--attempt ${attempt} does not match slice '${subject}' attempt ${slice.attempts}`);
1154
+ }
964
1155
  if (slice && flags.testCmd !== undefined
965
1156
  && !slice.test_plan.some((entry) => entry === flags.testCmd)) {
966
1157
  throw new CliError(
@@ -973,8 +1164,8 @@ const HANDLERS = {
973
1164
  : null;
974
1165
 
975
1166
  const { evidence, ancestry } = await writeObservedEvidence({
976
- runDir, runId, subject,
977
- attempt: flags.attempt === undefined ? 1 : integer(flags.attempt, 1, "--attempt"),
1167
+ repo, runDir, runId, subject,
1168
+ attempt,
978
1169
  branch: flags.repositoryVerify ? run.branch : flags.branch ?? null,
979
1170
  baseRef: flags.base, worktree, status: flags.status ?? "completed",
980
1171
  blockedReason: flags.blockedReason ?? null, claim,
@@ -1038,7 +1229,9 @@ const HANDLERS = {
1038
1229
  // is unchanged; only the shape is. Breaking for anyone parsing the strings, which is safe here
1039
1230
  // because an exact-match `FACTORY_VERSION` pin already forces consumers to move deliberately.
1040
1231
  steps: run.steps.map((step) => ({ agent: step.agent, status: step.status, attempts: step.attempts })),
1041
- slices: run.slices.map((slice) => ({ id: slice.id, status: slice.status, attempts: slice.attempts })),
1232
+ slices: run.slices.map((slice) => ({ id: slice.id, status: slice.status, attempts: slice.attempts,
1233
+ extra_attempts: slice.extra_attempts ?? 0, retry_limit: effectiveRetryLimit(run, slice) })),
1234
+ retry_extensions: run.retry_extensions ?? [],
1042
1235
  // Same narrowing: the record carries `report`, `reviewed_head` and `loops`, and only the verdict
1043
1236
  // came out. `loops` says whether validation is converging; `reviewed_head` says what it judged.
1044
1237
  validator: run.validator
@@ -1272,19 +1465,26 @@ const HANDLERS = {
1272
1465
  if (!isDeepStrictEqual(state, current)) throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
1273
1466
  if (!sameSessionOwner(runDir, boundOwner)) throw new CliError("factory resume bootstrap refused: factory.lock is absent, stale, or no longer names the same owner; current state and owner were preserved");
1274
1467
  };
1275
- // Enforcement: never unpark a driver that would keep reading an obsolete staged contract.
1276
- if (success) await writeProtectedFileAtomic(runDir, "WORKFLOW.md", readFileSync(new URL("../WORKFLOW.md", import.meta.url)));
1277
- const next = await transition(runDir, {
1468
+ // Enforcement: bootstrap permits its bound session heartbeat; serialize every later effect with grants.
1469
+ const next = await withRunJsonLock(runDir, async () => {
1470
+ assertNoRetryGrantTransaction(repo, runId, "resume"); assertBinding({ state: validateRun(JSON.parse(readFileSync(join(runDir, "run.json")))) });
1471
+ if (success) await writeProtectedFileAtomic(runDir, "WORKFLOW.md", readFileSync(new URL("../WORKFLOW.md", import.meta.url)));
1472
+ return transition(runDir, {
1278
1473
  participants: [{ familyId: "envelope", mode: success ? "resume-needs-human" : "record-bootstrap" }],
1279
- reobservers: new Map([["envelope", assertBinding]]), finalGuard: ({ state }) => {
1474
+ reobservers: new Map([["envelope", assertBinding]]), finalGuard: ({ state, source }) => {
1280
1475
  if (!readFileSync(join(runDir, "run.json")).equals(boundRunBytes) || !isDeepStrictEqual(state, current)) {
1281
1476
  throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
1282
1477
  }
1283
1478
  if (!sameSessionOwner(runDir, boundOwner)) throw new CliError("factory resume bootstrap refused: factory.lock is absent, stale, or no longer names the same owner; current state and owner were preserved");
1479
+ if ((current.retry_extensions?.length ?? 0) > 0
1480
+ && !observedParkSnapshot(repo, runId, runDir, new Set([RUN_JSON_LOCK_DIR, basename(source)]))) {
1481
+ throw new CliError("factory resume requires a complete current park snapshot after grant-retry");
1482
+ }
1284
1483
  },
1285
1484
  apply: (state) => ({ ...state, ...(success ? { status: "running" } : {}), updated_at: at,
1286
1485
  ...(outcome ? { bootstrap_command: config.bootstrapCommand, bootstrap_exit: outcome.exit } : {}) }),
1287
- });
1486
+ });
1487
+ }, { allowReentrant: true, nonExpiring: true });
1288
1488
  if (outcome?.refusal) throw new CliError(`${outcome.refusal}; run remains needs-human and its historical terminal result is preserved`);
1289
1489
  return emit(flags, {
1290
1490
  run_id: runId, status: next.status, terminal_result: next.terminal_result,
@@ -1588,6 +1788,7 @@ function preflightInit(positional, flags) {
1588
1788
  mode: flags.mode ?? "interactive",
1589
1789
  max_parallel_slices: integer(flags.maxParallelSlices, 3, "--max-parallel-slices"),
1590
1790
  max_retries: integer(flags.maxRetries, 3, "--max-retries"),
1791
+ retry_extensions: [],
1591
1792
  gates: {},
1592
1793
  steps: [],
1593
1794
  slices: [],
@@ -1662,9 +1863,11 @@ function usage() {
1662
1863
  factory init <run-id> [--branch B=feature/<run-id>] [--worktree W=.] [--pr-base TARGET] [--issue KEY] [--mode interactive|headless|autonomous]
1663
1864
  factory status <run-id> [--json]
1664
1865
  factory amend-paths <run-id> <slice-id> --add PATH [--add PATH ...] --reason TEXT --session ID [--now ISO]
1866
+ factory grant-retry <run-id> <slice-id> --scope slice|all --reason TEXT --session ID [--now ISO]
1665
1867
  factory decide <run-id> --text TEXT --session ID [--now ISO]
1666
1868
  factory resume <run-id> --session ID [--now ISO]
1667
1869
  factory restore <run-id> --repo OPERATOR --from refs/remotes/REMOTE/BRANCH [--now ISO]
1870
+ factory snapshot <run-id> --repo OPERATOR
1668
1871
  factory reverify-repair <run-id> <repair-record-id> [--repo PATH] [--now ISO] [--json]
1669
1872
  factory lock <run-id> <claim|steal|release> --session ID [--ttl-ms N]
1670
1873
  factory heartbeat <run-id> --session ID
package/bin/restore.js CHANGED
@@ -24,9 +24,9 @@ function exactDirectory(path, description) {
24
24
  if (stat.isSymbolicLink() || !stat.isDirectory() || realpathSync(path) !== path) throw new RestoreError(`${description} '${path}' is not an exact canonical directory`);
25
25
  }
26
26
 
27
- function entryState(path) { try { return lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; } }
27
+ export function entryState(path) { try { return lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; } }
28
28
 
29
- function inventoryEntries(root, skipped = new Set()) {
29
+ export function inventoryEntries(root, skipped = new Set()) {
30
30
  const entries = [];
31
31
  const visit = (relative, full) => {
32
32
  if (skipped.has(relative)) return;
@@ -41,11 +41,11 @@ function inventoryEntries(root, skipped = new Set()) {
41
41
  visit(".", root);
42
42
  return entries.sort();
43
43
  }
44
- const inventory = (root, skipped = new Set()) => JSON.stringify(inventoryEntries(root, skipped));
44
+ export const inventory = (root, skipped = new Set()) => JSON.stringify(inventoryEntries(root, skipped));
45
45
 
46
- function copySnapshot(source, target) {
46
+ export function copySnapshot(source, target, skipped = SKIPPED_ENTRIES) {
47
47
  const copy = (relative, from, to) => {
48
- if (SKIPPED_ENTRIES.has(relative)) return;
48
+ if (skipped.has(relative)) return;
49
49
  const stat = lstatSync(from), mode = stat.mode & 0o7777, present = entryState(to);
50
50
  if (stat.isDirectory()) {
51
51
  if (present && (present.isSymbolicLink() || !present.isDirectory())) throw new RestoreError(`restore target '${to}' has an unsafe type`);
@@ -91,13 +91,31 @@ function jsonAt(runDir, ref, description) {
91
91
 
92
92
  function safeReview(runDir, ref) { assertRegularRecord(runDir, ref, "review"); return readReview(runDir, ref); }
93
93
  function safeEvidence(runDir, ref, runId) { assertRegularRecord(runDir, ref, "evidence"); return readEvidence(runDir, ref, { runId }); }
94
+ const fileDigest = (path) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
95
+
96
+ export function assertRetryExtensionBindings(runDir, run) {
97
+ for (const extension of run.retry_extensions ?? []) {
98
+ const slice = run.slices.find((entry) => entry.id === extension.slice_id), priorAttempt = extension.attempt - 1;
99
+ const review = safeReview(runDir, extension.review_ref), evidence = safeEvidence(runDir, extension.evidence_ref, run.run_id);
100
+ if (fileDigest(join(runDir, extension.review_ref)) !== extension.review_sha256
101
+ || fileDigest(join(runDir, extension.evidence_ref)) !== extension.evidence_sha256) {
102
+ throw new RestoreError(`retry extension for slice '${extension.slice_id}' archive digest does not match`);
103
+ }
104
+ if (!slice || slice.base_ref !== extension.base_ref || review.subject !== slice.id || review.attempt !== priorAttempt || review.verdict !== "REJECT"
105
+ || evidence.subject !== slice.id || evidence.attempt !== priorAttempt || evidence.base_ref !== extension.base_ref
106
+ || evidence.commit !== review.reviewed_commit) {
107
+ throw new RestoreError(`retry extension for slice '${extension.slice_id}' does not bind its rejected attempt archives`);
108
+ }
109
+ }
110
+ }
94
111
 
95
112
  function transformRun(source, at, head, worktree, sourceRunDir) {
96
113
  const resetSlices = [];
97
114
  const slices = source.slices.map((slice) => {
98
- if (slice.status === "merged") return { ...slice, worktree: null };
115
+ if (["merged", "blocked"].includes(slice.status)) return slice.status === "merged" ? { ...slice, worktree: null } : { ...slice, worktree: null, branch: null, evidence_ref: null, review_ref: null, merge_commit: null };
99
116
  if (slice.status !== "pending" || [slice.worktree, slice.branch, slice.base_ref, slice.evidence_ref, slice.review_ref, slice.merge_commit].some((value) => value !== null)) resetSlices.push(slice.id);
100
- return { ...slice, status: "pending", worktree: null, branch: null, base_ref: null, evidence_ref: null, review_ref: null, merge_commit: null };
117
+ const retryBase = source.retry_extensions?.some((entry) => entry.slice_id === slice.id) ? slice.base_ref : null;
118
+ return { ...slice, status: "pending", worktree: null, branch: null, base_ref: retryBase, evidence_ref: null, review_ref: null, merge_commit: null };
101
119
  });
102
120
  const invalidated = [];
103
121
  const verifierState = source.steps.some((step) => step.agent === "test-verifier" && (step.status === "accepted" || step.review_ref || step.evidence_ref))
@@ -176,6 +194,7 @@ function qualifySource(operatorRoot, runId) {
176
194
  const bytes = readFileSync(manifest), run = validateRun(JSON.parse(bytes.toString("utf8")));
177
195
  if (run.run_id !== runId) throw new RestoreError(`park snapshot run_id '${run.run_id}' does not match requested '${runId}'`);
178
196
  if (run.status !== "needs-human") throw new RestoreError(`factory restore requires a needs-human snapshot; found '${run.status}'`);
197
+ assertRetryExtensionBindings(source, run);
179
198
  const entries = inventoryEntries(source), skipped = [...SKIPPED_ENTRIES];
180
199
  return { source, bytes, run, inventory: JSON.stringify(entries),
181
200
  copied: JSON.stringify(entries.filter((entry) => !skipped.some((name) => entry.startsWith(`${name} `) || entry.startsWith(`${name}/`)))) };
@@ -267,6 +286,7 @@ export async function dispatchRestore(positional, flags, operations = {}) {
267
286
  // Test seam only: production supplies no hook. The final guard below must catch any intervening writer.
268
287
  if (operations.beforeManifest) await operations.beforeManifest({ runDir, sandbox });
269
288
  const finalGuard = () => {
289
+ if ([`.grant-retry-${runId}.json`, `.grant-retry-${runId}.json.staging`].some((name) => entryState(join(dirname(qualified.source), name)))) throw new RestoreError("restore refuses an interrupted retry-grant transaction");
270
290
  if (!readFileSync(join(qualified.source, "run.json")).equals(qualified.bytes) || inventory(qualified.source) !== qualified.inventory) throw new RestoreError("park snapshot changed while restore was running; run.json was not published");
271
291
  if (inventory(runDir) !== preparedInventory) throw new RestoreError("restored control plane changed before manifest publication; run.json was not published");
272
292
  if (entryState(legacyManifest)) throw new RestoreError(`live run manifest appeared at '${legacyManifest}' while restore was running`);