feature-factory 0.8.6 → 0.8.8
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/WORKFLOW.md +8 -2
- package/bin/factory.js +36 -7
- package/package.json +1 -1
- package/state/index.js +25 -13
package/WORKFLOW.md
CHANGED
|
@@ -177,7 +177,8 @@ reporting anything:
|
|
|
177
177
|
A park that completes only step 1 is an unreported park with no recovery evidence, which is the state
|
|
178
178
|
this sequence exists to prevent. Verify step 2 the way an outside observer would: qualified status
|
|
179
179
|
reports `park_snapshot` as the published path, or `null` when no snapshot exists. Status reports the path
|
|
180
|
-
only while the snapshot is a complete copy of the live plane by the step 3 inventory
|
|
180
|
+
only while the snapshot is a complete copy of the live plane by the step 3 inventory — `factory.lock`
|
|
181
|
+
excluded, since a heartbeat may land between the copy and the read — and its manifest still
|
|
181
182
|
matches the live one byte for byte, so `null` also covers an interrupted or altered copy and a snapshot
|
|
182
183
|
left by an earlier park: neither is evidence for this park. Publishing again is what makes it correspond.
|
|
183
184
|
|
|
@@ -243,7 +244,12 @@ and "clean up the prior copy" are contradictory instructions once that rename ha
|
|
|
243
244
|
outside `P`; do not copy slice worktrees or any other part of `S`.
|
|
244
245
|
3. **Verify.** Build source and destination inventories exactly as the completed archive does — every
|
|
245
246
|
entry's relative path, type and mode, a SHA-256 for each regular file, a link target for each symlink,
|
|
246
|
-
sorted lexically — and require exact equality
|
|
247
|
+
sorted lexically — and require exact equality, **excluding the plane-root `factory.lock` only**. That
|
|
248
|
+
one entry is session liveness rather than run state and is the only thing in the plane designed to
|
|
249
|
+
change on a timer, so comparing it fails whenever a heartbeat lands between reading the source and
|
|
250
|
+
reading the copy. The exclusion is that exact path and nothing else: a `factory.lock` anywhere below
|
|
251
|
+
the plane root is run state and must match. Qualified status excludes the same single path for the same
|
|
252
|
+
reason. An unverified staging tree is never published.
|
|
247
253
|
4. **Commit.** With no snapshot at the canonical path, rename `.staging-$R` onto it; that rename is the
|
|
248
254
|
commit point. With one present, first rename the canonical snapshot to `.prior-$R`, then rename
|
|
249
255
|
`.staging-$R` onto the canonical path; that second rename is the commit point. If the first rename
|
package/bin/factory.js
CHANGED
|
@@ -10,7 +10,7 @@ import { pathToFileURL } from "node:url";
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { isDeepStrictEqual } from "node:util";
|
|
12
12
|
import { readFileSync } from "node:fs";
|
|
13
|
-
import { nextAction, readRun, readRunUnchecked } from "../state/index.js";
|
|
13
|
+
import { nextAction, nextActionRecord, readRun, readRunUnchecked } from "../state/index.js";
|
|
14
14
|
import { transition } from "../state/transition.js";
|
|
15
15
|
import { buildEvidence, deriveReviewReady, EVIDENCE_KEYS, evidenceRef, git, observeAncestry, observeCleanliness, observeTrackedCleanliness, observeWorktree, privilegedPaths, proveInitContainment, resolveWorktree, runBootstrap, unownedPaths } from "../observe/index.js";
|
|
16
16
|
import { assertPublicationReady, assertReviewBinding, observeMergeProof, readEvidence, readReview, readValidatorReview } from "../observe/review.js";
|
|
@@ -23,7 +23,7 @@ import { resolveSpawnExecutable } from "../core/executable.js";
|
|
|
23
23
|
import { dispatchInitPublication } from "./init-publication.js";
|
|
24
24
|
import { CONTROL_PLANE, SCHEMA_VERSION, GATE_NAMES, GATE_STATUSES, MODES, SLICE_STATUSES, STEP_STATUSES, TERMINAL_STATUSES, repositoryRelativePath, validateRun } from "../state/schema.js";
|
|
25
25
|
import {
|
|
26
|
-
claimSessionLock, inspectSessionLock, refreshSessionLock, releaseSessionLock, SessionLockHeldError,
|
|
26
|
+
claimSessionLock, inspectSessionLock, refreshSessionLock, releaseSessionLock, SESSION_LOCK_FILE, SessionLockHeldError,
|
|
27
27
|
} from "../state/session-lock.js";
|
|
28
28
|
|
|
29
29
|
export const COMMANDS = Object.freeze({
|
|
@@ -175,6 +175,15 @@ function briefDigestFor(decision, state, runDir) {
|
|
|
175
175
|
function planeInventory(root) {
|
|
176
176
|
const entries = [];
|
|
177
177
|
const record = (rel, full) => {
|
|
178
|
+
// The session lock is liveness, not run state, and it is the one entry in the plane designed to change
|
|
179
|
+
// on a timer. Comparing it made every snapshot invalid within one heartbeat: a live park published a
|
|
180
|
+
// complete, byte-correct plane and `status` reported `park_snapshot: null` eleven seconds later,
|
|
181
|
+
// because the copy held `heartbeat_at` 23:28:25 and the plane had moved to 23:28:36. The whole point
|
|
182
|
+
// of the field was to answer "did the driver publish it", and it answered "no" for a snapshot that was
|
|
183
|
+
// there -- the same disagree-with-your-own-description defect the 0.8.3 work existed to remove,
|
|
184
|
+
// reintroduced by the check built to remove it. Every existing test publishes and reads back with no
|
|
185
|
+
// heartbeat in between, which is why three review rounds and a real park all missed it.
|
|
186
|
+
if (rel === SESSION_LOCK_FILE) return;
|
|
178
187
|
const stat = lstatSync(full);
|
|
179
188
|
const mode = (stat.mode & 0o7777).toString(8);
|
|
180
189
|
if (stat.isSymbolicLink()) entries.push(`${rel} l ${mode} ${readlinkSync(full)}`);
|
|
@@ -963,12 +972,31 @@ const HANDLERS = {
|
|
|
963
972
|
pr_draft: run.pr_draft ?? true,
|
|
964
973
|
lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
|
|
965
974
|
lock_session: lock.owner?.session ?? null,
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
975
|
+
// The whole gate record, not just its status. `at` and `artifact` were dropped here while sitting
|
|
976
|
+
// intact in `run.json` -- and `at` is what tells a controller whether "approved" happened a minute
|
|
977
|
+
// ago or three hours ago, which is most of what "is this run stuck" means.
|
|
978
|
+
gates: Object.fromEntries(GATE_NAMES.filter((name) => run.gates[name])
|
|
979
|
+
.map((name) => [name, { status: run.gates[name].status, at: run.gates[name].at ?? null, artifact: run.gates[name].artifact ?? null }])),
|
|
980
|
+
// Structured, not `${agent}:${status}(${attempts})`. Attempts are the field a controller reads to
|
|
981
|
+
// decide whether an attempt was consumed, and reaching them meant regexing a display string out of
|
|
982
|
+
// a JSON contract. Nothing in the suite asserted the string form, so it was a public shape with no
|
|
983
|
+
// coverage -- which is how a rendering artifact survived in a machine-readable payload. The content
|
|
984
|
+
// is unchanged; only the shape is. Breaking for anyone parsing the strings, which is safe here
|
|
985
|
+
// because an exact-match `FACTORY_VERSION` pin already forces consumers to move deliberately.
|
|
986
|
+
steps: run.steps.map((step) => ({ agent: step.agent, status: step.status, attempts: step.attempts })),
|
|
987
|
+
slices: run.slices.map((slice) => ({ id: slice.id, status: slice.status, attempts: slice.attempts })),
|
|
988
|
+
// Same narrowing: the record carries `report`, `reviewed_head` and `loops`, and only the verdict
|
|
989
|
+
// came out. `loops` says whether validation is converging; `reviewed_head` says what it judged.
|
|
990
|
+
validator: run.validator
|
|
991
|
+
? { verdict: run.validator.verdict, report: run.validator.report ?? null,
|
|
992
|
+
reviewed_head: run.validator.reviewed_head ?? null, loops: run.validator.loops ?? null }
|
|
993
|
+
: null,
|
|
970
994
|
pr_url: run.pr_url,
|
|
971
995
|
terminal_result: run.terminal_result,
|
|
996
|
+
// Both, deliberately. `next_action` is the machine answer; `next` is its rendering, derived from the
|
|
997
|
+
// same record by one formatter so they cannot drift, and kept because the driver contract, the
|
|
998
|
+
// sidebar and a lot of prose name `next: gate:story`.
|
|
999
|
+
next_action: nextActionRecord(run),
|
|
972
1000
|
next: nextAction(run),
|
|
973
1001
|
});
|
|
974
1002
|
},
|
|
@@ -1163,7 +1191,8 @@ const HANDLERS = {
|
|
|
1163
1191
|
});
|
|
1164
1192
|
if (outcome?.refusal) throw new CliError(`${outcome.refusal}; run remains needs-human and its historical terminal result is preserved`);
|
|
1165
1193
|
return emit(flags, {
|
|
1166
|
-
run_id: runId, status: next.status, terminal_result: next.terminal_result,
|
|
1194
|
+
run_id: runId, status: next.status, terminal_result: next.terminal_result,
|
|
1195
|
+
next_action: nextActionRecord(next), next: nextAction(next),
|
|
1167
1196
|
});
|
|
1168
1197
|
},
|
|
1169
1198
|
};
|
package/package.json
CHANGED
package/state/index.js
CHANGED
|
@@ -28,11 +28,11 @@ export function readRunUnchecked(runDir) {
|
|
|
28
28
|
|
|
29
29
|
function nextSliceAction(slices) {
|
|
30
30
|
const blockedSlice = slices.find((slice) => slice.status === "blocked");
|
|
31
|
-
if (blockedSlice) return
|
|
31
|
+
if (blockedSlice) return { kind: "blocked-slice", subject: blockedSlice.id };
|
|
32
32
|
const activeSlice = slices.find((slice) => ["running", "review"].includes(slice.status));
|
|
33
|
-
if (activeSlice) return
|
|
33
|
+
if (activeSlice) return { kind: "observe-slice", subject: activeSlice.id };
|
|
34
34
|
const pendingSlice = slices.find((slice) => slice.status === "pending");
|
|
35
|
-
return pendingSlice ?
|
|
35
|
+
return pendingSlice ? { kind: "dispatch-slice", subject: pendingSlice.id } : undefined;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// The single answer to "what happens next". Both `factory status` and the opencode
|
|
@@ -42,23 +42,35 @@ function nextSliceAction(slices) {
|
|
|
42
42
|
// Resume: the first thing a returning session needs to know. Preserves the inherited
|
|
43
43
|
// rules: a pending gate re-presents, a running slice re-observes, an
|
|
44
44
|
// unaccepted step re-runs.
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// `{kind, subject}`, because the answer is two facts and a consumer that must split a string on `:` to
|
|
46
|
+
// recover them is parsing a rendering. `subject` is null for the kinds that name no target.
|
|
47
|
+
export function nextActionRecord(run) {
|
|
48
|
+
if (["completed", "partial", "blocked"].includes(run.status)) return { kind: "terminal", subject: run.status };
|
|
47
49
|
const openStep = run.steps.find((step) => step.status !== "accepted");
|
|
50
|
+
const stepAction = openStep ? { kind: "step", subject: openStep.agent } : null;
|
|
48
51
|
const sliceAction = nextSliceAction(run.slices);
|
|
49
52
|
for (const name of GATE_NAMES) {
|
|
50
53
|
const gate = run.gates[name];
|
|
51
54
|
// `pending` waits on a human; absent means the phase has not been reached, which is
|
|
52
55
|
// still not "done". But naming an absent gate while an agent is mid-round reads as
|
|
53
56
|
// "waiting on you" — so existing slice or step work is named instead.
|
|
54
|
-
if (gate === undefined) return sliceAction ??
|
|
55
|
-
if (gate.status === "pending") return
|
|
56
|
-
if (gate.status === "stop") return
|
|
57
|
-
if (gate.status === "changes") return
|
|
58
|
-
if (name === "brief" && gate.status === "approved" && run.slices.length === 0) return "seed-slices";
|
|
57
|
+
if (gate === undefined) return sliceAction ?? stepAction ?? { kind: "gate", subject: name };
|
|
58
|
+
if (gate.status === "pending") return { kind: "gate", subject: name };
|
|
59
|
+
if (gate.status === "stop") return { kind: "stopped-at-gate", subject: name };
|
|
60
|
+
if (gate.status === "changes") return { kind: "changes-at-gate", subject: name };
|
|
61
|
+
if (name === "brief" && gate.status === "approved" && run.slices.length === 0) return { kind: "seed-slices", subject: null };
|
|
59
62
|
}
|
|
60
63
|
if (sliceAction) return sliceAction;
|
|
61
|
-
if (
|
|
62
|
-
if (!run.pr_url) return "pr";
|
|
63
|
-
return "complete";
|
|
64
|
+
if (stepAction) return stepAction;
|
|
65
|
+
if (!run.pr_url) return { kind: "pr", subject: null };
|
|
66
|
+
return { kind: "complete", subject: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The string form is DERIVED from the record rather than computed beside it, so the two cannot disagree.
|
|
70
|
+
// Retained because the driver contract, the sidebar and a great deal of prose all name `next: gate:story`,
|
|
71
|
+
// and because a human-facing label is a fair thing for a CLI to keep -- as long as it is a projection of
|
|
72
|
+
// the structured answer and not a second implementation of it.
|
|
73
|
+
export function nextAction(run) {
|
|
74
|
+
const { kind, subject } = nextActionRecord(run);
|
|
75
|
+
return subject === null ? kind : `${kind}:${subject}`;
|
|
64
76
|
}
|