@haiyangbg/buildbeat 2.0.0-beta.3 → 2.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -7
- package/SKILL.md +76 -2
- package/docs/CLI-PILOT-2026-08-23.md +1 -1
- package/docs/CLI.md +1 -1
- package/docs/EXECUTION-PLAN.md +2 -2
- package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
- package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
- package/docs/RELEASING.md +1 -1
- package/docs/V2-D2-DECISION-CARD.md +2 -2
- package/docs/V2-DECISIONS.md +2 -2
- package/docs/V2-ITERATION-01.md +13 -13
- package/docs/V2-ITERATION-06.md +2 -2
- package/docs/V2-ITERATION-08.md +62 -0
- package/docs/V2-PLAN.md +6 -6
- package/docs/V2-PROPOSAL.md +2 -2
- package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
- package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +1 -1
- package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
- package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
- package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
- package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
- package/docs/v2/RFC-0001-product-definition.md +2 -2
- package/docs/v2/SPEC-0001-events-v1.md +2 -2
- package/docs/v2/guide/00-how-to-talk.md +57 -0
- package/docs/v2/guide/01-quickstart.md +4 -0
- package/docs/v2/guide/02-workflow-guide.md +14 -0
- package/docs/v2/guide/04-adapter-guide.md +4 -0
- package/docs/v2/guide/05-worker-contract.md +10 -0
- package/docs/v2/guide/06-evidence-guide.md +4 -0
- package/docs/v2/guide/07-approval-guide.md +33 -0
- package/docs/v2/guide/10-recovery.md +22 -1
- package/docs/v2/guide/README.md +3 -0
- package/example/.buildbeat/manifest.json +1 -1
- package/lessons.md +12 -0
- package/package.json +1 -1
- package/src/v2/adapters/shell.js +87 -14
- package/src/v2/cli/run.js +461 -25
- package/src/v2/engine/reducer.js +2 -0
- package/src/v2/engine/workflow.js +8 -1
- package/src/v2/evidence/collector.js +14 -3
- package/src/v2/presets/release-readback.yaml +36 -0
- package/src/v2/presets/risk/release.yaml +21 -0
- package/src/v2/runtime/cache.js +124 -0
- package/src/v2/runtime/env-contract.js +35 -1
- package/src/v2/runtime/envelope.js +183 -0
- package/src/v2/runtime/gc.js +182 -0
- package/src/v2/runtime/liveness.js +193 -0
- package/src/v2/runtime/metrics.js +8 -0
- package/src/v2/runtime/notify.js +223 -0
- package/src/v2/runtime/orchestrator.js +145 -8
- package/src/v2/runtime/overview.js +264 -0
- package/templates/v2/AGENTS.md +72 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
package/src/v2/cli/run.js
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
// M1 runtime CLI: run start / status / stop for a single foreground run.
|
|
3
3
|
// Deliberately thin — all facts live in the event ledger; this file only
|
|
4
4
|
// parses input, wires adapters, and renders derived state.
|
|
5
|
+
//
|
|
6
|
+
// Iteration 08 adds the human-facing layer the deploy campaign showed was
|
|
7
|
+
// missing: elapsed/typical time and live output in `status`, next-reply
|
|
8
|
+
// commands wherever a run waits, supersession of stale waits, `gc` for the
|
|
9
|
+
// runtime plane, `watch` + outbound notifications so a stopped run reaches
|
|
10
|
+
// its human.
|
|
5
11
|
|
|
6
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
12
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
7
13
|
import { createHash } from "node:crypto";
|
|
8
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
10
17
|
|
|
11
18
|
import { createShellAdapter } from "../adapters/shell.js";
|
|
12
19
|
import { loadRiskPreset } from "../engine/risk-preset.js";
|
|
@@ -22,7 +29,24 @@ import {
|
|
|
22
29
|
latestAdjudications,
|
|
23
30
|
readFindingsAccount,
|
|
24
31
|
} from "../runtime/findings.js";
|
|
32
|
+
import { loadEnvelope, nextAttemptId } from "../runtime/envelope.js";
|
|
33
|
+
import { applyGc, planGc } from "../runtime/gc.js";
|
|
34
|
+
import { computeOverview, renderOverview } from "../runtime/overview.js";
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_STALL_AFTER_MS,
|
|
37
|
+
describeLiveness,
|
|
38
|
+
formatMs,
|
|
39
|
+
tailLive,
|
|
40
|
+
} from "../runtime/liveness.js";
|
|
25
41
|
import { computeMetrics, renderMetrics } from "../runtime/metrics.js";
|
|
42
|
+
import {
|
|
43
|
+
NOTIFY_CONFIG,
|
|
44
|
+
buildNotification,
|
|
45
|
+
dispatchNotification,
|
|
46
|
+
loadNotifyConfig,
|
|
47
|
+
nextReply,
|
|
48
|
+
subscribes,
|
|
49
|
+
} from "../runtime/notify.js";
|
|
26
50
|
import { writeRunRecord } from "../runtime/run-record.js";
|
|
27
51
|
import { resumeRun, startRun } from "../runtime/orchestrator.js";
|
|
28
52
|
import { toRepoRef } from "../runtime/repo-ref.js";
|
|
@@ -34,10 +58,11 @@ const KERNEL = { kind: "kernel", id: "cli" };
|
|
|
34
58
|
const USAGE = `BuildBeat v2 runtime
|
|
35
59
|
|
|
36
60
|
Usage:
|
|
37
|
-
run.js start --config <run-config.yaml>
|
|
61
|
+
run.js start --config <run-config.yaml> [--attempt new]
|
|
38
62
|
run.js resume --config <run-config.yaml>
|
|
39
|
-
run.js status --repo <path> --run <RUN-ID>
|
|
63
|
+
run.js status --repo <path> --run <RUN-ID> [--stall-after <minutes>]
|
|
40
64
|
run.js inbox --repo <path>
|
|
65
|
+
run.js overview --repo <path> [--work <WORK-ID>] [--json true]
|
|
41
66
|
run.js approve --repo <path> --run <RUN-ID> --transition <t> [--by <name>] [--config <run-config.yaml>]
|
|
42
67
|
run.js reject --repo <path> --run <RUN-ID> [--transition <t>] [--reason <text>] [--by <name>]
|
|
43
68
|
run.js accept --repo <path> --work <WORK-ID> --artifact <plan|intent|spec> [--by <name>]
|
|
@@ -46,6 +71,8 @@ Usage:
|
|
|
46
71
|
run.js replay --repo <path> --run <RUN-ID>
|
|
47
72
|
run.js metrics --repo <path> [--json true]
|
|
48
73
|
run.js stop --repo <path> --run <RUN-ID> --reason <text>
|
|
74
|
+
run.js gc --repo <path> [--apply true] [--force true]
|
|
75
|
+
run.js watch --repo <path> --run <RUN-ID> [--stall-after <minutes>] [--interval <seconds>] [--once true]
|
|
49
76
|
run.js observe run --config <observe.yaml>
|
|
50
77
|
run.js observe status --repo <path>
|
|
51
78
|
run.js observe triage --repo <path> --intent <ref> --action <fix_now|schedule|dismiss> [--by <name>] [--note <text>]
|
|
@@ -71,7 +98,45 @@ function ledgerPathFor(repo, runId) {
|
|
|
71
98
|
return join(resolve(repo), ".buildbeat", "runtime", "runs", runId, "events.jsonl");
|
|
72
99
|
}
|
|
73
100
|
|
|
74
|
-
function
|
|
101
|
+
function stallAfterFromFlags(flags, fallbackMs = DEFAULT_STALL_AFTER_MS) {
|
|
102
|
+
if (flags["stall-after-ms"] !== undefined) {
|
|
103
|
+
return Number(flags["stall-after-ms"]);
|
|
104
|
+
}
|
|
105
|
+
if (flags["stall-after"] !== undefined) {
|
|
106
|
+
return Number(flags["stall-after"]) * 60 * 1000;
|
|
107
|
+
}
|
|
108
|
+
return fallbackMs;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The --repo value to print in copyable commands. Inside the project it is
|
|
112
|
+
// the relative path the user would type; outside it a placeholder — printing
|
|
113
|
+
// a machine-local absolute path is exactly what the output must never do.
|
|
114
|
+
function repoLabelFor(repoRoot, typed) {
|
|
115
|
+
if (typed && !isAbsolute(typed)) {
|
|
116
|
+
return typed;
|
|
117
|
+
}
|
|
118
|
+
const rel = relative(process.cwd(), repoRoot);
|
|
119
|
+
if (rel === "") {
|
|
120
|
+
return ".";
|
|
121
|
+
}
|
|
122
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
123
|
+
return "<repo-path>";
|
|
124
|
+
}
|
|
125
|
+
return rel;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function printNextReply(repoLabel, state) {
|
|
129
|
+
const lines = nextReply({ repoLabel, state });
|
|
130
|
+
if (lines.length === 0) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
console.log("next (copy one):");
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
console.log(` ${line}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function printState(state, ledger, view = {}) {
|
|
75
140
|
if (ledger.corruption) {
|
|
76
141
|
console.log(
|
|
77
142
|
`WARNING: ledger corrupted after seq=${ledger.corruption.afterSeq} at line ${ledger.corruption.atLine}: ${ledger.corruption.reason}`,
|
|
@@ -81,6 +146,15 @@ function printState(state, ledger) {
|
|
|
81
146
|
console.log("no run recorded");
|
|
82
147
|
return;
|
|
83
148
|
}
|
|
149
|
+
const liveness =
|
|
150
|
+
view.repoRoot && ledger.events
|
|
151
|
+
? describeLiveness({
|
|
152
|
+
repoRoot: view.repoRoot,
|
|
153
|
+
runId: state.run.id,
|
|
154
|
+
ledger,
|
|
155
|
+
stallAfterMs: view.stallAfterMs ?? DEFAULT_STALL_AFTER_MS,
|
|
156
|
+
})
|
|
157
|
+
: { steps: {}, inFlight: null };
|
|
84
158
|
console.log(`run: ${state.run.id} (work ${state.run.work})`);
|
|
85
159
|
console.log(`status: ${state.run.status}`);
|
|
86
160
|
console.log(`workflow: ${state.run.workflowRef} @ ${state.run.workflowDigest}`);
|
|
@@ -90,17 +164,62 @@ function printState(state, ledger) {
|
|
|
90
164
|
);
|
|
91
165
|
}
|
|
92
166
|
for (const [step, info] of Object.entries(state.steps)) {
|
|
93
|
-
|
|
167
|
+
const timing = liveness.steps[step];
|
|
168
|
+
let suffix = "";
|
|
169
|
+
if (timing && info.status !== "RUNNING") {
|
|
170
|
+
const parts = [];
|
|
171
|
+
if (timing.lastMs !== null) {
|
|
172
|
+
parts.push(`last ${formatMs(timing.lastMs)}`);
|
|
173
|
+
}
|
|
174
|
+
if (timing.attempts > 1) {
|
|
175
|
+
parts.push(`total ${formatMs(timing.totalMs)}`);
|
|
176
|
+
}
|
|
177
|
+
if (timing.typicalMs !== null) {
|
|
178
|
+
parts.push(`typical ${formatMs(timing.typicalMs)} n=${timing.samples}`);
|
|
179
|
+
}
|
|
180
|
+
if (parts.length > 0) {
|
|
181
|
+
suffix = ` [${parts.join(", ")}]`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
console.log(`step ${step}: ${info.status} (attempts ${info.attempts})${suffix}`);
|
|
185
|
+
}
|
|
186
|
+
const live = liveness.inFlight;
|
|
187
|
+
if (live) {
|
|
188
|
+
const typical = live.typicalMs !== null ? `, typical ${formatMs(live.typicalMs)} n=${live.samples}` : "";
|
|
189
|
+
console.log(
|
|
190
|
+
`in flight: ${live.step} attempt ${live.attempt} since ${live.startedAt} (elapsed ${formatMs(live.elapsedMs)}${typical})`,
|
|
191
|
+
);
|
|
192
|
+
if (live.command) {
|
|
193
|
+
console.log(` worker: ${live.command}`);
|
|
194
|
+
}
|
|
195
|
+
if (live.lastOutputAt) {
|
|
196
|
+
console.log(` last output: ${live.lastOutputAt} (${formatMs(live.sinceOutputMs)} ago, ${live.bytes} bytes so far)`);
|
|
197
|
+
} else {
|
|
198
|
+
console.log(` last output: (none yet, ${formatMs(live.sinceOutputMs)} since start)`);
|
|
199
|
+
}
|
|
200
|
+
if (live.stalled) {
|
|
201
|
+
console.log(
|
|
202
|
+
` STALLED: no output for ${formatMs(live.sinceOutputMs)} (threshold ${formatMs(live.stallAfterMs)}); process not killed — inspect, then stop or wait`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const tail = tailLive(view.repoRoot, state.run.id, 3);
|
|
206
|
+
for (const line of tail) {
|
|
207
|
+
console.log(` | ${line.slice(0, 160)}`);
|
|
208
|
+
}
|
|
94
209
|
}
|
|
95
210
|
for (const item of state.evidence) {
|
|
96
211
|
const ref = isAbsolute(item.ref) ? "<legacy-absolute-evidence-ref>" : item.ref;
|
|
97
|
-
|
|
212
|
+
const reused = item.reused ? ` (reused from ${item.reused.run})` : "";
|
|
213
|
+
console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${ref}${reused}`);
|
|
98
214
|
}
|
|
99
215
|
if (state.pendingHuman) {
|
|
100
216
|
console.log(`waiting on human: ${state.pendingHuman.transition}`);
|
|
101
217
|
for (const reason of state.pendingHuman.reasons) {
|
|
102
218
|
console.log(` reason: ${reason}`);
|
|
103
219
|
}
|
|
220
|
+
if (view.repoLabel) {
|
|
221
|
+
printNextReply(view.repoLabel, state);
|
|
222
|
+
}
|
|
104
223
|
}
|
|
105
224
|
if (state.terminal) {
|
|
106
225
|
console.log(`terminal: ${state.terminal.status} (${state.terminal.reason})`);
|
|
@@ -156,8 +275,35 @@ function loadRunConfig(flags, command) {
|
|
|
156
275
|
if (config.reviewTriage !== undefined && !["required", "off"].includes(config.reviewTriage)) {
|
|
157
276
|
throw new Error(`reviewTriage must be "required" or "off", got: ${config.reviewTriage}`);
|
|
158
277
|
}
|
|
278
|
+
if (config.supersede !== undefined && !["waiting", "off"].includes(config.supersede)) {
|
|
279
|
+
throw new Error(`supersede must be "waiting" or "off", got: ${config.supersede}`);
|
|
280
|
+
}
|
|
281
|
+
if (config.stallAfterMs !== undefined && !(Number(config.stallAfterMs) > 0)) {
|
|
282
|
+
throw new Error(`stallAfterMs must be a positive number, got: ${config.stallAfterMs}`);
|
|
283
|
+
}
|
|
284
|
+
const cache = {};
|
|
285
|
+
for (const [step, mode] of Object.entries(config.cache ?? {})) {
|
|
286
|
+
if (mode !== "tree") {
|
|
287
|
+
throw new Error(`cache.${step} must be "tree", got: ${mode}`);
|
|
288
|
+
}
|
|
289
|
+
if (!workflow.stepIds.has(step)) {
|
|
290
|
+
throw new Error(`cache.${step}: step not in workflow`);
|
|
291
|
+
}
|
|
292
|
+
cache[step] = mode;
|
|
293
|
+
}
|
|
294
|
+
const redact = (config.redact ?? []).map((pattern) => {
|
|
295
|
+
try {
|
|
296
|
+
return new RegExp(String(pattern), "g");
|
|
297
|
+
} catch {
|
|
298
|
+
throw new Error(`redact pattern is not a valid regular expression: ${pattern}`);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
const envelope = loadEnvelope(config, configDir, Object.keys(config.workers ?? {}));
|
|
159
302
|
|
|
160
303
|
return {
|
|
304
|
+
envelope,
|
|
305
|
+
cache,
|
|
306
|
+
redact,
|
|
161
307
|
repoRoot,
|
|
162
308
|
workflow,
|
|
163
309
|
workflowDigest,
|
|
@@ -175,54 +321,178 @@ function loadRunConfig(flags, command) {
|
|
|
175
321
|
allowedPaths: config.allowedPaths,
|
|
176
322
|
requires: config.requires ?? [],
|
|
177
323
|
reviewTriage: config.reviewTriage === "required" ? "required" : null,
|
|
324
|
+
supersede: config.supersede ?? "waiting",
|
|
325
|
+
stallAfterMs: config.stallAfterMs !== undefined ? Number(config.stallAfterMs) : DEFAULT_STALL_AFTER_MS,
|
|
178
326
|
planDigest: digestOfWorkFile("plan.md"),
|
|
179
327
|
intentDigest: digestOfWorkFile("intent.md"),
|
|
180
328
|
};
|
|
181
329
|
}
|
|
182
330
|
|
|
183
|
-
|
|
331
|
+
// Notification config is optional and never fatal for a run: a broken file
|
|
332
|
+
// is reported once and the run proceeds without outbound messages.
|
|
333
|
+
function notifyConfigFor(repoRoot) {
|
|
334
|
+
try {
|
|
335
|
+
return { config: loadNotifyConfig(repoRoot), error: null };
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return { config: null, error: error.message };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function notifyForState(repoRoot, repoLabel, state) {
|
|
342
|
+
const { config, error } = notifyConfigFor(repoRoot);
|
|
343
|
+
if (error) {
|
|
344
|
+
console.log(`notify: config ignored (${error})`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!config || !state.run) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const kinds = [];
|
|
351
|
+
if (state.pendingHuman && state.run.status === "WAITING_HUMAN") {
|
|
352
|
+
kinds.push("HUMAN_REQUESTED");
|
|
353
|
+
}
|
|
354
|
+
if (state.terminal) {
|
|
355
|
+
kinds.push("RUN_TERMINAL");
|
|
356
|
+
}
|
|
357
|
+
for (const kind of kinds) {
|
|
358
|
+
if (!subscribes(config, kind)) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const results = await dispatchNotification(config, buildNotification(kind, { repoLabel, state }), { repoRoot });
|
|
362
|
+
for (const row of results) {
|
|
363
|
+
const outcome = row.ok ? "sent" : row.skipped ? `skipped (${row.error})` : `FAILED (${row.error})`;
|
|
364
|
+
console.log(`notify ${kind} -> ${row.channel}: ${outcome}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A stall watcher is a separate detached process: the orchestrator blocks in
|
|
370
|
+
// spawnSync while a worker runs, so it cannot look at the clock itself. The
|
|
371
|
+
// watcher exits on its own once the run is no longer RUNNING or the parent
|
|
372
|
+
// process is gone.
|
|
373
|
+
function spawnStallWatcher(repoRoot, runId, stallAfterMs) {
|
|
374
|
+
const { config } = notifyConfigFor(repoRoot);
|
|
375
|
+
if (!subscribes(config, "STALLED")) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
const child = spawn(
|
|
379
|
+
process.execPath,
|
|
380
|
+
[
|
|
381
|
+
fileURLToPath(import.meta.url),
|
|
382
|
+
"watch",
|
|
383
|
+
"--repo",
|
|
384
|
+
repoRoot,
|
|
385
|
+
"--run",
|
|
386
|
+
runId,
|
|
387
|
+
"--stall-after-ms",
|
|
388
|
+
String(stallAfterMs),
|
|
389
|
+
"--parent",
|
|
390
|
+
String(process.pid),
|
|
391
|
+
],
|
|
392
|
+
{ detached: true, stdio: "ignore" },
|
|
393
|
+
);
|
|
394
|
+
child.unref();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function commandStart(flags) {
|
|
184
399
|
const options = loadRunConfig(flags, "start");
|
|
400
|
+
if (flags.attempt !== undefined) {
|
|
401
|
+
if (flags.attempt !== "new") {
|
|
402
|
+
throw new Error(`--attempt must be "new" (auto-number the next run of this family), got: ${flags.attempt}`);
|
|
403
|
+
}
|
|
404
|
+
// The run config names the family; the kernel numbers the attempt. One
|
|
405
|
+
// config per work, not one per retry (the campaign hand-numbered -01..-30).
|
|
406
|
+
options.runId = nextAttemptId(options.repoRoot, options.workId, options.runId);
|
|
407
|
+
console.log(`attempt: ${options.runId}`);
|
|
408
|
+
}
|
|
409
|
+
if (options.envelope) {
|
|
410
|
+
console.log(`envelope: ${options.envelope.source} (${Object.keys(options.envelope.prompts).join(", ")}) ${options.envelope.digest}`);
|
|
411
|
+
}
|
|
185
412
|
if (process.stdout.isTTY) {
|
|
186
413
|
// Run launch discipline (real incident: a host-tool timeout killed a
|
|
187
414
|
// verify worker mid-run): anything longer than minutes belongs in a
|
|
188
415
|
// detached process, not an interactive foreground shell.
|
|
189
416
|
console.log("tip: long runs should be started detached (nohup/setsid); interactive shells die with their host");
|
|
190
417
|
}
|
|
418
|
+
const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
|
|
419
|
+
if (watching) {
|
|
420
|
+
console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
|
|
421
|
+
}
|
|
191
422
|
const result = startRun(options);
|
|
423
|
+
const repoLabel = repoLabelFor(options.repoRoot);
|
|
424
|
+
for (const run of result.superseded ?? []) {
|
|
425
|
+
console.log(`superseded ${run} (was waiting on a human for the same work; now SUPERSEDED)`);
|
|
426
|
+
}
|
|
427
|
+
for (const row of result.supersedeSkipped ?? []) {
|
|
428
|
+
console.log(`could not supersede ${row.run}: ${row.reason}`);
|
|
429
|
+
}
|
|
192
430
|
console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
|
|
193
|
-
|
|
431
|
+
const ledger = EventLedger.open(result.ledgerPath);
|
|
432
|
+
printState(ledger.state, ledger, {
|
|
433
|
+
repoRoot: options.repoRoot,
|
|
434
|
+
repoLabel,
|
|
435
|
+
stallAfterMs: options.stallAfterMs,
|
|
436
|
+
});
|
|
437
|
+
await notifyForState(options.repoRoot, repoLabel, ledger.state);
|
|
194
438
|
}
|
|
195
439
|
|
|
196
|
-
function commandResume(flags) {
|
|
440
|
+
async function commandResume(flags) {
|
|
197
441
|
const options = loadRunConfig(flags, "resume");
|
|
442
|
+
const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
|
|
443
|
+
if (watching) {
|
|
444
|
+
console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
|
|
445
|
+
}
|
|
198
446
|
const result = resumeRun(options);
|
|
447
|
+
const repoLabel = repoLabelFor(options.repoRoot);
|
|
199
448
|
if (!result.resumed) {
|
|
200
449
|
console.log(`nothing to resume: ${result.reason}`);
|
|
201
450
|
}
|
|
202
451
|
console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
|
|
203
|
-
|
|
452
|
+
const ledger = EventLedger.open(result.ledgerPath);
|
|
453
|
+
printState(ledger.state, ledger, {
|
|
454
|
+
repoRoot: options.repoRoot,
|
|
455
|
+
repoLabel,
|
|
456
|
+
stallAfterMs: options.stallAfterMs,
|
|
457
|
+
});
|
|
458
|
+
if (result.resumed) {
|
|
459
|
+
await notifyForState(options.repoRoot, repoLabel, ledger.state);
|
|
460
|
+
}
|
|
204
461
|
}
|
|
205
462
|
|
|
206
463
|
function commandInbox(flags) {
|
|
207
464
|
if (!flags.repo) {
|
|
208
465
|
throw new Error("inbox requires --repo");
|
|
209
466
|
}
|
|
210
|
-
const
|
|
467
|
+
const repoRoot = resolve(flags.repo);
|
|
468
|
+
const rows = listInbox(repoRoot);
|
|
211
469
|
if (rows.length === 0) {
|
|
212
470
|
console.log("inbox empty: no runs waiting on a human");
|
|
213
471
|
return;
|
|
214
472
|
}
|
|
215
|
-
|
|
473
|
+
const sorted = [...rows].sort((a, b) => `${a.work ?? ""}${a.run}`.localeCompare(`${b.work ?? ""}${b.run}`));
|
|
474
|
+
let lastWork = null;
|
|
475
|
+
for (const row of sorted) {
|
|
216
476
|
if (row.corrupted) {
|
|
217
477
|
console.log(`${row.run}: LEDGER CORRUPTED after seq=${row.corrupted.afterSeq} (${row.corrupted.reason})`);
|
|
218
478
|
continue;
|
|
219
479
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
480
|
+
if (row.work !== lastWork) {
|
|
481
|
+
console.log(`work ${row.work}:`);
|
|
482
|
+
lastWork = row.work;
|
|
483
|
+
}
|
|
484
|
+
const ledger = EventLedger.open(ledgerPathFor(repoRoot, row.run));
|
|
485
|
+
const requested = [...ledger.events].reverse().find((event) => event.type === "HUMAN_REQUESTED");
|
|
486
|
+
const age = requested ? formatMs(Date.now() - Date.parse(requested.ts)) : "?";
|
|
487
|
+
console.log(` ${row.run} [${row.kind}] ${row.transition} — waiting ${age}${requested ? ` (since ${requested.ts})` : ""}`);
|
|
488
|
+
console.log(` candidate: ${row.subject.candidate}`);
|
|
489
|
+
console.log(` planDigest: ${row.subject.planDigest}`);
|
|
490
|
+
console.log(` evidenceDigest: ${row.subject.evidenceDigest}`);
|
|
224
491
|
for (const reason of row.reasons) {
|
|
225
|
-
console.log(`
|
|
492
|
+
console.log(` reason: ${reason}`);
|
|
493
|
+
}
|
|
494
|
+
for (const line of nextReply({ repoLabel: repoLabelFor(repoRoot, flags.repo), state: ledger.state })) {
|
|
495
|
+
console.log(` next: ${line}`);
|
|
226
496
|
}
|
|
227
497
|
}
|
|
228
498
|
}
|
|
@@ -335,6 +605,20 @@ function commandDoctor(flags) {
|
|
|
335
605
|
} else {
|
|
336
606
|
console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
|
|
337
607
|
}
|
|
608
|
+
console.log(`supersede: ${options.supersede} (new run for the same work ${options.supersede === "off" ? "leaves" : "supersedes"} older WAITING_HUMAN runs)`);
|
|
609
|
+
console.log(`stall threshold: ${formatMs(options.stallAfterMs)} without worker output`);
|
|
610
|
+
const { config: notify, error: notifyError } = notifyConfigFor(options.repoRoot);
|
|
611
|
+
if (notifyError) {
|
|
612
|
+
console.log(`notify: PROBLEM ${notifyError}`);
|
|
613
|
+
} else if (!notify) {
|
|
614
|
+
console.log(`notify: none (${NOTIFY_CONFIG} absent; a waiting run reaches nobody until someone runs inbox)`);
|
|
615
|
+
} else {
|
|
616
|
+
console.log("notify channels:");
|
|
617
|
+
for (const channel of notify.channels) {
|
|
618
|
+
const urlState = process.env[channel.urlEnv] ? "url env set" : `WARNING env ${channel.urlEnv} not set in this shell`;
|
|
619
|
+
console.log(` ${channel.id}: type=${channel.type} events=${channel.events.join(",")} (${urlState})`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
338
622
|
}
|
|
339
623
|
|
|
340
624
|
// Preflight: run one step's configured worker command directly in the main
|
|
@@ -469,7 +753,7 @@ function commandReplay(flags) {
|
|
|
469
753
|
} else {
|
|
470
754
|
console.log(`chain OK: ${ledger.events.length} events verified (digest/prev/seq)`);
|
|
471
755
|
}
|
|
472
|
-
printState(ledger.state, { corruption: null });
|
|
756
|
+
printState(ledger.state, { corruption: null }, {});
|
|
473
757
|
}
|
|
474
758
|
|
|
475
759
|
function commandMetrics(flags) {
|
|
@@ -488,8 +772,13 @@ function commandStatus(flags) {
|
|
|
488
772
|
if (!flags.repo || !flags.run) {
|
|
489
773
|
throw new Error("status requires --repo and --run");
|
|
490
774
|
}
|
|
491
|
-
const
|
|
492
|
-
|
|
775
|
+
const repoRoot = resolve(flags.repo);
|
|
776
|
+
const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
|
|
777
|
+
printState(ledger.state, ledger, {
|
|
778
|
+
repoRoot,
|
|
779
|
+
repoLabel: repoLabelFor(repoRoot, flags.repo),
|
|
780
|
+
stallAfterMs: stallAfterFromFlags(flags),
|
|
781
|
+
});
|
|
493
782
|
}
|
|
494
783
|
|
|
495
784
|
function commandStop(flags) {
|
|
@@ -519,6 +808,147 @@ function commandStop(flags) {
|
|
|
519
808
|
}
|
|
520
809
|
}
|
|
521
810
|
|
|
811
|
+
function commandOverview(flags) {
|
|
812
|
+
if (!flags.repo) {
|
|
813
|
+
throw new Error("overview requires --repo");
|
|
814
|
+
}
|
|
815
|
+
const repoRoot = resolve(flags.repo);
|
|
816
|
+
const rows = computeOverview(repoRoot, { work: flags.work ?? null, repoLabel: repoLabelFor(repoRoot, flags.repo) });
|
|
817
|
+
if (flags.json === "true") {
|
|
818
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
console.log(renderOverview(rows));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function commandGc(flags) {
|
|
825
|
+
if (!flags.repo) {
|
|
826
|
+
throw new Error("gc requires --repo");
|
|
827
|
+
}
|
|
828
|
+
const repoRoot = resolve(flags.repo);
|
|
829
|
+
const rows = planGc(repoRoot);
|
|
830
|
+
if (rows.length === 0) {
|
|
831
|
+
console.log("gc: no run ledgers found");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
let actionable = 0;
|
|
835
|
+
for (const row of rows) {
|
|
836
|
+
const summary = row.actions.map((action) => {
|
|
837
|
+
if (action.kind === "remove-worktree") {
|
|
838
|
+
return `remove worktree${action.dirty ? " (DIRTY, needs --force)" : ""}`;
|
|
839
|
+
}
|
|
840
|
+
if (action.kind === "delete-branch") {
|
|
841
|
+
return `delete branch ${action.branch} (${action.reason})`;
|
|
842
|
+
}
|
|
843
|
+
return "remove stale lock";
|
|
844
|
+
});
|
|
845
|
+
actionable += row.actions.length;
|
|
846
|
+
const keep = row.keep.map((reason) => `keep: ${reason}`);
|
|
847
|
+
const parts = [...summary, ...keep];
|
|
848
|
+
console.log(`${row.run} [${row.status}] ${parts.length > 0 ? parts.join("; ") : "nothing to do"}`);
|
|
849
|
+
}
|
|
850
|
+
if (flags.apply !== "true") {
|
|
851
|
+
console.log(
|
|
852
|
+
actionable > 0
|
|
853
|
+
? `plan only: ${actionable} action(s); rerun with --apply true to execute (branches whose candidate lives only there are always kept)`
|
|
854
|
+
: "nothing to collect",
|
|
855
|
+
);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const results = applyGc(repoRoot, rows, { force: flags.force === "true" });
|
|
859
|
+
let done = 0;
|
|
860
|
+
for (const result of results) {
|
|
861
|
+
const target = result.kind === "delete-branch" ? result.branch : result.path;
|
|
862
|
+
if (result.done) {
|
|
863
|
+
done += 1;
|
|
864
|
+
console.log(` ${result.kind} ${target}: done`);
|
|
865
|
+
} else {
|
|
866
|
+
console.log(` ${result.kind} ${target}: NOT done (${result.error})`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
console.log(`gc applied: ${done}/${results.length} action(s)`);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function processAlive(pid) {
|
|
873
|
+
try {
|
|
874
|
+
process.kill(pid, 0);
|
|
875
|
+
return true;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
882
|
+
|
|
883
|
+
// Watches one RUNNING run and reports a stall once per step attempt. Exits
|
|
884
|
+
// when the run leaves RUNNING, the parent process (if given) dies, or after a
|
|
885
|
+
// single pass with --once true.
|
|
886
|
+
async function commandWatch(flags) {
|
|
887
|
+
if (!flags.repo || !flags.run) {
|
|
888
|
+
throw new Error("watch requires --repo and --run");
|
|
889
|
+
}
|
|
890
|
+
const repoRoot = resolve(flags.repo);
|
|
891
|
+
const stallAfterMs = stallAfterFromFlags(flags);
|
|
892
|
+
const intervalMs = Number(flags.interval ?? 30) * 1000;
|
|
893
|
+
const parent = flags.parent ? Number(flags.parent) : null;
|
|
894
|
+
const repoLabel = repoLabelFor(repoRoot, flags.repo);
|
|
895
|
+
const { config } = notifyConfigFor(repoRoot);
|
|
896
|
+
const notified = new Set();
|
|
897
|
+
let notifiedThisPass = false;
|
|
898
|
+
for (;;) {
|
|
899
|
+
notifiedThisPass = false;
|
|
900
|
+
const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
|
|
901
|
+
if (!ledger.state.run) {
|
|
902
|
+
console.log(`watch: no ledger for ${flags.run} yet`);
|
|
903
|
+
} else if (ledger.state.run.status !== "RUNNING") {
|
|
904
|
+
console.log(`watch: run is ${ledger.state.run.status}; done`);
|
|
905
|
+
return;
|
|
906
|
+
} else {
|
|
907
|
+
const liveness = describeLiveness({ repoRoot, runId: flags.run, ledger, stallAfterMs });
|
|
908
|
+
const live = liveness.inFlight;
|
|
909
|
+
if (live?.stalled) {
|
|
910
|
+
const key = `${live.step}#${live.attempt}`;
|
|
911
|
+
if (!notified.has(key)) {
|
|
912
|
+
notified.add(key);
|
|
913
|
+
notifiedThisPass = true;
|
|
914
|
+
console.log(
|
|
915
|
+
`watch: STALLED ${live.step} attempt ${live.attempt} — no output for ${formatMs(live.sinceOutputMs)} (threshold ${formatMs(stallAfterMs)})`,
|
|
916
|
+
);
|
|
917
|
+
if (subscribes(config, "STALLED")) {
|
|
918
|
+
const notification = buildNotification("STALLED", {
|
|
919
|
+
repoLabel,
|
|
920
|
+
state: ledger.state,
|
|
921
|
+
detail: {
|
|
922
|
+
step: live.step,
|
|
923
|
+
attempt: live.attempt,
|
|
924
|
+
startedAt: live.startedAt,
|
|
925
|
+
elapsed: formatMs(live.elapsedMs),
|
|
926
|
+
sinceOutput: formatMs(live.sinceOutputMs),
|
|
927
|
+
threshold: formatMs(stallAfterMs),
|
|
928
|
+
command: live.command,
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
const results = await dispatchNotification(config, notification, { repoRoot });
|
|
932
|
+
for (const row of results) {
|
|
933
|
+
console.log(`watch: notify -> ${row.channel}: ${row.ok ? "sent" : row.error}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
} else if (live) {
|
|
938
|
+
console.log(`watch: ${live.step} attempt ${live.attempt} elapsed ${formatMs(live.elapsedMs)}, last output ${formatMs(live.sinceOutputMs)} ago`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (flags.once === "true") {
|
|
942
|
+
return notifiedThisPass;
|
|
943
|
+
}
|
|
944
|
+
if (parent !== null && !processAlive(parent)) {
|
|
945
|
+
console.log("watch: parent process gone; done");
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
await sleep(intervalMs);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
522
952
|
function commandObserve(rest) {
|
|
523
953
|
const [sub, ...args] = rest;
|
|
524
954
|
const flags = parseFlags(args);
|
|
@@ -577,7 +1007,7 @@ function commandObserve(rest) {
|
|
|
577
1007
|
}
|
|
578
1008
|
}
|
|
579
1009
|
|
|
580
|
-
function main() {
|
|
1010
|
+
async function main() {
|
|
581
1011
|
const [command, ...rest] = process.argv.slice(2);
|
|
582
1012
|
if (command === "observe" || command === "findings") {
|
|
583
1013
|
try {
|
|
@@ -591,9 +1021,9 @@ function main() {
|
|
|
591
1021
|
try {
|
|
592
1022
|
const flags = parseFlags(rest);
|
|
593
1023
|
if (command === "start") {
|
|
594
|
-
commandStart(flags);
|
|
1024
|
+
await commandStart(flags);
|
|
595
1025
|
} else if (command === "resume") {
|
|
596
|
-
commandResume(flags);
|
|
1026
|
+
await commandResume(flags);
|
|
597
1027
|
} else if (command === "inbox") {
|
|
598
1028
|
commandInbox(flags);
|
|
599
1029
|
} else if (command === "approve") {
|
|
@@ -614,6 +1044,12 @@ function main() {
|
|
|
614
1044
|
commandStatus(flags);
|
|
615
1045
|
} else if (command === "stop") {
|
|
616
1046
|
commandStop(flags);
|
|
1047
|
+
} else if (command === "gc") {
|
|
1048
|
+
commandGc(flags);
|
|
1049
|
+
} else if (command === "overview") {
|
|
1050
|
+
commandOverview(flags);
|
|
1051
|
+
} else if (command === "watch") {
|
|
1052
|
+
await commandWatch(flags);
|
|
617
1053
|
} else if (command === "preflight") {
|
|
618
1054
|
commandPreflight(flags);
|
|
619
1055
|
} else {
|
|
@@ -627,4 +1063,4 @@ function main() {
|
|
|
627
1063
|
}
|
|
628
1064
|
}
|
|
629
1065
|
|
|
630
|
-
main();
|
|
1066
|
+
await main();
|
package/src/v2/engine/reducer.js
CHANGED
|
@@ -143,6 +143,8 @@ export function applyEvent(state, event) {
|
|
|
143
143
|
...(data.suppressedFingerprints
|
|
144
144
|
? { suppressedFingerprints: data.suppressedFingerprints }
|
|
145
145
|
: {}),
|
|
146
|
+
...(data.cacheKey ? { cacheKey: data.cacheKey } : {}),
|
|
147
|
+
...(data.reused ? { reused: data.reused } : {}),
|
|
146
148
|
});
|
|
147
149
|
break;
|
|
148
150
|
}
|
|
@@ -27,7 +27,8 @@ const TOP_FIELDS = new Set([
|
|
|
27
27
|
"policies",
|
|
28
28
|
"budgets",
|
|
29
29
|
]);
|
|
30
|
-
const STEP_FIELDS = new Set(["id", "worker", "optional", "requiredWhen", "readonly"]);
|
|
30
|
+
const STEP_FIELDS = new Set(["id", "worker", "optional", "requiredWhen", "readonly", "grade"]);
|
|
31
|
+
const EVIDENCE_GRADES = new Set(["L0", "L1", "L2", "L3", "L4"]);
|
|
31
32
|
const TRANSITION_FIELDS = new Set(["from", "on", "to"]);
|
|
32
33
|
|
|
33
34
|
function rejectUnknownFields(object, allowed, where) {
|
|
@@ -82,6 +83,11 @@ export function parseWorkflow(text) {
|
|
|
82
83
|
if (raw.readonly !== undefined && typeof raw.readonly !== "boolean") {
|
|
83
84
|
throw new WorkflowError(`step ${id} readonly must be a boolean`);
|
|
84
85
|
}
|
|
86
|
+
// Evidence grade a step's command evidence is recorded at (iteration 08:
|
|
87
|
+
// release readbacks run against the real environment and are L4).
|
|
88
|
+
if (raw.grade !== undefined && !EVIDENCE_GRADES.has(raw.grade)) {
|
|
89
|
+
throw new WorkflowError(`step ${id} grade must be one of L0-L4`);
|
|
90
|
+
}
|
|
85
91
|
stepIds.add(id);
|
|
86
92
|
steps.push({
|
|
87
93
|
id,
|
|
@@ -89,6 +95,7 @@ export function parseWorkflow(text) {
|
|
|
89
95
|
optional: raw.optional ?? false,
|
|
90
96
|
requiredWhen: raw.requiredWhen ?? null,
|
|
91
97
|
readonly: raw.readonly ?? false,
|
|
98
|
+
grade: raw.grade ?? "L2",
|
|
92
99
|
});
|
|
93
100
|
}
|
|
94
101
|
|