feature-factory 0.10.3 → 0.10.5
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 +16 -9
- package/WORKFLOW.md +34 -43
- package/bin/factory.js +25 -5
- package/core/contracts.js +3 -2
- package/observe/identity.js +44 -0
- package/observe/repository-config.js +8 -2
- package/package.json +1 -1
- package/state/schema.js +6 -5
package/README.md
CHANGED
|
@@ -47,11 +47,13 @@ resolved Git top level:
|
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
The root has two required properties, `resolve` and `verify`, and
|
|
51
|
-
`pr_draft`, `verify_timeout_ms`, `bootstrap`, and `
|
|
50
|
+
The root has two required properties, `resolve` and `verify`, and six optional properties: `publish`,
|
|
51
|
+
`pr_draft`, `verify_timeout_ms`, `bootstrap`, `bootstrap_timeout_ms`, and `max_retries`. Required commands and any present
|
|
52
52
|
`publish` or `bootstrap` are non-empty strings. There is no `publishing_identity` key, and a file carrying one is malformed because the
|
|
53
53
|
optional set is closed. A present `pr_draft` must be a JSON boolean and omission means `true`. Both timeouts are
|
|
54
54
|
positive safe integers. `bootstrap_timeout_ms` requires `bootstrap`.
|
|
55
|
+
A present `max_retries` must be a positive integer. It is the run's attempt budget when `init` runs
|
|
56
|
+
without `--max-retries`, which still outranks it; omission leaves the default `3`.
|
|
55
57
|
Each omitted timeout independently defaults to `900000`; neither shares the other's budget. The file is
|
|
56
58
|
operator-owned, committed, and protected as a privileged path: a run cannot create, write, merge,
|
|
57
59
|
archive, package, or repair it.
|
|
@@ -66,9 +68,9 @@ value stops the run instead of publishing under whatever credential the host hap
|
|
|
66
68
|
it from `gh`, the token, stored authentication, or Git configuration: an expectation read from the
|
|
67
69
|
credential being checked would always match.
|
|
68
70
|
|
|
69
|
-
Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; then invalid `
|
|
71
|
+
Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; invalid `publish`; then invalid `max_retries`.
|
|
70
72
|
|
|
71
|
-
The named forms are `.factory.json entry 'pr_draft' must be a boolean`, `.factory.json entry 'bootstrap' must be a non-empty string`, `.factory.json entry 'bootstrap_timeout_ms' requires a declared bootstrap command`, `.factory.json entry 'bootstrap_timeout_ms' must be a positive integer`, and `.factory.json entry '
|
|
73
|
+
The named forms are `.factory.json entry 'pr_draft' must be a boolean`, `.factory.json entry 'bootstrap' must be a non-empty string`, `.factory.json entry 'bootstrap_timeout_ms' requires a declared bootstrap command`, `.factory.json entry 'bootstrap_timeout_ms' must be a positive integer`, `.factory.json entry 'verify_timeout_ms' must be a positive integer`, and `.factory.json entry 'max_retries' must be a positive integer`.
|
|
72
74
|
|
|
73
75
|
A present invalid, unreadable, incomplete, wrong-type, whitespace-only, or unknown-property config refuses closed.
|
|
74
76
|
|
|
@@ -258,8 +260,9 @@ own the same path. Duplicate, target-already-owned, malformed, privileged, repla
|
|
|
258
260
|
requests refuse atomically. Resume never amends or reseeds. A merge continues to refuse every unamended
|
|
259
261
|
or privileged changed path.
|
|
260
262
|
|
|
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
|
|
263
|
+
A parked slice that exhausted its effective limit can be reopened for one more attempt through
|
|
264
|
+
`factory grant-retry <run-id> <slice-id> --scope slice|all [--max-retries N] --reason <text> --session <id> --repo <sandbox>`;
|
|
265
|
+
`--max-retries` (with `all` only) sets the new run-wide limit in one call instead of raising it by one.
|
|
263
266
|
Slice scope raises only the target's additive allowance. All scope raises `max_retries` for pending later
|
|
264
267
|
waves too, but refuses when another slice is blocked or an exhausted post-merge repair record exists. Both
|
|
265
268
|
scopes reopen only the named slice after exact owner, snapshot, REJECT, evidence, base, current clean head,
|
|
@@ -286,14 +289,18 @@ report `null`, skips all three guards; an absent config does not affect them.
|
|
|
286
289
|
|
|
287
290
|
Before each guard, inherited `GH_TOKEN` must exist and contain at least one character. Missing or empty
|
|
288
291
|
means identity is unobservable without invoking `gh`, the network, stored authentication, credential
|
|
289
|
-
queries, or any fallback. A prepared environment submits exactly this
|
|
292
|
+
queries, or any fallback. A prepared environment submits exactly this command as one
|
|
290
293
|
ordinary host shell step with cwd exactly `RUN_REPO`, inherited environment, and no stdin:
|
|
291
294
|
|
|
292
295
|
```sh
|
|
293
|
-
|
|
296
|
+
factory identity "$R" --json --repo "$RUN_REPO"
|
|
294
297
|
```
|
|
295
298
|
|
|
296
|
-
The
|
|
299
|
+
The CLI runs the read-only network probe `gh api --method GET /user --jq .login` itself, with separate
|
|
300
|
+
stdout and stderr pipes, so a host whose shell tool combines the two streams can still run the guard. It
|
|
301
|
+
returns JSON whose `reason` is null on a match and is otherwise the complete rendered park reason.
|
|
302
|
+
|
|
303
|
+
The probe's stdout bytes, stderr bytes, and numeric status are parsed strictly. Only numeric zero, empty
|
|
297
304
|
stderr, and exactly one ASCII GitHub login plus one LF are observable; the required LF alone is removed,
|
|
298
305
|
then the raw login is compared exactly and case-sensitively with the raw declaration. `gh auth status`
|
|
299
306
|
does not prove the publishing identity.
|
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
|
|
@@ -545,8 +547,8 @@ The optional repository-owned file is `$O/.factory.json`:
|
|
|
545
547
|
```
|
|
546
548
|
|
|
547
549
|
The root must be a JSON object with the two required own properties `resolve` and `verify`,
|
|
548
|
-
plus only the optional own properties `publish`, `pr_draft`, `verify_timeout_ms`, `bootstrap`,
|
|
549
|
-
`bootstrap_timeout_ms`. `resolve`, `verify`, `publish`, and `bootstrap` are command strings; every present
|
|
550
|
+
plus only the optional own properties `publish`, `pr_draft`, `verify_timeout_ms`, `bootstrap`,
|
|
551
|
+
`bootstrap_timeout_ms`, and `max_retries`. `resolve`, `verify`, `publish`, and `bootstrap` are command strings; every present
|
|
550
552
|
command must be non-empty. `publish` was required and invoked nowhere until this release, so every
|
|
551
553
|
consumer wrote a command that could not run. It is optional now and contributes only the file candidate
|
|
552
554
|
to the one Step 6 publishing selection. Inherited `FACTORY_PUBLISHING_COMMAND` or the default may win, so
|
|
@@ -557,9 +559,10 @@ key is malformed, because the optional set above is closed. `pr_draft` must be a
|
|
|
557
559
|
when present and defaults to `true` when absent. Both timeout values must be positive
|
|
558
560
|
safe integers when present, and `bootstrap_timeout_ms` is valid only with a declared `bootstrap`.
|
|
559
561
|
`verify_timeout_ms` and `bootstrap_timeout_ms` each independently default to `900000` milliseconds;
|
|
560
|
-
neither timeout shares or consumes the other's budget.
|
|
562
|
+
neither timeout shares or consumes the other's budget. A present `max_retries` must be a positive integer. It is the run's attempt budget when `init` runs
|
|
563
|
+
without `--max-retries`, which still outranks it; omission leaves the default `3`.
|
|
561
564
|
|
|
562
|
-
Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; then invalid `
|
|
565
|
+
Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; invalid `publish`; then invalid `max_retries`.
|
|
563
566
|
|
|
564
567
|
Do not use the obsolete summary “Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; then missing or invalid required entries.” because it omits the earlier `pr_draft` check.
|
|
565
568
|
|
|
@@ -1071,53 +1074,41 @@ reconciliation, reading `status.next`, dispatch, or any transition. For a parked
|
|
|
1071
1074
|
immediately after explicit resume has been verified `running` with unchanged historical result, real
|
|
1072
1075
|
next action, and the same fresh owner. No operation may intervene on either side of this guard.
|
|
1073
1076
|
|
|
1074
|
-
At every one of the three guards,
|
|
1075
|
-
|
|
1076
|
-
`GH_TOKEN` is immediately the same unobservable reason below. Do not invoke `gh`, hit the network,
|
|
1077
|
-
inspect stored authentication, query or attempt credentials, or run any fallback in that case.
|
|
1078
|
-
|
|
1079
|
-
After that preflight succeeds, submit exactly this command as one ordinary host shell step with cwd
|
|
1080
|
-
exactly `RUN_REPO`, the inherited environment including that nonempty `GH_TOKEN`, and no stdin:
|
|
1077
|
+
At every one of the three guards, submit exactly this command as one ordinary host shell step with cwd
|
|
1078
|
+
exactly `RUN_REPO`, the inherited environment, and no stdin:
|
|
1081
1079
|
|
|
1082
1080
|
```sh
|
|
1083
|
-
|
|
1081
|
+
factory identity "$R" --json --repo "$RUN_REPO"
|
|
1084
1082
|
```
|
|
1085
1083
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`. Every other status or byte sequence is unobservable;
|
|
1094
|
-
do not trim, decode-and-normalize, retry, or recover a partial value. Remove only the required final LF
|
|
1095
|
-
from an observable value, then compare the raw declared and observed strings exactly and
|
|
1096
|
-
case-sensitively before rendering either one.
|
|
1097
|
-
|
|
1098
|
-
Render a value for the reason with the deterministic ASCII-only JSON-string renderer. Surround it with
|
|
1099
|
-
double quotes. Emit printable ASCII U+0020 through U+007E literally except quote and backslash, which
|
|
1100
|
-
use `\"` and `\\`. Use the fixed JSON short escapes `\b`, `\t`, `\n`, `\f`, and `\r` for U+0008,
|
|
1101
|
-
U+0009, U+000A, U+000C, and U+000D. Render every other UTF-16 code unit outside U+0020 through U+007E
|
|
1102
|
-
as lowercase `\uXXXX`. A non-BMP code point therefore renders as its two surrogate units, and an
|
|
1103
|
-
unpaired surrogate renders as its one unit. Leave slash unescaped. This covers C0, C1, DEL, U+0085,
|
|
1104
|
-
U+2028, U+2029, and non-BMP input without a literal non-ASCII or control byte.
|
|
1084
|
+
The CLI owns the observation, so the verdict does not depend on what the host's shell tool reports. It
|
|
1085
|
+
refuses without invoking `gh` when inherited `GH_TOKEN` is missing or empty. Otherwise it runs the
|
|
1086
|
+
read-only network observation `gh api --method GET /user --jq .login` with separate stdout and stderr pipes,
|
|
1087
|
+
accepts only numeric status zero, zero-byte stderr, and exactly one ASCII login matching
|
|
1088
|
+
`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$` followed by one LF, and compares it exactly and
|
|
1089
|
+
case-sensitively with the recorded `publishing_identity`. A host whose shell tool combines stdout and
|
|
1090
|
+
stderr can run this guard; it could never run the probe itself.
|
|
1105
1091
|
|
|
1106
|
-
|
|
1092
|
+
Require exit zero and one JSON object. `reason: null` passes the guard. A non-null `reason` is the
|
|
1093
|
+
complete already-rendered ASCII park reason, rendered with deterministic ASCII-only JSON strings, and is
|
|
1094
|
+
exactly one of:
|
|
1107
1095
|
|
|
1108
1096
|
```text
|
|
1109
1097
|
publishing identity mismatch: declared <declared-ascii-json>, observed <observed-ascii-json>; authenticate as <declared-ascii-json> and retry.
|
|
1110
1098
|
```
|
|
1111
1099
|
|
|
1112
|
-
An unobservable result uses exactly:
|
|
1113
|
-
|
|
1114
1100
|
```text
|
|
1115
1101
|
publishing identity unobservable: declared <declared-ascii-json>; launch with inherited GH_TOKEN for <declared-ascii-json> as documented in OPERATING.md and retry.
|
|
1116
1102
|
```
|
|
1117
1103
|
|
|
1104
|
+
Use the returned `reason` exactly; do not re-render, trim, or edit it. Never run `gh` yourself for this
|
|
1105
|
+
guard, retry, fall back to stored authentication, or put a token in argv. A nonzero exit or output that is
|
|
1106
|
+
not that JSON object is a failed guard: perform no further operation and report only
|
|
1107
|
+
`Outcome: retained-lock-error`.
|
|
1108
|
+
|
|
1118
1109
|
Never expose the token, raw stdout or stderr, diagnostics, status, command text, target, helper output,
|
|
1119
1110
|
or environment. On either reason, quiesce every builder, tool, background task, and heartbeat call.
|
|
1120
|
-
Bind `PRE_QUOTING_REASON` to the complete already-rendered ASCII reason. Encode it as one deterministic
|
|
1111
|
+
Bind `PRE_QUOTING_REASON` to the complete already-rendered ASCII reason the CLI returned. Encode it as one deterministic
|
|
1121
1112
|
POSIX shell token by surrounding the complete reason with single quotes and replacing every literal
|
|
1122
1113
|
`'` inside it with the exact shell sequence `'\''`. Use that encoded token as the sole `--reason`
|
|
1123
1114
|
argument in the host shell command string:
|
|
@@ -2166,12 +2157,12 @@ this is not a claim that a target is unobservable. Use the same three exact reda
|
|
|
2166
2157
|
from Step 0.
|
|
2167
2158
|
|
|
2168
2159
|
With `DECLARED_PUBLISHING_IDENTITY`, immediately after exact target equality and before the unchanged
|
|
2169
|
-
push, run the same
|
|
2170
|
-
|
|
2160
|
+
push, run the same identity guard command under its exact cwd, environment, no-stdin, verdict,
|
|
2161
|
+
redaction, and parking rules. No operation may intervene between equality, this
|
|
2171
2162
|
guard, and the push:
|
|
2172
2163
|
|
|
2173
2164
|
```sh
|
|
2174
|
-
|
|
2165
|
+
factory identity "$R" --json --repo "$RUN_REPO"
|
|
2175
2166
|
```
|
|
2176
2167
|
|
|
2177
2168
|
Publish the fully qualified recorded feature ref from `RUN_REPO`, run `gh` from `O` with that exact head
|
|
@@ -2181,7 +2172,7 @@ legacy local runs use `O` through the selection already made in Step 0:
|
|
|
2181
2172
|
|
|
2182
2173
|
```sh
|
|
2183
2174
|
git -C "$RUN_REPO" push origin "refs/heads/$FEATURE_BRANCH:refs/heads/$FEATURE_BRANCH"
|
|
2184
|
-
|
|
2175
|
+
factory identity "$R" --json --repo "$RUN_REPO"
|
|
2185
2176
|
(
|
|
2186
2177
|
cd "$O"
|
|
2187
2178
|
if [ "$PR_DRAFT" = true ]; then
|
package/bin/factory.js
CHANGED
|
@@ -14,6 +14,7 @@ import { nextAction, nextActionRecord, readRun, readRunUnchecked } from "../stat
|
|
|
14
14
|
import { transition } from "../state/transition.js";
|
|
15
15
|
import { RUN_JSON_LOCK_DIR, withRunJsonLock } from "../core/run-lock.js";
|
|
16
16
|
import { buildEvidence, deriveReviewReady, EVIDENCE_KEYS, evidenceRef, git, observeAncestry, observeCleanliness, observeTrackedCleanliness, observeWorktree, privilegedPaths, proveInitContainment, resolveWorktree, runBootstrap, unownedPaths } from "../observe/index.js";
|
|
17
|
+
import { identityReason, observeIdentity } from "../observe/identity.js";
|
|
17
18
|
import { assertPublicationReady, assertReviewBinding, isApproving, observeMergeProof, readEvidence, readReview, readValidatorReview } from "../observe/review.js";
|
|
18
19
|
import { readRepositoryConfig, RepositoryConfigError } from "../observe/repository-config.js";
|
|
19
20
|
import { reverifyRepair } from "../observe/repair-reverification.js";
|
|
@@ -40,7 +41,7 @@ export const COMMANDS = Object.freeze({
|
|
|
40
41
|
status: Object.freeze(["--repo", "--json"]),
|
|
41
42
|
"amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
|
|
42
43
|
resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
|
|
43
|
-
"grant-retry": Object.freeze(["--repo", "--scope", "--reason", "--session", "--now", "--json"]),
|
|
44
|
+
"grant-retry": Object.freeze(["--repo", "--scope", "--max-retries", "--reason", "--session", "--now", "--json"]),
|
|
44
45
|
restore: Object.freeze(["--repo", "--from", "--now", "--json"]),
|
|
45
46
|
snapshot: Object.freeze(["--repo", "--json"]),
|
|
46
47
|
decide: Object.freeze(["--repo", "--text", "--session", "--now", "--json"]),
|
|
@@ -60,6 +61,7 @@ export const COMMANDS = Object.freeze({
|
|
|
60
61
|
pr: Object.freeze(["--repo", "--url", "--now", "--json"]),
|
|
61
62
|
"reverify-repair": Object.freeze(["--repo", "--now", "--json"]),
|
|
62
63
|
"effective-push": Object.freeze([]),
|
|
64
|
+
identity: Object.freeze(["--repo", "--json"]),
|
|
63
65
|
});
|
|
64
66
|
|
|
65
67
|
const BOOLEAN_FLAGS = new Set(["--json", "--repository-verify"]);
|
|
@@ -82,7 +84,7 @@ export async function run(argv) {
|
|
|
82
84
|
if (!Object.hasOwn(COMMANDS, command)) throw new CliError(`unknown command '${command}' (try --help)`);
|
|
83
85
|
const { positional, flags } = parse(command, rest);
|
|
84
86
|
const handler = HANDLERS[command];
|
|
85
|
-
if (["init", "status", "snapshot", "lock", "heartbeat", "effective-push"].includes(command)) return handler(positional, flags);
|
|
87
|
+
if (["init", "status", "snapshot", "lock", "heartbeat", "effective-push", "identity"].includes(command)) return handler(positional, flags);
|
|
86
88
|
const repo = resolve(flags.repo ?? process.cwd()), runId = positional[0];
|
|
87
89
|
if (command === "restore") {
|
|
88
90
|
const live = [join(repo, CONTROL_PLANE, runId), join(repo, ".factory-sandboxes", runId, CONTROL_PLANE, runId)]
|
|
@@ -799,11 +801,16 @@ const HANDLERS = {
|
|
|
799
801
|
if (positional.length !== 2) throw new CliError("factory grant-retry requires exactly <run-id> <slice-id>");
|
|
800
802
|
const [runId, sliceId] = positional;
|
|
801
803
|
if (!RETRY_EXTENSION_SCOPES.includes(flags.scope)) throw new CliError(`factory grant-retry requires --scope ${RETRY_EXTENSION_SCOPES.join("|")}`);
|
|
804
|
+
if (flags.maxRetries !== undefined && flags.scope !== "all") throw new CliError("factory grant-retry --max-retries requires --scope all");
|
|
802
805
|
if (typeof flags.reason !== "string" || !flags.reason.trim()) throw new CliError("factory grant-retry requires nonblank --reason <text>");
|
|
803
806
|
if (typeof flags.session !== "string" || !flags.session.trim()) throw new CliError("factory grant-retry requires nonblank --session <id>");
|
|
804
807
|
const runDir = runDirFor(flags, runId), repo = resolve(flags.repo ?? process.cwd());
|
|
805
808
|
const boundBytes = readFileSync(join(runDir, "run.json")), current = validateRun(JSON.parse(boundBytes.toString("utf8")));
|
|
806
809
|
if (current.status !== "needs-human") throw new CliError(`factory grant-retry requires current status needs-human; found '${current.status}'`);
|
|
810
|
+
// Instruction, not enforcement, on the size: a higher run-wide limit buys more reviewed attempts, never an
|
|
811
|
+
// unearned merge. Enforced only that it rises, since the grant must reopen the exhausted slice at N+1.
|
|
812
|
+
const grantedMax = flags.scope === "all" ? integer(flags.maxRetries, current.max_retries + 1, "--max-retries") : current.max_retries;
|
|
813
|
+
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
814
|
const owner = assertFreshSessionOwner(runDir, runId, flags.session, "grant-retry"), at = stamp(flags);
|
|
808
815
|
if (Date.parse(at) <= Date.parse(current.updated_at)) throw new CliError("grant-retry must move updated_at forwards");
|
|
809
816
|
const qualified = qualifyRetryGrant(repo, runDir, runId, current, sliceId);
|
|
@@ -864,7 +871,7 @@ const HANDLERS = {
|
|
|
864
871
|
const row = { ...existing, status: "running", attempts: existing.attempts + 1,
|
|
865
872
|
...(flags.scope === "slice" ? { extra_attempts: (existing.extra_attempts ?? 0) + 1 } : {}),
|
|
866
873
|
evidence_ref: null, review_ref: null };
|
|
867
|
-
const maxRetries =
|
|
874
|
+
const maxRetries = grantedMax, newLimit = maxRetries + (row.extra_attempts ?? 0);
|
|
868
875
|
const audit = { scope: flags.scope, slice_id: sliceId, base_ref: existing.base_ref, attempt: row.attempts, previous_limit: previousLimit,
|
|
869
876
|
new_limit: newLimit, previous_max_retries: state.max_retries, max_retries: maxRetries,
|
|
870
877
|
session: flags.session, reason: flags.reason.trim(), at, snapshot_digest: qualified.snapshotDigest,
|
|
@@ -1276,6 +1283,16 @@ const HANDLERS = {
|
|
|
1276
1283
|
throw new CliError("factory lock requires <claim|steal|release>");
|
|
1277
1284
|
},
|
|
1278
1285
|
|
|
1286
|
+
// Read-only: no lock, no state write. The driver parks with `reason` when it is non-null.
|
|
1287
|
+
async identity(positional, flags) {
|
|
1288
|
+
if (positional.length !== 1) throw new CliError("factory identity requires exactly <run-id>");
|
|
1289
|
+
const [runId] = positional, run = readRun(runDirFor(flags, runId)), declared = run.publishing_identity ?? null;
|
|
1290
|
+
if (declared === null) return emit(flags, { run_id: runId, publishing_identity: null, checked: false, reason: null });
|
|
1291
|
+
const observed = observeIdentity(resolve(flags.repo ?? process.cwd()));
|
|
1292
|
+
return emit(flags, { run_id: runId, publishing_identity: declared, checked: true, observable: observed !== null,
|
|
1293
|
+
reason: identityReason(declared, observed) });
|
|
1294
|
+
},
|
|
1295
|
+
|
|
1279
1296
|
async heartbeat([runId], flags) {
|
|
1280
1297
|
const runDir = runDirFor(flags, runId);
|
|
1281
1298
|
const owner = await refreshSessionLock(runDir, {
|
|
@@ -1722,7 +1739,9 @@ export async function dispatchInit(positional, flags, operations = INIT_OPERATIO
|
|
|
1722
1739
|
proveContainedBranch();
|
|
1723
1740
|
let run;
|
|
1724
1741
|
try {
|
|
1725
|
-
|
|
1742
|
+
// An explicit `--max-retries` outranks the committed `.factory.json` default, which outranks 3.
|
|
1743
|
+
const configuredRetries = flags.maxRetries === undefined && config?.maxRetries ? { max_retries: config.maxRetries } : {};
|
|
1744
|
+
run = validateRun({ ...candidate, ...configuredRetries, pr_base: prBase, pr_draft: config?.prDraft ?? true, ...bootstrapEvidence });
|
|
1726
1745
|
} catch (error) {
|
|
1727
1746
|
throw new CliError(`final manifest validation failed for sandbox '${S}'; sandbox was retained`, { cause: error });
|
|
1728
1747
|
}
|
|
@@ -1863,11 +1882,12 @@ function usage() {
|
|
|
1863
1882
|
factory init <run-id> [--branch B=feature/<run-id>] [--worktree W=.] [--pr-base TARGET] [--issue KEY] [--mode interactive|headless|autonomous]
|
|
1864
1883
|
factory status <run-id> [--json]
|
|
1865
1884
|
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]
|
|
1885
|
+
factory grant-retry <run-id> <slice-id> --scope slice|all [--max-retries N] --reason TEXT --session ID [--now ISO]
|
|
1867
1886
|
factory decide <run-id> --text TEXT --session ID [--now ISO]
|
|
1868
1887
|
factory resume <run-id> --session ID [--now ISO]
|
|
1869
1888
|
factory restore <run-id> --repo OPERATOR --from refs/remotes/REMOTE/BRANCH [--now ISO]
|
|
1870
1889
|
factory snapshot <run-id> --repo OPERATOR
|
|
1890
|
+
factory identity <run-id> [--repo PATH] [--json]
|
|
1871
1891
|
factory reverify-repair <run-id> <repair-record-id> [--repo PATH] [--now ISO] [--json]
|
|
1872
1892
|
factory lock <run-id> <claim|steal|release> --session ID [--ttl-ms N]
|
|
1873
1893
|
factory heartbeat <run-id> --session ID
|
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}`);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Enforcement (false green): publishing under an account other than the run's recorded identity. This
|
|
4
|
+
// was driver prose that required the host to return stdout, stderr and status separately; Prime's bash
|
|
5
|
+
// returns them combined, so a compliant Prime driver could never observe the identity (#365). The CLI
|
|
6
|
+
// owns the pipes now, so the verdict no longer depends on what the host's shell tool can report.
|
|
7
|
+
export const IDENTITY_ARGV = Object.freeze(["api", "--method", "GET", "/user", "--jq", ".login"]);
|
|
8
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u;
|
|
9
|
+
const SHORT = new Map([[8, "\\b"], [9, "\\t"], [10, "\\n"], [12, "\\f"], [13, "\\r"]]);
|
|
10
|
+
|
|
11
|
+
// Deterministic ASCII-only JSON string: printable ASCII literal except quote and backslash, the fixed
|
|
12
|
+
// short escapes, and every other UTF-16 code unit as lowercase \uXXXX. Slash stays unescaped.
|
|
13
|
+
export function asciiJson(value) {
|
|
14
|
+
let rendered = "\"";
|
|
15
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
16
|
+
const unit = value.charCodeAt(index);
|
|
17
|
+
if (SHORT.has(unit)) rendered += SHORT.get(unit);
|
|
18
|
+
else if (unit === 34 || unit === 92) rendered += `\\${value[index]}`;
|
|
19
|
+
else if (unit >= 0x20 && unit <= 0x7e) rendered += value[index];
|
|
20
|
+
else rendered += `\\u${unit.toString(16).padStart(4, "0")}`;
|
|
21
|
+
}
|
|
22
|
+
return `${rendered}"`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Observable only as numeric zero, zero-byte stderr, and exactly one login plus one LF. Nothing is
|
|
26
|
+
// trimmed, retried, or recovered from a partial value.
|
|
27
|
+
export function classifyIdentity({ status, stdout, stderr }) {
|
|
28
|
+
if (status !== 0 || !Buffer.isBuffer(stdout) || !Buffer.isBuffer(stderr) || stderr.length !== 0) return null;
|
|
29
|
+
if (stdout.length < 2 || stdout[stdout.length - 1] !== 10 || stdout.subarray(0, -1).some((byte) => byte > 0x7f)) return null;
|
|
30
|
+
const login = stdout.subarray(0, -1).toString("ascii");
|
|
31
|
+
return LOGIN.test(login) ? login : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function identityReason(declared, observed) {
|
|
35
|
+
const name = asciiJson(declared);
|
|
36
|
+
if (observed === null) return `publishing identity unobservable: declared ${name}; launch with inherited GH_TOKEN for ${name} as documented in OPERATING.md and retry.`;
|
|
37
|
+
return observed === declared ? null : `publishing identity mismatch: declared ${name}, observed ${asciiJson(observed)}; authenticate as ${name} and retry.`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A missing or empty GH_TOKEN never reaches gh, the network, or stored authentication.
|
|
41
|
+
export function observeIdentity(cwd, env = process.env, timeout = 60000) {
|
|
42
|
+
if (typeof env.GH_TOKEN !== "string" || env.GH_TOKEN.length === 0) return null;
|
|
43
|
+
return classifyIdentity(spawnSync("gh", IDENTITY_ARGV, { cwd, env, stdio: ["ignore", "pipe", "pipe"], timeout }));
|
|
44
|
+
}
|
|
@@ -21,7 +21,7 @@ export function parseRepositoryConfig(bytes) {
|
|
|
21
21
|
// not. It is optional now and consumed when present, so the key means what it says either way.
|
|
22
22
|
const requiredKeys = ["resolve", "verify"];
|
|
23
23
|
const optionalCommandKeys = ["publish"];
|
|
24
|
-
const allowedKeys = [...requiredKeys, ...optionalCommandKeys, "pr_draft", "verify_timeout_ms", "bootstrap", "bootstrap_timeout_ms"];
|
|
24
|
+
const allowedKeys = [...requiredKeys, ...optionalCommandKeys, "pr_draft", "verify_timeout_ms", "bootstrap", "bootstrap_timeout_ms", "max_retries"];
|
|
25
25
|
if (!config || typeof config !== "object" || Array.isArray(config)
|
|
26
26
|
|| Object.keys(config).some((keyName) => !allowedKeys.includes(keyName))) {
|
|
27
27
|
throw new RepositoryConfigError("invalid .factory.json");
|
|
@@ -53,8 +53,14 @@ export function parseRepositoryConfig(bytes) {
|
|
|
53
53
|
&& (typeof config[keyName] !== "string" || !config[keyName].trim()))) {
|
|
54
54
|
throw new RepositoryConfigError("invalid .factory.json: entry 'publish' must be a non-empty string");
|
|
55
55
|
}
|
|
56
|
+
// A project default for `init --max-retries`, so it need not ride on every invocation. Instruction, not
|
|
57
|
+
// enforcement: more attempts are more reviewed work, never an unearned merge. A present value is still
|
|
58
|
+
// checked, because a key that is silently ignored reads as a budget that took effect.
|
|
59
|
+
if (Object.hasOwn(config, "max_retries") && (!Number.isSafeInteger(config.max_retries) || config.max_retries <= 0)) {
|
|
60
|
+
throw new RepositoryConfigError("invalid .factory.json: entry 'max_retries' must be a positive integer");
|
|
61
|
+
}
|
|
56
62
|
const parsed = { command: config.verify, timeoutMs: config.verify_timeout_ms ?? DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS,
|
|
57
|
-
prDraft: config.pr_draft ?? true };
|
|
63
|
+
prDraft: config.pr_draft ?? true, ...(Object.hasOwn(config, "max_retries") ? { maxRetries: config.max_retries } : {}) };
|
|
58
64
|
return hasBootstrap ? { ...parsed, bootstrapCommand: config.bootstrap,
|
|
59
65
|
bootstrapTimeoutMs: config.bootstrap_timeout_ms ?? DEFAULT_BOOTSTRAP_TIMEOUT_MS } : parsed;
|
|
60
66
|
}
|
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
|
}
|