@renzrollon/interlock 1.1.0 → 1.2.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +142 -0
- package/bin/interlock +413 -65
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +83 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +488 -28
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +153 -45
- package/lib/meter-quiet.mjs +45 -0
- package/lib/plan-fingerprint.mjs +83 -13
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1193 -85
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +285 -83
- package/package.json +2 -1
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/fix-tests/SKILL.md +1 -1
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
package/bin/interlock
CHANGED
|
@@ -14,13 +14,28 @@
|
|
|
14
14
|
// own — only the agents it spawns run commands. So a policy module that is not
|
|
15
15
|
// wired to a subcommand here is a policy the model will improvise in prose.
|
|
16
16
|
|
|
17
|
-
import { execFileSync } from 'node:child_process'
|
|
18
|
-
import {
|
|
19
|
-
|
|
17
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
18
|
+
import {
|
|
19
|
+
appendFileSync,
|
|
20
|
+
copyFileSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
mkdtempSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
realpathSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
writeFileSync
|
|
30
|
+
} from 'node:fs'
|
|
31
|
+
import { dirname, isAbsolute, join, relative, sep } from 'node:path'
|
|
32
|
+
import { constants as osConstants, cpus, tmpdir } from 'node:os'
|
|
20
33
|
import { performance } from 'node:perf_hooks'
|
|
21
34
|
import {
|
|
22
35
|
planWaves,
|
|
23
36
|
formatPlan,
|
|
37
|
+
laneLabel,
|
|
38
|
+
laneModel,
|
|
24
39
|
createRunState,
|
|
25
40
|
nextStep,
|
|
26
41
|
recordBatchResult,
|
|
@@ -31,8 +46,11 @@ import {
|
|
|
31
46
|
} from '../lib/waves.mjs'
|
|
32
47
|
import {
|
|
33
48
|
CLASSIFIED_PATH,
|
|
49
|
+
LAST_STEP_PATH,
|
|
50
|
+
VERIFY_TIMINGS_PATH,
|
|
34
51
|
checkSequence,
|
|
35
52
|
readManifest,
|
|
53
|
+
relayStep,
|
|
36
54
|
runClassified,
|
|
37
55
|
runClose,
|
|
38
56
|
runCommit,
|
|
@@ -43,9 +61,13 @@ import {
|
|
|
43
61
|
runReplan,
|
|
44
62
|
runReviewed,
|
|
45
63
|
runStart,
|
|
46
|
-
runVerifyFinal
|
|
64
|
+
runVerifyFinal,
|
|
65
|
+
writeManifest
|
|
47
66
|
} from '../lib/run.mjs'
|
|
48
67
|
import { haltStep } from '../lib/run.mjs'
|
|
68
|
+
import { explicitStateHome, resolveStateHome } from '../lib/state-home.mjs'
|
|
69
|
+
import { probeClaudeEffort, probeClaudeVersion } from '../lib/host/claude-cli.mjs'
|
|
70
|
+
import { observeClaudeEnv, runtimeSlotsOf } from '../lib/host/claude-env.mjs'
|
|
49
71
|
import { composeCheckpointMessage, postNtfy, readNotifyConfig, NOTIFY_TOPIC_ENV } from '../lib/notify.mjs'
|
|
50
72
|
import { classifySurface } from '../lib/surface.mjs'
|
|
51
73
|
import { evaluateGate, formatGate, TOLERANCE_BAND } from '../lib/findings.mjs'
|
|
@@ -170,6 +192,9 @@ Usage:
|
|
|
170
192
|
interlock run close [--halt <reason>] names, writes the results, calls
|
|
171
193
|
[--host-banners <f>]
|
|
172
194
|
[--host-observed <f>]
|
|
195
|
+
(record-batch, judge, reviewed, remediated, commit, replan and close also
|
|
196
|
+
take --host-records <f>: the runner's per-spawn host observations, beside
|
|
197
|
+
--results and never inside them)
|
|
173
198
|
\`then.argv\`, and repeats until
|
|
174
199
|
\`then\` is null. It decides nothing:
|
|
175
200
|
every branch the loop has (apply-only,
|
|
@@ -265,6 +290,8 @@ Usage:
|
|
|
265
290
|
interlock verify unit --result <file> Judge a unit result on its own
|
|
266
291
|
interlock verify spill --run-id <id> --kind <k> --input <f>
|
|
267
292
|
Spill oversized stdout/stderr to disk
|
|
293
|
+
interlock verify exec --kind <k> --command <cmd>
|
|
294
|
+
Run one planned check and time it
|
|
268
295
|
interlock verify cluster --failures <f> Group failures by root-cause signature
|
|
269
296
|
interlock verify repair --state <file> Decide repair / halt / accept next
|
|
270
297
|
interlock plan fingerprint [change] Fingerprint a change's planning inputs
|
|
@@ -286,6 +313,10 @@ Usage:
|
|
|
286
313
|
Common options:
|
|
287
314
|
--json Emit JSON instead of human-readable text
|
|
288
315
|
--root <dir> Repo root (default: cwd)
|
|
316
|
+
--state-home <dir> Where corpora (trajectories, outcomes, metrics, resume
|
|
317
|
+
cards, interrupted notes, autonomy ledger) are read and
|
|
318
|
+
written; default INTERLOCK_STATE_HOME, else the main
|
|
319
|
+
checkout when --root is a linked worktree, else --root
|
|
289
320
|
Any <file> argument accepts "-" to read that input from stdin. Only one input
|
|
290
321
|
per invocation may be "-", since stdin can only be drained once.
|
|
291
322
|
|
|
@@ -643,6 +674,17 @@ verify spill:
|
|
|
643
674
|
head/tail slice bounded by \`interlock limits\`' verify preview budget.
|
|
644
675
|
Always exits 0.
|
|
645
676
|
|
|
677
|
+
verify exec:
|
|
678
|
+
--kind <k> The planned step this runs, e.g. typecheck/unit/lint (required)
|
|
679
|
+
--command <cmd> The planned command, verbatim; run through the shell (required)
|
|
680
|
+
--cwd <dir> Directory to run it in (default: the current one)
|
|
681
|
+
Runs the command with its stdout, stderr and exit code passed through
|
|
682
|
+
unchanged, and appends { kind, exitCode, durationMs, command, at } to
|
|
683
|
+
.claude/ship/verify-timings.jsonl. \`run judge\` folds those durations into
|
|
684
|
+
the inter-wave verify budget, so the budget counts the checks themselves
|
|
685
|
+
rather than the relay and agent time around them. A failed append is a
|
|
686
|
+
stderr warning; the exit code is always the command's.
|
|
687
|
+
|
|
646
688
|
plan:
|
|
647
689
|
fingerprint [change] Hash the inputs a plan for this change is derived from:
|
|
648
690
|
proposal.md, design.md, tasks.md (with checkbox markers
|
|
@@ -826,6 +868,51 @@ function readStdin() {
|
|
|
826
868
|
}
|
|
827
869
|
}
|
|
828
870
|
|
|
871
|
+
/**
|
|
872
|
+
* The explicit state home, if one was given: `--state-home <dir>`, else
|
|
873
|
+
* `INTERLOCK_STATE_HOME` (ship/state-home). `--root` keeps naming the working
|
|
874
|
+
* tree; this names where its corpora go.
|
|
875
|
+
*/
|
|
876
|
+
function explicitHome(flags) {
|
|
877
|
+
return explicitStateHome({
|
|
878
|
+
flag: typeof flags['state-home'] === 'string' ? flags['state-home'] : undefined,
|
|
879
|
+
env: process.env
|
|
880
|
+
})
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* The state home a run starts with: the explicit value, else the resolver. Never
|
|
885
|
+
* a manifest already at the root — that is the PREVIOUS run's.
|
|
886
|
+
*/
|
|
887
|
+
function startStateHome(root, flags) {
|
|
888
|
+
return resolveStateHome(root, { explicit: explicitHome(flags) || undefined, env: {} })
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* The state home a corpus command reads and writes (design D14): the home the
|
|
893
|
+
* live run at this root recorded, so a lane's `wave-state` append or a report
|
|
894
|
+
* run mid-run never disagrees with the run's own; else the explicit value; else
|
|
895
|
+
* the resolver. Resolved once per invocation and handed to every corpus reader
|
|
896
|
+
* and writer the command calls.
|
|
897
|
+
*
|
|
898
|
+
* @returns {{home: string, surface: string, reason: string|null, resolvedFrom: string}}
|
|
899
|
+
*/
|
|
900
|
+
function corpusHome(root, flags) {
|
|
901
|
+
const explicit = explicitHome(flags)
|
|
902
|
+
if (!explicit) {
|
|
903
|
+
const manifest = readManifest(root)
|
|
904
|
+
if (manifest && typeof manifest.stateHome === 'string' && manifest.stateHome) {
|
|
905
|
+
return {
|
|
906
|
+
home: manifest.stateHome,
|
|
907
|
+
surface: typeof manifest.surface === 'string' ? manifest.surface : 'unknown',
|
|
908
|
+
reason: typeof manifest.stateHomeReason === 'string' ? manifest.stateHomeReason : null,
|
|
909
|
+
resolvedFrom: typeof manifest.stateHomeFrom === 'string' ? manifest.stateHomeFrom : 'git'
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return resolveStateHome(root, { explicit: explicit || undefined, env: {} })
|
|
914
|
+
}
|
|
915
|
+
|
|
829
916
|
function readInput(value, what) {
|
|
830
917
|
if (!value || value === true) die(`${what} is required`)
|
|
831
918
|
const raw = value === '-' ? readStdin() : readFileSync(value, 'utf8')
|
|
@@ -875,6 +962,51 @@ function emit(json, obj, text) {
|
|
|
875
962
|
process.stdout.write(json ? JSON.stringify(obj, null, 2) + '\n' : text)
|
|
876
963
|
}
|
|
877
964
|
|
|
965
|
+
/**
|
|
966
|
+
* Print a `run` step, shaped for the host that will read it, and keep a copy of
|
|
967
|
+
* the exact bytes printed.
|
|
968
|
+
*
|
|
969
|
+
* `relay: true` routes the step through `relayStep`, which slims it for the
|
|
970
|
+
* Workflow host — the one host whose stdout is retyped by a model rather than
|
|
971
|
+
* read by a process. The close does not come through here at all: its summary
|
|
972
|
+
* is what it is for, and it must not overwrite the copy a halt points at.
|
|
973
|
+
*
|
|
974
|
+
* The copy at LAST_STEP_PATH is forensic, not a corpus: when a relay's copy
|
|
975
|
+
* does not parse, the halt points here instead of at a transcript. A failed
|
|
976
|
+
* write is spoken on stderr and never touches the exit code — losing the copy
|
|
977
|
+
* must not lose the step.
|
|
978
|
+
*/
|
|
979
|
+
/**
|
|
980
|
+
* The machine's CPU count, or null when the platform will not say. The vendor
|
|
981
|
+
* reduces the Workflow runtime's concurrency on small machines by an amount it
|
|
982
|
+
* does not publish, so this is recorded beside the default and never turned
|
|
983
|
+
* into a number of its own.
|
|
984
|
+
*/
|
|
985
|
+
function cpuCount() {
|
|
986
|
+
try {
|
|
987
|
+
const n = cpus().length
|
|
988
|
+
return Number.isInteger(n) && n > 0 ? n : null
|
|
989
|
+
} catch {
|
|
990
|
+
return null
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function emitRunStep(json, root, step, text, { relay = false } = {}) {
|
|
995
|
+
if (!json) {
|
|
996
|
+
process.stdout.write(text)
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
const shaped = relay ? relayStep(readManifest(root), step) : step
|
|
1000
|
+
const out = JSON.stringify(shaped, null, 2) + '\n'
|
|
1001
|
+
try {
|
|
1002
|
+
mkdirSync(join(root, dirname(LAST_STEP_PATH)), { recursive: true })
|
|
1003
|
+
writeFileSync(join(root, LAST_STEP_PATH), out)
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
process.stderr.write(`interlock: ${LAST_STEP_PATH} not written: ${(err && err.message) || err}\n`)
|
|
1006
|
+
}
|
|
1007
|
+
process.stdout.write(out)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
878
1010
|
/**
|
|
879
1011
|
* record-* / replan: default stdout is the new state. --write-state writes that
|
|
880
1012
|
* state to a file and stdout becomes nextStep() — the shape `wave-state next`
|
|
@@ -932,13 +1064,18 @@ function changeFor(state, flagChange) {
|
|
|
932
1064
|
return changeOf(state) || flag
|
|
933
1065
|
}
|
|
934
1066
|
|
|
935
|
-
// `wave-state next` / `--write-state` both surface
|
|
936
|
-
// step. A `run-batch`/`test-wave` step carries
|
|
937
|
-
// LANES) and `remainingBatches` (rest of the
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
// record-batch --write-state
|
|
941
|
-
// events.
|
|
1067
|
+
// The `wave-state` protocol: `wave-state next` / `--write-state` both surface
|
|
1068
|
+
// the state machine's next step. A `run-batch`/`test-wave` step carries
|
|
1069
|
+
// `tasks[]` (the current batch, as LANES) and `remainingBatches` (rest of the
|
|
1070
|
+
// wave, as batches of lanes). A caller of that protocol runs remainingBatches
|
|
1071
|
+
// sequentially before its next record call, so the reconstructable spawn list
|
|
1072
|
+
// is remainingBatches — not only tasks[]. Mid-wave record-batch --write-state
|
|
1073
|
+
// is cursor catch-up and MUST NOT duplicate those events.
|
|
1074
|
+
//
|
|
1075
|
+
// The `run` program is NOT that protocol: it emits one step per batch, so it
|
|
1076
|
+
// passes the batch's own `lanes` and gets exactly those logged (see
|
|
1077
|
+
// `logLaneSpawns` in lib/run.mjs). Logging a whole wave there wrote spawn events
|
|
1078
|
+
// for lanes a halted run never dispatched.
|
|
942
1079
|
//
|
|
943
1080
|
// One spawn per LANE, because a lane is one agent. Counting per task would
|
|
944
1081
|
// report four spawns for a wave that ran two, which is the fold this change
|
|
@@ -955,36 +1092,30 @@ function spawnLanesOf(step) {
|
|
|
955
1092
|
.filter(lane => lane.length)
|
|
956
1093
|
}
|
|
957
1094
|
|
|
958
|
-
// The label the workflow gives the same agent (`workflows/ship.js` laneLabel), so
|
|
959
|
-
// a trajectory line and the agent it describes carry one name rather than two.
|
|
960
|
-
function laneLabelOf(lane) {
|
|
961
|
-
return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// The model the lane runs on: its hardest task's. A lane is one agent and that
|
|
965
|
-
// agent has to be capable of everything in it.
|
|
966
|
-
function laneModelOf(lane) {
|
|
967
|
-
const tier = lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
|
|
968
|
-
const hardest = lane.find(t => t.tier === tier) || lane[0]
|
|
969
|
-
return hardest && typeof hardest.model === 'string' ? hardest.model : undefined
|
|
970
|
-
}
|
|
971
|
-
|
|
972
1095
|
// Takes the run's `state` rather than a runId, so the change name is resolved
|
|
973
1096
|
// from it the same way `logWaveMutation` does instead of being threaded in by a
|
|
974
1097
|
// caller who may not have been given one.
|
|
975
|
-
function logAgentSpawns(root, { state, change, step, source }) {
|
|
976
|
-
|
|
977
|
-
if (
|
|
1098
|
+
function logAgentSpawns(root, { state, change, step, source, lanes }) {
|
|
1099
|
+
const explicit = Array.isArray(lanes)
|
|
1100
|
+
if (!explicit) {
|
|
1101
|
+
if (!step || (step.action !== 'run-batch' && step.action !== 'test-wave')) return true
|
|
1102
|
+
if (source === 'record-batch' && Number(step.batchIndex) > 0) return true
|
|
1103
|
+
}
|
|
978
1104
|
const runId = runIdOf(state)
|
|
979
1105
|
const changeName = changeFor(state, change)
|
|
1106
|
+
const dispatched = explicit
|
|
1107
|
+
? lanes
|
|
1108
|
+
.map(lane => (Array.isArray(lane) ? lane.filter(t => t && typeof t === 'object') : []))
|
|
1109
|
+
.filter(lane => lane.length)
|
|
1110
|
+
: spawnLanesOf(step)
|
|
980
1111
|
let ok = true
|
|
981
|
-
for (const lane of
|
|
1112
|
+
for (const lane of dispatched) {
|
|
982
1113
|
const spawn = appendRunLogEvent(root, {
|
|
983
1114
|
runId,
|
|
984
1115
|
change: changeName,
|
|
985
1116
|
type: 'agent-spawn',
|
|
986
|
-
label:
|
|
987
|
-
model:
|
|
1117
|
+
label: laneLabel(lane),
|
|
1118
|
+
model: laneModel(lane),
|
|
988
1119
|
kind: 'implementer',
|
|
989
1120
|
// The lane's anchor task. A multi-task lane's later ids are recoverable
|
|
990
1121
|
// from the plan and from its per-task record-batch results; what this line
|
|
@@ -1217,6 +1348,127 @@ function observedChangedPaths(root) {
|
|
|
1217
1348
|
return paths
|
|
1218
1349
|
}
|
|
1219
1350
|
|
|
1351
|
+
// The identity a shared-tree snapshot is committed under. It is nobody's: it is
|
|
1352
|
+
// set in the environment of the one `commit-tree` call and never in any config,
|
|
1353
|
+
// because `commit-tree` refuses to write without an identity, a CI checkout
|
|
1354
|
+
// often has none, and the user's name does not belong on an object they did not
|
|
1355
|
+
// author.
|
|
1356
|
+
const SNAPSHOT_IDENTITY = {
|
|
1357
|
+
GIT_AUTHOR_NAME: 'Interlock',
|
|
1358
|
+
GIT_AUTHOR_EMAIL: 'interlock@localhost',
|
|
1359
|
+
GIT_COMMITTER_NAME: 'Interlock',
|
|
1360
|
+
GIT_COMMITTER_EMAIL: 'interlock@localhost'
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Every linked worktree of the repository that lies inside `root`, as a
|
|
1365
|
+
* root-relative path with forward slashes. Read from git at capture time rather
|
|
1366
|
+
* than kept as a list, so a host that moves where it puts its worktrees is
|
|
1367
|
+
* still covered. Both sides are resolved through `realpathSync` because git
|
|
1368
|
+
* reports real paths and `root` may arrive through a symlink (macOS's `/tmp`).
|
|
1369
|
+
*/
|
|
1370
|
+
function linkedWorktreesInside(root, porcelain) {
|
|
1371
|
+
const top = realpathSync(root)
|
|
1372
|
+
const inside = []
|
|
1373
|
+
for (const line of porcelain.split('\n')) {
|
|
1374
|
+
if (!line.startsWith('worktree ')) continue
|
|
1375
|
+
let path = line.slice('worktree '.length)
|
|
1376
|
+
try {
|
|
1377
|
+
path = realpathSync(path)
|
|
1378
|
+
} catch {
|
|
1379
|
+
// A worktree whose directory is gone is compared as git listed it.
|
|
1380
|
+
}
|
|
1381
|
+
const rel = relative(top, path)
|
|
1382
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue
|
|
1383
|
+
inside.push(rel.split(sep).join('/'))
|
|
1384
|
+
}
|
|
1385
|
+
return inside
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* The commit an isolated batch's lanes fork from: the shared tree as it stands,
|
|
1390
|
+
* with every tracked edit, every deletion and every untracked file the ignore
|
|
1391
|
+
* rules let through — so it holds every earlier fold of the run, which HEAD
|
|
1392
|
+
* never does (the ship commit is the run's last step).
|
|
1393
|
+
*
|
|
1394
|
+
* Built through a temporary index and plumbing only. No ref moves, the
|
|
1395
|
+
* repository's own index is never opened for writing, and no working-tree file
|
|
1396
|
+
* changes. The commit is pinned by nothing: the lane worktrees forked from it
|
|
1397
|
+
* keep it reachable until the fold, and git's own expiry takes it after.
|
|
1398
|
+
*
|
|
1399
|
+
* - The index is seeded from HEAD, so a tracked-but-ignored file keeps its
|
|
1400
|
+
* committed content instead of dropping out of the tree.
|
|
1401
|
+
* - Interlock's run state and every linked worktree inside the root are left
|
|
1402
|
+
* out. Staged, a worktree becomes an embedded-repository entry; a lane's
|
|
1403
|
+
* checkout holds an empty directory there; and the fold reads that as a
|
|
1404
|
+
* deletion of a sibling lane's worktree.
|
|
1405
|
+
* - A tree identical to HEAD's returns HEAD itself and creates no object.
|
|
1406
|
+
*
|
|
1407
|
+
* @param {string} root
|
|
1408
|
+
* @param {string} [change] named in the snapshot's message, for whoever finds it
|
|
1409
|
+
* @returns {string|null} null on any failure — `decorate` halts on it rather
|
|
1410
|
+
* than forking lanes from a base nobody captured
|
|
1411
|
+
*/
|
|
1412
|
+
function snapshotTree(root, change) {
|
|
1413
|
+
let dir = null
|
|
1414
|
+
try {
|
|
1415
|
+
dir = mkdtempSync(join(tmpdir(), 'interlock-snapshot-'))
|
|
1416
|
+
const env = { ...process.env, GIT_INDEX_FILE: join(dir, 'index') }
|
|
1417
|
+
const git = (args, extraEnv = {}) =>
|
|
1418
|
+
execFileSync('git', args, {
|
|
1419
|
+
cwd: root,
|
|
1420
|
+
encoding: 'utf8',
|
|
1421
|
+
env: { ...env, ...extraEnv },
|
|
1422
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1423
|
+
}).trim()
|
|
1424
|
+
|
|
1425
|
+
git(['read-tree', 'HEAD'])
|
|
1426
|
+
const worktrees = linkedWorktreesInside(root, git(['worktree', 'list', '--porcelain']))
|
|
1427
|
+
git([
|
|
1428
|
+
'add',
|
|
1429
|
+
'-A',
|
|
1430
|
+
'--',
|
|
1431
|
+
'.',
|
|
1432
|
+
':(exclude).claude/ship',
|
|
1433
|
+
...worktrees.map(rel => `:(exclude,literal)${rel}`)
|
|
1434
|
+
])
|
|
1435
|
+
const tree = git(['write-tree'])
|
|
1436
|
+
const head = git(['rev-parse', 'HEAD'])
|
|
1437
|
+
if (tree === git(['rev-parse', 'HEAD^{tree}'])) return head || null
|
|
1438
|
+
// --no-gpg-sign: `commit.gpgSign` would otherwise ask a signing agent to
|
|
1439
|
+
// sign an object nobody will ever push.
|
|
1440
|
+
const message = `interlock: shared-tree snapshot (${change || 'unknown change'})`
|
|
1441
|
+
return git(['commit-tree', '--no-gpg-sign', tree, '-p', head, '-m', message], SNAPSHOT_IDENTITY) || null
|
|
1442
|
+
} catch {
|
|
1443
|
+
return null
|
|
1444
|
+
} finally {
|
|
1445
|
+
if (dir) rmSync(dir, { recursive: true, force: true })
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* The commit a lane worktree is checked out at, as git reports it from inside
|
|
1451
|
+
* the worktree — never the base the lane's agent says it forked from, because
|
|
1452
|
+
* that report is the thing being audited. `null` when git cannot answer,
|
|
1453
|
+
* including when the directory is gone.
|
|
1454
|
+
*
|
|
1455
|
+
* @param {string} worktreePath
|
|
1456
|
+
* @returns {string|null}
|
|
1457
|
+
*/
|
|
1458
|
+
function worktreeHead(worktreePath) {
|
|
1459
|
+
if (typeof worktreePath !== 'string' || !worktreePath || !existsSync(worktreePath)) return null
|
|
1460
|
+
try {
|
|
1461
|
+
return (
|
|
1462
|
+
execFileSync('git', ['-C', worktreePath, 'rev-parse', 'HEAD'], {
|
|
1463
|
+
encoding: 'utf8',
|
|
1464
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1465
|
+
}).trim() || null
|
|
1466
|
+
)
|
|
1467
|
+
} catch {
|
|
1468
|
+
return null
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1220
1472
|
/**
|
|
1221
1473
|
* A lane worktree's actual writes relative to the shared base commit, as git
|
|
1222
1474
|
* sees them — never a prediction. `null` means git could not answer (bad
|
|
@@ -1594,7 +1846,7 @@ async function main() {
|
|
|
1594
1846
|
if (flags.metrics !== undefined) {
|
|
1595
1847
|
if (flags.metrics === true) die('--metrics requires a change name')
|
|
1596
1848
|
const surviving = result.total + result.malformed.length
|
|
1597
|
-
metrics = writeReviewMetrics(root, {
|
|
1849
|
+
metrics = writeReviewMetrics(corpusHome(root, flags).home, {
|
|
1598
1850
|
change: String(flags.metrics),
|
|
1599
1851
|
counts: {
|
|
1600
1852
|
raised: result.dismissedCount + result.droppedByQuality + surviving,
|
|
@@ -1647,6 +1899,7 @@ async function main() {
|
|
|
1647
1899
|
// would encode. Every value here is one `if` away from a threshold; the
|
|
1648
1900
|
// absence of that `if` is the feature. See the header of lib/report.mjs.
|
|
1649
1901
|
const report = buildReport(root, {
|
|
1902
|
+
stateHome: corpusHome(root, flags),
|
|
1650
1903
|
since: typeof flags.since === 'string' ? flags.since : undefined,
|
|
1651
1904
|
change: typeof flags.change === 'string' ? flags.change : undefined,
|
|
1652
1905
|
maxRuns: num(flags, 'max-runs')
|
|
@@ -1710,23 +1963,27 @@ async function main() {
|
|
|
1710
1963
|
// `limits`: LIMITS holds positive-integer loop counts (its invariant test
|
|
1711
1964
|
// asserts it), whereas the lane-cap table is a tier→cap map and the solo
|
|
1712
1965
|
// envelope is a separate object — the same reason `effort` is emitted apart.
|
|
1966
|
+
// The slot count this machine's runtime will honour, as far as this
|
|
1967
|
+
// process can see it — beside the vendor fact, never instead of it.
|
|
1968
|
+
const runtimeObserved = runtimeSlotsOf(observeClaudeEnv(process.env, { cpuCount: cpuCount() }))
|
|
1713
1969
|
emit(
|
|
1714
1970
|
json,
|
|
1715
1971
|
{
|
|
1716
1972
|
limits: LIMITS,
|
|
1717
|
-
runtime: RUNTIME,
|
|
1973
|
+
runtime: { ...RUNTIME, observed: runtimeObserved },
|
|
1718
1974
|
evals: EVAL_CAPS,
|
|
1719
1975
|
// Its own group, beside `evals` rather than inside it: the eval caps
|
|
1720
1976
|
// are scalars a reader compares a running total against, and this is
|
|
1721
1977
|
// the table that produced the total. Its `id` is what a recorded
|
|
1722
|
-
// result row is keyed against.
|
|
1978
|
+
// result row is keyed against. Emitted whole, so each model's own
|
|
1979
|
+
// `cacheMultipliers.read` entry and both write tiers travel with it.
|
|
1723
1980
|
prices: MODEL_PRICES,
|
|
1724
1981
|
report: REPORT_CAPS,
|
|
1725
1982
|
effort: EFFORT,
|
|
1726
1983
|
laneCaps: LANE_CAPS,
|
|
1727
1984
|
solo: SOLO
|
|
1728
1985
|
},
|
|
1729
|
-
formatLimits()
|
|
1986
|
+
formatLimits({ runtimeObserved })
|
|
1730
1987
|
)
|
|
1731
1988
|
break
|
|
1732
1989
|
}
|
|
@@ -1791,7 +2048,7 @@ async function main() {
|
|
|
1791
2048
|
// Read-only by construction — see the header of lib/doctor.mjs. The exit
|
|
1792
2049
|
// code is the whole point: a preflight that only prints is a preflight a
|
|
1793
2050
|
// zero-touch run walks straight past.
|
|
1794
|
-
const report = diagnose(root)
|
|
2051
|
+
const report = diagnose(root, { cpuCount: cpuCount(), stateHome: explicitHome(flags) || undefined })
|
|
1795
2052
|
emit(json, report, formatDoctor(report))
|
|
1796
2053
|
if (!report.ok) process.exit(1)
|
|
1797
2054
|
break
|
|
@@ -1810,6 +2067,7 @@ async function main() {
|
|
|
1810
2067
|
const outDir = typeof flags.out === 'string' ? flags.out : ''
|
|
1811
2068
|
if (!outDir) die('evals capture requires --out <dir>')
|
|
1812
2069
|
const result = captureCase(root, {
|
|
2070
|
+
stateHome: corpusHome(root, flags).home,
|
|
1813
2071
|
runId,
|
|
1814
2072
|
taskId: typeof flags.task === 'string' ? flags.task : undefined,
|
|
1815
2073
|
outDir
|
|
@@ -1923,7 +2181,7 @@ async function main() {
|
|
|
1923
2181
|
let metrics = null
|
|
1924
2182
|
if (flags.metrics !== undefined) {
|
|
1925
2183
|
if (flags.metrics === true) die('--metrics requires a change name')
|
|
1926
|
-
metrics = writeReviewMetrics(root, { change: String(flags.metrics), counts: result.counts })
|
|
2184
|
+
metrics = writeReviewMetrics(corpusHome(root, flags).home, { change: String(flags.metrics), counts: result.counts })
|
|
1927
2185
|
if (!metrics.written) warn(`metrics not written: ${metrics.reason}`)
|
|
1928
2186
|
}
|
|
1929
2187
|
|
|
@@ -2041,10 +2299,11 @@ async function main() {
|
|
|
2041
2299
|
// trusting the untrusted source exactly when the trusted one is
|
|
2042
2300
|
// missing would defeat the partition on the runs most likely to need
|
|
2043
2301
|
// it.
|
|
2302
|
+
const home = corpusHome(root, flags).home
|
|
2044
2303
|
const receipt = runId
|
|
2045
|
-
? queryRunLog(
|
|
2304
|
+
? queryRunLog(home, runId, { type: 'run-receipt' }).records.pop() || null
|
|
2046
2305
|
: null
|
|
2047
|
-
const written = appendOutcome(
|
|
2306
|
+
const written = appendOutcome(home, { ...reportedHalf, observed: observedFromReceipt(receipt) })
|
|
2048
2307
|
if (carriedObserved) {
|
|
2049
2308
|
warn(
|
|
2050
2309
|
'outcome record carried an "observed" group: refused. Observed values are derived ' +
|
|
@@ -2066,7 +2325,7 @@ async function main() {
|
|
|
2066
2325
|
`${written.written ? `recorded → ${written.path}` : `not recorded: ${written.reason}`}\n`
|
|
2067
2326
|
)
|
|
2068
2327
|
} else if (sub === 'list' || sub === undefined) {
|
|
2069
|
-
const result = readOutcomes(root)
|
|
2328
|
+
const result = readOutcomes(corpusHome(root, flags).home)
|
|
2070
2329
|
emit(json, result, formatOutcomes(result))
|
|
2071
2330
|
} else {
|
|
2072
2331
|
die(`unknown outcomes subcommand: ${sub}`)
|
|
@@ -2076,9 +2335,10 @@ async function main() {
|
|
|
2076
2335
|
|
|
2077
2336
|
case 'run-log': {
|
|
2078
2337
|
const sub = positional[1]
|
|
2338
|
+
const logHome = corpusHome(root, flags).home
|
|
2079
2339
|
if (sub === 'append') {
|
|
2080
2340
|
const record = readInput(flags.event, '--event <file|->')
|
|
2081
|
-
const { written, path, reason } = appendRunLogEvent(
|
|
2341
|
+
const { written, path, reason } = appendRunLogEvent(logHome, record)
|
|
2082
2342
|
if (!written) {
|
|
2083
2343
|
// Never fatal: losing a trajectory line must not fail the run that
|
|
2084
2344
|
// produced it. Say so on stderr and exit 0.
|
|
@@ -2087,17 +2347,17 @@ async function main() {
|
|
|
2087
2347
|
emit(json, { written, path, reason }, `${written ? `recorded → ${path}` : `not recorded: ${reason}`}\n`)
|
|
2088
2348
|
} else if (sub === 'list') {
|
|
2089
2349
|
const change = typeof flags.change === 'string' ? flags.change : undefined
|
|
2090
|
-
const runs = listRunLogs(
|
|
2350
|
+
const runs = listRunLogs(logHome, { change })
|
|
2091
2351
|
emit(json, { runs }, formatRunLogList(runs))
|
|
2092
2352
|
} else if (sub === 'show') {
|
|
2093
2353
|
const runId = positional[2]
|
|
2094
2354
|
if (!runId) die('run-log show requires a runId')
|
|
2095
|
-
const result = readRunLog(
|
|
2355
|
+
const result = readRunLog(logHome, runId)
|
|
2096
2356
|
emit(json, result, formatRunLog(result))
|
|
2097
2357
|
} else if (sub === 'query') {
|
|
2098
2358
|
const runId = typeof flags.run === 'string' ? flags.run : undefined
|
|
2099
2359
|
if (!runId) die('--run is required')
|
|
2100
|
-
const result = queryRunLog(
|
|
2360
|
+
const result = queryRunLog(logHome, runId, {
|
|
2101
2361
|
type: typeof flags.type === 'string' ? flags.type : undefined,
|
|
2102
2362
|
halted: flags.halted === true
|
|
2103
2363
|
})
|
|
@@ -2105,7 +2365,7 @@ async function main() {
|
|
|
2105
2365
|
} else if (sub === 'check') {
|
|
2106
2366
|
const runId = runIdFromFlags(flags)
|
|
2107
2367
|
if (!runId) die('run-log check requires --state <file> (with a runId) or --run-id <id>')
|
|
2108
|
-
const result = checkRunLog(
|
|
2368
|
+
const result = checkRunLog(logHome, runId)
|
|
2109
2369
|
emit(json, result, formatRunLogCheck(result))
|
|
2110
2370
|
if (!result.ok) process.exit(1)
|
|
2111
2371
|
} else {
|
|
@@ -2217,7 +2477,7 @@ async function main() {
|
|
|
2217
2477
|
}
|
|
2218
2478
|
const opts = { context, ...verifyJudgeOpts(flags) }
|
|
2219
2479
|
const verdict = judgeVerification(plan, results, opts)
|
|
2220
|
-
const logOk = logVerifyJudgement(root, {
|
|
2480
|
+
const logOk = logVerifyJudgement(corpusHome(root, flags).home, {
|
|
2221
2481
|
runId: runIdFromFlags(flags),
|
|
2222
2482
|
// Same precedence as every wave-state append site: the state names the
|
|
2223
2483
|
// run, the flag is only the pre-state-name fallback.
|
|
@@ -2249,6 +2509,42 @@ async function main() {
|
|
|
2249
2509
|
result,
|
|
2250
2510
|
`spilled ${result.bytes} byte(s) → ${result.locator}${result.truncated ? ' (truncated preview)' : ''}\n`
|
|
2251
2511
|
)
|
|
2512
|
+
} else if (sub === 'exec') {
|
|
2513
|
+
const kind = typeof flags.kind === 'string' ? flags.kind.trim() : ''
|
|
2514
|
+
if (!kind) die('--kind is required')
|
|
2515
|
+
const command = typeof flags.command === 'string' ? flags.command : ''
|
|
2516
|
+
if (!command.trim()) die('--command is required')
|
|
2517
|
+
const cwd = typeof flags.cwd === 'string' && flags.cwd.trim() ? flags.cwd : undefined
|
|
2518
|
+
// Timed here, around the command and nothing else: the inter-wave budget
|
|
2519
|
+
// used to be clocked from the step's dispatch to its judgement, which
|
|
2520
|
+
// charged two relays and the verify agent's own turns to "verification"
|
|
2521
|
+
// and spent the whole budget on the first checkpoint of every Workflow run.
|
|
2522
|
+
const started = performance.now()
|
|
2523
|
+
const ran = spawnSync(command, { shell: true, stdio: 'inherit', cwd })
|
|
2524
|
+
const durationMs = Math.round(performance.now() - started)
|
|
2525
|
+
let exitCode
|
|
2526
|
+
if (ran.error) {
|
|
2527
|
+
process.stderr.write(`interlock: verify exec could not run the command: ${ran.error.message}\n`)
|
|
2528
|
+
exitCode = 127
|
|
2529
|
+
} else if (typeof ran.status === 'number') {
|
|
2530
|
+
exitCode = ran.status
|
|
2531
|
+
} else {
|
|
2532
|
+
const signal = ran.signal && osConstants.signals[ran.signal]
|
|
2533
|
+
exitCode = Number.isInteger(signal) ? 128 + signal : 1
|
|
2534
|
+
}
|
|
2535
|
+
try {
|
|
2536
|
+
mkdirSync(join(root, dirname(VERIFY_TIMINGS_PATH)), { recursive: true })
|
|
2537
|
+
appendFileSync(
|
|
2538
|
+
join(root, VERIFY_TIMINGS_PATH),
|
|
2539
|
+
JSON.stringify({ kind, exitCode, durationMs, command, at: new Date().toISOString() }) + '\n'
|
|
2540
|
+
)
|
|
2541
|
+
} catch (err) {
|
|
2542
|
+
// A timing is a budget input, not a gate: losing it costs the budget
|
|
2543
|
+
// its precision (the judge falls back to the round trip, and says
|
|
2544
|
+
// so), never the check its result.
|
|
2545
|
+
process.stderr.write(`interlock: verify timing not recorded: ${(err && err.message) || err}\n`)
|
|
2546
|
+
}
|
|
2547
|
+
process.exit(exitCode)
|
|
2252
2548
|
} else if (sub === 'cluster') {
|
|
2253
2549
|
const failures = readInput(flags.failures, '--failures <file|->')
|
|
2254
2550
|
if (!Array.isArray(failures)) {
|
|
@@ -2266,7 +2562,7 @@ async function main() {
|
|
|
2266
2562
|
if (action.action === 'halt') process.exit(1)
|
|
2267
2563
|
} else {
|
|
2268
2564
|
die(
|
|
2269
|
-
`unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair`
|
|
2565
|
+
`unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair|exec`
|
|
2270
2566
|
)
|
|
2271
2567
|
}
|
|
2272
2568
|
break
|
|
@@ -2393,11 +2689,13 @@ async function main() {
|
|
|
2393
2689
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
2394
2690
|
}).trim() || null
|
|
2395
2691
|
} catch {
|
|
2396
|
-
// An unreadable base is null, never a guess: `
|
|
2397
|
-
// it rather than folding a batch against a commit nobody read.
|
|
2692
|
+
// An unreadable base is null, never a guess: `record-batch` halts
|
|
2693
|
+
// on it rather than folding a batch against a commit nobody read.
|
|
2398
2694
|
return null
|
|
2399
2695
|
}
|
|
2400
2696
|
},
|
|
2697
|
+
snapshotTree,
|
|
2698
|
+
worktreeHead,
|
|
2401
2699
|
logWaveMutation,
|
|
2402
2700
|
logAgentSpawns,
|
|
2403
2701
|
observedChangedPaths,
|
|
@@ -2429,6 +2727,11 @@ async function main() {
|
|
|
2429
2727
|
|
|
2430
2728
|
const results = flags.results ? readInput(flags.results, '--results <file|->') : []
|
|
2431
2729
|
const resultList = Array.isArray(results) ? results : [results]
|
|
2730
|
+
// The host's own observation of each spawn, beside the agents' results and
|
|
2731
|
+
// never inside them: a result is the agent's report, a host record is what
|
|
2732
|
+
// the host saw (design D7). Only the runner sends one; the CLI decides.
|
|
2733
|
+
const records = flags['host-records'] ? readInput(flags['host-records'], '--host-records <file|->') : []
|
|
2734
|
+
const hostRecords = Array.isArray(records) ? records : [records]
|
|
2432
2735
|
|
|
2433
2736
|
let step
|
|
2434
2737
|
if (sub === 'start') {
|
|
@@ -2446,7 +2749,7 @@ async function main() {
|
|
|
2446
2749
|
: ''
|
|
2447
2750
|
}`
|
|
2448
2751
|
)
|
|
2449
|
-
|
|
2752
|
+
emitRunStep(json, root, step, formatRunStep(step))
|
|
2450
2753
|
break
|
|
2451
2754
|
}
|
|
2452
2755
|
const name = resolved.name
|
|
@@ -2462,10 +2765,35 @@ async function main() {
|
|
|
2462
2765
|
die(`--host-capabilities must be a JSON object of the host's declared capabilities: ${err.message}`)
|
|
2463
2766
|
}
|
|
2464
2767
|
}
|
|
2768
|
+
const hostId = typeof flags.host === 'string' ? flags.host : undefined
|
|
2769
|
+
// The Workflow host used to record `effort: flag` because the runtime's
|
|
2770
|
+
// `agent()` option cannot be watched from this process. The installed
|
|
2771
|
+
// CLI's help can. A caller that already named the capability keeps it.
|
|
2772
|
+
let workflowEffortProbe = null
|
|
2773
|
+
if (
|
|
2774
|
+
hostId === 'workflow' &&
|
|
2775
|
+
!(hostCapabilities && Object.prototype.hasOwnProperty.call(hostCapabilities, 'effort'))
|
|
2776
|
+
) {
|
|
2777
|
+
workflowEffortProbe = probeClaudeEffort(process.env)
|
|
2778
|
+
hostCapabilities = { ...(hostCapabilities || {}), effort: workflowEffortProbe.capability }
|
|
2779
|
+
}
|
|
2780
|
+
// The model-routing variables, the slot override and the host version,
|
|
2781
|
+
// read from THIS process's environment — the Workflow driver forwards
|
|
2782
|
+
// what `runStart` decides from them and reads none of them itself.
|
|
2783
|
+
let hostEnv
|
|
2784
|
+
if (hostId === 'workflow') {
|
|
2785
|
+
const probed = probeClaudeVersion(process.env)
|
|
2786
|
+
hostEnv = {
|
|
2787
|
+
...observeClaudeEnv(process.env, { version: probed.version, cpuCount: cpuCount() }),
|
|
2788
|
+
...(probed.reason ? { versionReason: probed.reason } : {})
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2465
2791
|
step = runStart(ctx, {
|
|
2466
2792
|
change: name,
|
|
2467
|
-
host:
|
|
2793
|
+
host: hostId,
|
|
2468
2794
|
hostCapabilities,
|
|
2795
|
+
hostEnv,
|
|
2796
|
+
stateHome: startStateHome(root, flags),
|
|
2469
2797
|
flags: {
|
|
2470
2798
|
applyOnly: flags['apply-only'] === true,
|
|
2471
2799
|
noCommit: flags['no-commit'] === true,
|
|
@@ -2482,6 +2810,18 @@ async function main() {
|
|
|
2482
2810
|
strict: flags.strict !== undefined
|
|
2483
2811
|
}
|
|
2484
2812
|
})
|
|
2813
|
+
if (workflowEffortProbe && workflowEffortProbe.capability === 'unsupported') {
|
|
2814
|
+
// Spoken where the assumption used to be silent. The script still
|
|
2815
|
+
// forwards the option: `agent()` does not reject an unknown key the
|
|
2816
|
+
// way the CLI rejects an unknown flag.
|
|
2817
|
+
const banner = `EFFORT ROUTING UNAVAILABLE (workflow): ${workflowEffortProbe.reason}`
|
|
2818
|
+
const manifest = readManifest(root)
|
|
2819
|
+
if (manifest) {
|
|
2820
|
+
manifest.banners = [...(manifest.banners || []), banner]
|
|
2821
|
+
writeManifest(root, manifest)
|
|
2822
|
+
}
|
|
2823
|
+
if (step && typeof step === 'object') step.banners = [...(step.banners || []), banner]
|
|
2824
|
+
}
|
|
2485
2825
|
} else if (sub === 'classified') {
|
|
2486
2826
|
const classified =
|
|
2487
2827
|
typeof flags.classified === 'string' && flags.classified.trim()
|
|
@@ -2489,25 +2829,25 @@ async function main() {
|
|
|
2489
2829
|
: CLASSIFIED_PATH
|
|
2490
2830
|
step = runClassified(ctx, { classifiedPath: classified })
|
|
2491
2831
|
} else if (sub === 'record-batch') {
|
|
2492
|
-
step = runRecordBatch(ctx, { results: resultList })
|
|
2832
|
+
step = runRecordBatch(ctx, { results: resultList, hostRecords })
|
|
2493
2833
|
} else if (sub === 'judge') {
|
|
2494
2834
|
const context = flags.context === undefined ? 'final' : String(flags.context)
|
|
2495
2835
|
if (context !== 'inter-wave' && context !== 'final') {
|
|
2496
2836
|
die(`--context must be inter-wave|final (got "${context}")`)
|
|
2497
2837
|
}
|
|
2498
|
-
step = runJudge(ctx, { context, results: resultList })
|
|
2838
|
+
step = runJudge(ctx, { context, results: resultList, hostRecords })
|
|
2499
2839
|
} else if (sub === 'verify-final') {
|
|
2500
2840
|
step = runVerifyFinal(ctx)
|
|
2501
2841
|
} else if (sub === 'reviewed') {
|
|
2502
|
-
step = runReviewed(ctx, { results: resultList })
|
|
2842
|
+
step = runReviewed(ctx, { results: resultList, hostRecords })
|
|
2503
2843
|
} else if (sub === 'remediated') {
|
|
2504
2844
|
const roundNum = num(flags, 'round')
|
|
2505
2845
|
if (!Number.isInteger(roundNum)) die('run remediated requires --round <n>')
|
|
2506
|
-
step = runRemediated(ctx, { round: roundNum, results: resultList })
|
|
2846
|
+
step = runRemediated(ctx, { round: roundNum, results: resultList, hostRecords })
|
|
2507
2847
|
} else if (sub === 'commit') {
|
|
2508
|
-
step = runCommit(ctx, { results: resultList })
|
|
2848
|
+
step = runCommit(ctx, { results: resultList, hostRecords })
|
|
2509
2849
|
} else if (sub === 'replan') {
|
|
2510
|
-
step = runReplan(ctx, { results: resultList })
|
|
2850
|
+
step = runReplan(ctx, { results: resultList, hostRecords })
|
|
2511
2851
|
} else if (sub === 'next') {
|
|
2512
2852
|
step = runNext(ctx)
|
|
2513
2853
|
} else if (sub === 'close') {
|
|
@@ -2522,8 +2862,12 @@ async function main() {
|
|
|
2522
2862
|
halt: typeof flags.halt === 'string' ? flags.halt : null,
|
|
2523
2863
|
hostBanners: Array.isArray(hostBanners) ? hostBanners : [],
|
|
2524
2864
|
hostObserved,
|
|
2865
|
+
hostRecords,
|
|
2525
2866
|
notify: flags.notify === true
|
|
2526
2867
|
})
|
|
2868
|
+
// Printed plainly, and NOT copied to LAST_STEP_PATH: a halted run closes
|
|
2869
|
+
// through here, and overwriting the file would replace the very step the
|
|
2870
|
+
// halt reason points a reader at with the close's own summary.
|
|
2527
2871
|
emit(json, step, step.summary + '\n')
|
|
2528
2872
|
if (step.exitCode !== 0) process.exit(step.exitCode)
|
|
2529
2873
|
break
|
|
@@ -2534,7 +2878,7 @@ async function main() {
|
|
|
2534
2878
|
)
|
|
2535
2879
|
}
|
|
2536
2880
|
|
|
2537
|
-
|
|
2881
|
+
emitRunStep(json, root, step, formatRunStep(step), { relay: true })
|
|
2538
2882
|
// A halt step still exits 0: its `then` is the close, and a driver that
|
|
2539
2883
|
// stopped here would leave the run with no receipt and no terminal event.
|
|
2540
2884
|
// `run close` is the call that carries the non-zero exit.
|
|
@@ -2544,19 +2888,22 @@ async function main() {
|
|
|
2544
2888
|
case 'wave-state': {
|
|
2545
2889
|
const sub = positional[1]
|
|
2546
2890
|
const change = typeof flags.change === 'string' ? flags.change : undefined
|
|
2891
|
+
// The trajectory this command appends to lives in the state home; the
|
|
2892
|
+
// changed-path read below stays on the working tree.
|
|
2893
|
+
const logRoot = corpusHome(root, flags).home
|
|
2547
2894
|
if (sub === 'create') {
|
|
2548
2895
|
const plan = readInput(flags.plan, '--plan <file|->')
|
|
2549
2896
|
const maxParallel = num(flags, 'max-parallel')
|
|
2550
2897
|
// The one place the name enters the run: from here it travels on the
|
|
2551
2898
|
// state, and no later wave-state invocation needs the flag.
|
|
2552
2899
|
const state = createRunState(plan, { maxParallel, change })
|
|
2553
|
-
const { ok: logOk } = logWaveMutation(
|
|
2900
|
+
const { ok: logOk } = logWaveMutation(logRoot, { source: 'create', change, state })
|
|
2554
2901
|
emit(json, state, formatRunState(state))
|
|
2555
2902
|
if (!logOk) process.exit(1)
|
|
2556
2903
|
} else if (sub === 'next') {
|
|
2557
2904
|
const state = readInput(flags.state, '--state <file|->')
|
|
2558
|
-
const { step, ok: logOk } = logWaveMutation(
|
|
2559
|
-
const spawnOk = logAgentSpawns(
|
|
2905
|
+
const { step, ok: logOk } = logWaveMutation(logRoot, { source: 'next', change, state })
|
|
2906
|
+
const spawnOk = logAgentSpawns(logRoot, { state, change, step, source: 'next' })
|
|
2560
2907
|
emit(json, step, formatRunState(state))
|
|
2561
2908
|
if (step.action === 'halt' || !logOk || !spawnOk) process.exit(1)
|
|
2562
2909
|
} else if (sub === 'record-batch' || sub === 'record-verify') {
|
|
@@ -2569,11 +2916,11 @@ async function main() {
|
|
|
2569
2916
|
sub === 'record-batch'
|
|
2570
2917
|
? recordBatchResult(state, result, { changedPaths: observedChangedPaths(root) })
|
|
2571
2918
|
: recordVerifyResult(state, result)
|
|
2572
|
-
const { ok: logOk } = logWaveMutation(
|
|
2919
|
+
const { ok: logOk } = logWaveMutation(logRoot, { source: sub, change, state: next })
|
|
2573
2920
|
// Derived from the two states rather than returned by the recorder, so
|
|
2574
2921
|
// `recordBatchResult`'s signature and every other caller are untouched.
|
|
2575
2922
|
const recorded = sub === 'record-batch' ? batchOutcomes(state, next) : undefined
|
|
2576
|
-
emitRecordedState(json, flags, next, { root, change, logOk, source: sub, recorded })
|
|
2923
|
+
emitRecordedState(json, flags, next, { root: logRoot, change, logOk, source: sub, recorded })
|
|
2577
2924
|
} else if (sub === 'replan') {
|
|
2578
2925
|
const state = readInput(flags.state, '--state <file|->')
|
|
2579
2926
|
const groups = readInput(flags.groups, '--groups <file|->')
|
|
@@ -2581,7 +2928,7 @@ async function main() {
|
|
|
2581
2928
|
die('--groups must be a JSON array of { group, tasks } revisions')
|
|
2582
2929
|
}
|
|
2583
2930
|
const next = applyReplan(state, groups)
|
|
2584
|
-
const { ok: logOk } = logWaveMutation(
|
|
2931
|
+
const { ok: logOk } = logWaveMutation(logRoot, { source: 'replan', change, state: next })
|
|
2585
2932
|
emitRecordedState(json, flags, next, { root, change, logOk, source: 'replan' })
|
|
2586
2933
|
} else {
|
|
2587
2934
|
die(
|
|
@@ -2594,7 +2941,8 @@ async function main() {
|
|
|
2594
2941
|
|
|
2595
2942
|
case 'autonomy': {
|
|
2596
2943
|
const sub = positional[1]
|
|
2597
|
-
|
|
2944
|
+
// The autonomy ledger is a corpus: it lives in the state home (ship/state-home).
|
|
2945
|
+
const opts = { root: corpusHome(root, flags).home }
|
|
2598
2946
|
if (sub === 'level') {
|
|
2599
2947
|
const path = positional[2]
|
|
2600
2948
|
if (!path) die('autonomy level requires a path')
|