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/README.md +12 -0
- package/WORKFLOW.md +107 -40
- package/bin/factory.js +245 -42
- package/bin/restore.js +27 -7
- package/bin/snapshot.js +173 -0
- package/core/atomic-write.js +11 -7
- package/core/contracts.js +48 -7
- package/core/run-lock.js +26 -6
- package/core/write-core.js +17 -4
- package/observe/repair-reverification.js +3 -3
- package/package.json +1 -1
- package/state/retry-grant-transaction.js +194 -0
- package/state/review-archive.js +36 -22
- package/state/schema.js +85 -4
- package/state/session-lock.js +3 -3
- package/state/transition.js +3 -1
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 {
|
|
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 {
|
|
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
|
|
186
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
755
|
-
if (!
|
|
756
|
-
|
|
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
|
|
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
|
|
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:
|
|
1276
|
-
|
|
1277
|
-
|
|
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 (
|
|
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"
|
|
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
|
-
|
|
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`);
|