feature-factory 0.10.3 → 0.10.4
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 +3 -2
- package/WORKFLOW.md +6 -4
- package/bin/factory.js +8 -3
- package/core/contracts.js +3 -2
- package/package.json +1 -1
- package/state/schema.js +6 -5
package/README.md
CHANGED
|
@@ -258,8 +258,9 @@ own the same path. Duplicate, target-already-owned, malformed, privileged, repla
|
|
|
258
258
|
requests refuse atomically. Resume never amends or reseeds. A merge continues to refuse every unamended
|
|
259
259
|
or privileged changed path.
|
|
260
260
|
|
|
261
|
-
A parked slice that exhausted its effective limit can
|
|
262
|
-
`factory grant-retry <run-id> <slice-id> --scope slice|all --reason <text> --session <id> --repo <sandbox
|
|
261
|
+
A parked slice that exhausted its effective limit can be reopened for one more attempt through
|
|
262
|
+
`factory grant-retry <run-id> <slice-id> --scope slice|all [--max-retries N] --reason <text> --session <id> --repo <sandbox>`;
|
|
263
|
+
`--max-retries` (with `all` only) sets the new run-wide limit in one call instead of raising it by one.
|
|
263
264
|
Slice scope raises only the target's additive allowance. All scope raises `max_retries` for pending later
|
|
264
265
|
waves too, but refuses when another slice is blocked or an exhausted post-merge repair record exists. Both
|
|
265
266
|
scopes reopen only the named slice after exact owner, snapshot, REJECT, evidence, base, current clean head,
|
package/WORKFLOW.md
CHANGED
|
@@ -224,12 +224,14 @@ scope deliberately:
|
|
|
224
224
|
|
|
225
225
|
```sh
|
|
226
226
|
factory grant-retry "$R" "$SLICE_ID" --scope slice --reason "$EXTENSION_REASON" --session "$SESSION_ID" --repo "$RUN_REPO"
|
|
227
|
-
factory grant-retry "$R" "$SLICE_ID" --scope all --reason "$EXTENSION_REASON" --session "$SESSION_ID" --repo "$RUN_REPO"
|
|
227
|
+
factory grant-retry "$R" "$SLICE_ID" --scope all [--max-retries "$N"] --reason "$EXTENSION_REASON" --session "$SESSION_ID" --repo "$RUN_REPO"
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
`slice` raises only that slice's additive allowance. `all` raises the run-wide default, including
|
|
231
|
-
pending later wave, but still reopens only `SLICE_ID`; it refuses while another slice is blocked or an
|
|
232
|
-
exhausted post-merge repair exists.
|
|
230
|
+
`slice` raises only that slice's additive allowance by one. `all` raises the run-wide default, including
|
|
231
|
+
every pending later wave, but still reopens only `SLICE_ID`; it refuses while another slice is blocked or an
|
|
232
|
+
exhausted post-merge repair exists. `all` raises by one unless `--max-retries N` sets the new run-wide limit
|
|
233
|
+
directly; `N` must exceed the current limit. Choose `N` for the whole remaining plan: once granted, later
|
|
234
|
+
rejections below it retry without another operator grant. Both scopes require `blocked@N` exactly at the current effective limit;
|
|
233
235
|
a matching REJECT, evidence, immutable base and live clean branch head; the exact fresh lock owner; a
|
|
234
236
|
complete current park snapshot; and immutable attempt-N review and evidence archives. A legacy run missing
|
|
235
237
|
an archive gets a preparation-only refusal: publish the changed plane and invoke the grant again. They
|
package/bin/factory.js
CHANGED
|
@@ -40,7 +40,7 @@ export const COMMANDS = Object.freeze({
|
|
|
40
40
|
status: Object.freeze(["--repo", "--json"]),
|
|
41
41
|
"amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
|
|
42
42
|
resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
|
|
43
|
-
"grant-retry": Object.freeze(["--repo", "--scope", "--reason", "--session", "--now", "--json"]),
|
|
43
|
+
"grant-retry": Object.freeze(["--repo", "--scope", "--max-retries", "--reason", "--session", "--now", "--json"]),
|
|
44
44
|
restore: Object.freeze(["--repo", "--from", "--now", "--json"]),
|
|
45
45
|
snapshot: Object.freeze(["--repo", "--json"]),
|
|
46
46
|
decide: Object.freeze(["--repo", "--text", "--session", "--now", "--json"]),
|
|
@@ -799,11 +799,16 @@ const HANDLERS = {
|
|
|
799
799
|
if (positional.length !== 2) throw new CliError("factory grant-retry requires exactly <run-id> <slice-id>");
|
|
800
800
|
const [runId, sliceId] = positional;
|
|
801
801
|
if (!RETRY_EXTENSION_SCOPES.includes(flags.scope)) throw new CliError(`factory grant-retry requires --scope ${RETRY_EXTENSION_SCOPES.join("|")}`);
|
|
802
|
+
if (flags.maxRetries !== undefined && flags.scope !== "all") throw new CliError("factory grant-retry --max-retries requires --scope all");
|
|
802
803
|
if (typeof flags.reason !== "string" || !flags.reason.trim()) throw new CliError("factory grant-retry requires nonblank --reason <text>");
|
|
803
804
|
if (typeof flags.session !== "string" || !flags.session.trim()) throw new CliError("factory grant-retry requires nonblank --session <id>");
|
|
804
805
|
const runDir = runDirFor(flags, runId), repo = resolve(flags.repo ?? process.cwd());
|
|
805
806
|
const boundBytes = readFileSync(join(runDir, "run.json")), current = validateRun(JSON.parse(boundBytes.toString("utf8")));
|
|
806
807
|
if (current.status !== "needs-human") throw new CliError(`factory grant-retry requires current status needs-human; found '${current.status}'`);
|
|
808
|
+
// Instruction, not enforcement, on the size: a higher run-wide limit buys more reviewed attempts, never an
|
|
809
|
+
// unearned merge. Enforced only that it rises, since the grant must reopen the exhausted slice at N+1.
|
|
810
|
+
const grantedMax = flags.scope === "all" ? integer(flags.maxRetries, current.max_retries + 1, "--max-retries") : current.max_retries;
|
|
811
|
+
if (flags.scope === "all" && grantedMax <= current.max_retries) throw new CliError(`grant-retry --max-retries must exceed the current run-wide limit ${current.max_retries}`);
|
|
807
812
|
const owner = assertFreshSessionOwner(runDir, runId, flags.session, "grant-retry"), at = stamp(flags);
|
|
808
813
|
if (Date.parse(at) <= Date.parse(current.updated_at)) throw new CliError("grant-retry must move updated_at forwards");
|
|
809
814
|
const qualified = qualifyRetryGrant(repo, runDir, runId, current, sliceId);
|
|
@@ -864,7 +869,7 @@ const HANDLERS = {
|
|
|
864
869
|
const row = { ...existing, status: "running", attempts: existing.attempts + 1,
|
|
865
870
|
...(flags.scope === "slice" ? { extra_attempts: (existing.extra_attempts ?? 0) + 1 } : {}),
|
|
866
871
|
evidence_ref: null, review_ref: null };
|
|
867
|
-
const maxRetries =
|
|
872
|
+
const maxRetries = grantedMax, newLimit = maxRetries + (row.extra_attempts ?? 0);
|
|
868
873
|
const audit = { scope: flags.scope, slice_id: sliceId, base_ref: existing.base_ref, attempt: row.attempts, previous_limit: previousLimit,
|
|
869
874
|
new_limit: newLimit, previous_max_retries: state.max_retries, max_retries: maxRetries,
|
|
870
875
|
session: flags.session, reason: flags.reason.trim(), at, snapshot_digest: qualified.snapshotDigest,
|
|
@@ -1863,7 +1868,7 @@ function usage() {
|
|
|
1863
1868
|
factory init <run-id> [--branch B=feature/<run-id>] [--worktree W=.] [--pr-base TARGET] [--issue KEY] [--mode interactive|headless|autonomous]
|
|
1864
1869
|
factory status <run-id> [--json]
|
|
1865
1870
|
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]
|
|
1871
|
+
factory grant-retry <run-id> <slice-id> --scope slice|all [--max-retries N] --reason TEXT --session ID [--now ISO]
|
|
1867
1872
|
factory decide <run-id> --text TEXT --session ID [--now ISO]
|
|
1868
1873
|
factory resume <run-id> --session ID [--now ISO]
|
|
1869
1874
|
factory restore <run-id> --repo OPERATOR --from refs/remotes/REMOTE/BRANCH [--now ISO]
|
package/core/contracts.js
CHANGED
|
@@ -66,7 +66,7 @@ const envelope = contract({
|
|
|
66
66
|
const scope = mode === "grant-retry-all" ? "all" : "slice";
|
|
67
67
|
if (after.status !== "needs-human" || !isDeepStrictEqual(after.terminal_result, before.terminal_result)) throw new Error("grant-retry must preserve the parked envelope and terminal_result");
|
|
68
68
|
if (Date.parse(after.updated_at) <= Date.parse(before.updated_at)) throw new Error("grant-retry must move updated_at forwards");
|
|
69
|
-
if (scope === "all" ? after.max_retries
|
|
69
|
+
if (scope === "all" ? !(after.max_retries > before.max_retries) : after.max_retries !== before.max_retries) throw new Error(`grant-retry ${scope} has an invalid run-wide retry limit`);
|
|
70
70
|
if (after.retry_extensions.length !== before.retry_extensions.length + 1 || !isDeepStrictEqual(after.retry_extensions.slice(0, -1), before.retry_extensions) || after.retry_extensions.at(-1)?.scope !== scope) throw new Error("grant-retry must append one matching audit record");
|
|
71
71
|
for (const key of Object.keys(before).filter((key) => !["updated_at", "max_retries", "retry_extensions"].includes(key))) if (!isDeepStrictEqual(before[key], after[key])) throw new Error(`grant-retry cannot change envelope.${key}`);
|
|
72
72
|
for (const key of Object.keys(current).filter((key) => !Object.hasOwn(before, key) && key !== "slices")) if (!isDeepStrictEqual(current[key], candidate[key])) throw new Error(`grant-retry cannot change run.${key}`);
|
|
@@ -340,7 +340,8 @@ const slices = contract({
|
|
|
340
340
|
if (scope === "all" && before.some((entry, entryIndex) => entryIndex !== index && entry.status === "blocked")) throw new Error("grant-retry all cannot strand another blocked slice below the raised limit");
|
|
341
341
|
if (prior.id !== slice.id || prior.status !== "blocked" || slice.status !== "running") throw new Error("grant-retry requires one blocked slice to become running");
|
|
342
342
|
const previousLimit = effectiveRetryLimit(current, prior), nextLimit = effectiveRetryLimit(candidate, slice);
|
|
343
|
-
|
|
343
|
+
const raise = scope === "all" ? candidate.max_retries - current.max_retries : 1;
|
|
344
|
+
if (prior.attempts !== previousLimit || slice.attempts !== prior.attempts + 1 || nextLimit !== previousLimit + raise) throw new Error("grant-retry must open exactly N+1 from the exhausted effective limit");
|
|
344
345
|
const previousExtra = prior.extra_attempts ?? 0, nextExtra = slice.extra_attempts ?? 0;
|
|
345
346
|
if (scope === "slice" ? nextExtra !== previousExtra + 1 : nextExtra !== previousExtra) throw new Error(`grant-retry ${scope} has an invalid slice-specific extension`);
|
|
346
347
|
for (const key of new Set([...Object.keys(prior), ...Object.keys(slice)])) if (!["status", "attempts", "extra_attempts", "evidence_ref", "review_ref"].includes(key) && !isDeepStrictEqual(prior[key], slice[key])) throw new Error(`grant-retry cannot change slice '${slice.id}' ${key}`);
|
package/package.json
CHANGED
package/state/schema.js
CHANGED
|
@@ -181,9 +181,10 @@ function retryExtensions(errors, run) {
|
|
|
181
181
|
for (const key of ["attempt", "previous_limit", "new_limit", "previous_max_retries", "max_retries"]) positiveInt(errors, entry, key, path);
|
|
182
182
|
if (!RETRY_EXTENSION_SCOPES.includes(entry.scope) || !ID.test(entry.slice_id)
|
|
183
183
|
|| ["attempt", "previous_limit", "new_limit", "previous_max_retries", "max_retries"].some((key) => !Number.isSafeInteger(entry[key]) || entry[key] < 1)) crossCheck = false;
|
|
184
|
-
|
|
184
|
+
const raise = entry.scope === "all" ? entry.max_retries - entry.previous_max_retries : 1;
|
|
185
|
+
if (Number.isSafeInteger(entry.previous_limit) && entry.new_limit !== entry.previous_limit + raise) errors.push({ path: `${path}.new_limit`, message: "must advance by the granted raise" });
|
|
185
186
|
if (entry.scope === "slice" && entry.max_retries !== entry.previous_max_retries) errors.push({ path: `${path}.max_retries`, message: "must stay unchanged for slice scope" });
|
|
186
|
-
if (entry.scope === "all" &&
|
|
187
|
+
if (entry.scope === "all" && !(raise >= 1)) errors.push({ path: `${path}.max_retries`, message: "must rise for all scope" });
|
|
187
188
|
for (const key of ["session", "reason"]) required(errors, entry, key, path);
|
|
188
189
|
pattern(errors, entry, "at", ISO, path);
|
|
189
190
|
pattern(errors, entry, "snapshot_digest", DIGEST, path);
|
|
@@ -196,7 +197,7 @@ function retryExtensions(errors, run) {
|
|
|
196
197
|
}
|
|
197
198
|
});
|
|
198
199
|
if (!crossCheck) return;
|
|
199
|
-
let maxRetries = run.max_retries - value.
|
|
200
|
+
let maxRetries = run.max_retries - value.reduce((sum, entry) => sum + (entry.scope === "all" ? entry.max_retries - entry.previous_max_retries : 0), 0);
|
|
200
201
|
if (maxRetries < 1) return void errors.push({ path: "run.retry_extensions", message: "contains more run-wide grants than the final max_retries permits" });
|
|
201
202
|
const extras = new Map(), latestGrant = new Map(), grantBases = new Map(), archiveRefs = new Set();
|
|
202
203
|
let previousAt = null;
|
|
@@ -212,10 +213,10 @@ function retryExtensions(errors, run) {
|
|
|
212
213
|
previousAt = Date.parse(entry.at);
|
|
213
214
|
const previousLimit = maxRetries + previousExtra;
|
|
214
215
|
if (entry.previous_max_retries !== maxRetries || entry.previous_limit !== previousLimit) errors.push({ path, message: "does not continue the recorded retry limits" });
|
|
215
|
-
if (entry.scope === "all") maxRetries
|
|
216
|
+
if (entry.scope === "all") maxRetries = entry.max_retries;
|
|
216
217
|
else extras.set(entry.slice_id, previousExtra + 1);
|
|
217
218
|
const newLimit = maxRetries + (extras.get(entry.slice_id) ?? 0);
|
|
218
|
-
if (entry.max_retries !== maxRetries || entry.new_limit !== newLimit || entry.attempt !==
|
|
219
|
+
if (entry.max_retries !== maxRetries || entry.new_limit !== newLimit || entry.attempt !== previousLimit + 1) errors.push({ path, message: "does not bind the granted attempt and resulting limits" });
|
|
219
220
|
if (grantBases.has(entry.slice_id) && grantBases.get(entry.slice_id) !== entry.base_ref) errors.push({ path: `${path}.base_ref`, message: "changes the slice's immutable retry base" });
|
|
220
221
|
grantBases.set(entry.slice_id, entry.base_ref); latestGrant.set(entry.slice_id, entry.attempt);
|
|
221
222
|
}
|