@haiyangbg/buildbeat 2.0.0-beta.3 → 2.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -7
- package/SKILL.md +86 -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.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +9 -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 +7 -6
- 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 +48 -1
- 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 +47 -0
- package/docs/v2/guide/10-recovery.md +25 -3
- package/docs/v2/guide/README.md +3 -0
- package/example/.buildbeat/manifest.json +1 -1
- package/lessons.md +37 -0
- package/package.json +1 -1
- package/src/v2/adapters/mock.js +9 -2
- package/src/v2/adapters/shell.js +87 -14
- package/src/v2/cli/run.js +607 -30
- package/src/v2/domain/event-registry.js +1 -0
- package/src/v2/engine/reducer.js +29 -1
- 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/decisions.js +80 -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 +267 -19
- package/src/v2/runtime/overview.js +301 -0
- package/src/v2/runtime/run-record.js +3 -0
- package/src/v2/runtime/work-cost.js +147 -0
- package/src/v2/workspace/workspace-manager.js +14 -1
- package/templates/contracts/PROTOCOL.md +4 -0
- package/templates/gitignore.template +5 -0
- package/templates/scripts/bus-check.sh +37 -12
- package/templates/v2/AGENTS.md +73 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
package/src/v2/cli/run.js
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
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";
|
|
13
|
-
import { loadWorkflow } from "../engine/workflow.js";
|
|
20
|
+
import { loadWorkflow, nextStep } from "../engine/workflow.js";
|
|
14
21
|
import { parseYamlSubset } from "../engine/yaml-subset.js";
|
|
15
22
|
import { parsePolicyDoc } from "../policy/policy.js";
|
|
16
23
|
import { observeStatus, runObserveCycle, triageIntent } from "../observe/observe.js";
|
|
17
|
-
import { acceptArtifact, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
|
|
24
|
+
import { acceptArtifact, adoptCandidate, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
|
|
18
25
|
import { checkRequires } from "../runtime/env-contract.js";
|
|
19
26
|
import {
|
|
20
27
|
adjudicateFinding,
|
|
@@ -22,22 +29,40 @@ 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 { artifactStatus, computeOverview, readJsonl, 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";
|
|
29
53
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
30
|
-
import { acquireLock, releaseLock } from "../workspace/workspace-manager.js";
|
|
54
|
+
import { acquireLock, listHeldRunLocks, releaseLock } from "../workspace/workspace-manager.js";
|
|
31
55
|
|
|
32
56
|
const KERNEL = { kind: "kernel", id: "cli" };
|
|
33
57
|
|
|
34
58
|
const USAGE = `BuildBeat v2 runtime
|
|
35
59
|
|
|
36
60
|
Usage:
|
|
37
|
-
run.js start --config <run-config.yaml>
|
|
38
|
-
run.js resume --config <run-config.yaml>
|
|
39
|
-
run.js status --repo <path> --run <RUN-ID>
|
|
61
|
+
run.js start --config <run-config.yaml> [--attempt new]
|
|
62
|
+
run.js resume --config <run-config.yaml> [--adopt <sha> --by <name>] # --adopt: hand fix committed in the worktree; skip fix, resume at verify
|
|
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,72 @@ 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
|
+
// budgets: run config beats the preset (the preset's two review rounds
|
|
285
|
+
// could not be raised per run before; a pilot's shipped candidates ended
|
|
286
|
+
// as CANCELLED runs because of it).
|
|
287
|
+
const budgets = {};
|
|
288
|
+
if (config.budgets !== undefined) {
|
|
289
|
+
if (!config.budgets || typeof config.budgets !== "object" || Array.isArray(config.budgets)) {
|
|
290
|
+
throw new Error("budgets must be a map");
|
|
291
|
+
}
|
|
292
|
+
for (const key of Object.keys(config.budgets)) {
|
|
293
|
+
if (!["maxAttempts", "reviewRoundsPerWork"].includes(key)) {
|
|
294
|
+
throw new Error(`unknown budgets key: ${key} (known: maxAttempts, reviewRoundsPerWork)`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (config.budgets.maxAttempts !== undefined) {
|
|
298
|
+
const map = config.budgets.maxAttempts;
|
|
299
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) {
|
|
300
|
+
throw new Error("budgets.maxAttempts must be a map of step -> positive integer");
|
|
301
|
+
}
|
|
302
|
+
budgets.maxAttempts = {};
|
|
303
|
+
for (const [step, value] of Object.entries(map)) {
|
|
304
|
+
if (!workflow.stepIds.has(step)) {
|
|
305
|
+
throw new Error(`budgets.maxAttempts.${step}: step not in workflow`);
|
|
306
|
+
}
|
|
307
|
+
if (!Number.isInteger(Number(value)) || Number(value) < 1) {
|
|
308
|
+
throw new Error(`budgets.maxAttempts.${step} must be a positive integer, got: ${value}`);
|
|
309
|
+
}
|
|
310
|
+
budgets.maxAttempts[step] = Number(value);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (config.budgets.reviewRoundsPerWork !== undefined) {
|
|
314
|
+
const value = Number(config.budgets.reviewRoundsPerWork);
|
|
315
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
316
|
+
throw new Error(`budgets.reviewRoundsPerWork must be a positive integer, got: ${config.budgets.reviewRoundsPerWork}`);
|
|
317
|
+
}
|
|
318
|
+
budgets.reviewRoundsPerWork = value;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const cache = {};
|
|
322
|
+
for (const [step, mode] of Object.entries(config.cache ?? {})) {
|
|
323
|
+
if (mode !== "tree") {
|
|
324
|
+
throw new Error(`cache.${step} must be "tree", got: ${mode}`);
|
|
325
|
+
}
|
|
326
|
+
if (!workflow.stepIds.has(step)) {
|
|
327
|
+
throw new Error(`cache.${step}: step not in workflow`);
|
|
328
|
+
}
|
|
329
|
+
cache[step] = mode;
|
|
330
|
+
}
|
|
331
|
+
const redact = (config.redact ?? []).map((pattern) => {
|
|
332
|
+
try {
|
|
333
|
+
return new RegExp(String(pattern), "g");
|
|
334
|
+
} catch {
|
|
335
|
+
throw new Error(`redact pattern is not a valid regular expression: ${pattern}`);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
const envelope = loadEnvelope(config, configDir, Object.keys(config.workers ?? {}));
|
|
159
339
|
|
|
160
340
|
return {
|
|
341
|
+
envelope,
|
|
342
|
+
cache,
|
|
343
|
+
redact,
|
|
161
344
|
repoRoot,
|
|
162
345
|
workflow,
|
|
163
346
|
workflowDigest,
|
|
@@ -171,58 +354,221 @@ function loadRunConfig(flags, command) {
|
|
|
171
354
|
policies,
|
|
172
355
|
riskPreset,
|
|
173
356
|
maxAttemptsPerStep: config.maxAttemptsPerStep ?? 4,
|
|
357
|
+
budgets,
|
|
174
358
|
stepTimeoutMs: config.stepTimeoutMs,
|
|
175
359
|
allowedPaths: config.allowedPaths,
|
|
176
360
|
requires: config.requires ?? [],
|
|
177
361
|
reviewTriage: config.reviewTriage === "required" ? "required" : null,
|
|
362
|
+
supersede: config.supersede ?? "waiting",
|
|
363
|
+
stallAfterMs: config.stallAfterMs !== undefined ? Number(config.stallAfterMs) : DEFAULT_STALL_AFTER_MS,
|
|
178
364
|
planDigest: digestOfWorkFile("plan.md"),
|
|
179
365
|
intentDigest: digestOfWorkFile("intent.md"),
|
|
180
366
|
};
|
|
181
367
|
}
|
|
182
368
|
|
|
183
|
-
|
|
369
|
+
// Notification config is optional and never fatal for a run: a broken file
|
|
370
|
+
// is reported once and the run proceeds without outbound messages.
|
|
371
|
+
function notifyConfigFor(repoRoot) {
|
|
372
|
+
try {
|
|
373
|
+
return { config: loadNotifyConfig(repoRoot), error: null };
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return { config: null, error: error.message };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function notifyForState(repoRoot, repoLabel, state) {
|
|
380
|
+
const { config, error } = notifyConfigFor(repoRoot);
|
|
381
|
+
if (error) {
|
|
382
|
+
console.log(`notify: config ignored (${error})`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!config || !state.run) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const kinds = [];
|
|
389
|
+
if (state.pendingHuman && state.run.status === "WAITING_HUMAN") {
|
|
390
|
+
kinds.push("HUMAN_REQUESTED");
|
|
391
|
+
}
|
|
392
|
+
if (state.terminal) {
|
|
393
|
+
kinds.push("RUN_TERMINAL");
|
|
394
|
+
}
|
|
395
|
+
for (const kind of kinds) {
|
|
396
|
+
if (!subscribes(config, kind)) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const results = await dispatchNotification(config, buildNotification(kind, { repoLabel, state }), { repoRoot });
|
|
400
|
+
for (const row of results) {
|
|
401
|
+
const outcome = row.ok ? "sent" : row.skipped ? `skipped (${row.error})` : `FAILED (${row.error})`;
|
|
402
|
+
console.log(`notify ${kind} -> ${row.channel}: ${outcome}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A stall watcher is a separate detached process: the orchestrator blocks in
|
|
408
|
+
// spawnSync while a worker runs, so it cannot look at the clock itself. The
|
|
409
|
+
// watcher exits on its own once the run is no longer RUNNING or the parent
|
|
410
|
+
// process is gone.
|
|
411
|
+
function spawnStallWatcher(repoRoot, runId, stallAfterMs) {
|
|
412
|
+
const { config } = notifyConfigFor(repoRoot);
|
|
413
|
+
if (!subscribes(config, "STALLED")) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
const child = spawn(
|
|
417
|
+
process.execPath,
|
|
418
|
+
[
|
|
419
|
+
fileURLToPath(import.meta.url),
|
|
420
|
+
"watch",
|
|
421
|
+
"--repo",
|
|
422
|
+
repoRoot,
|
|
423
|
+
"--run",
|
|
424
|
+
runId,
|
|
425
|
+
"--stall-after-ms",
|
|
426
|
+
String(stallAfterMs),
|
|
427
|
+
"--parent",
|
|
428
|
+
String(process.pid),
|
|
429
|
+
],
|
|
430
|
+
{ detached: true, stdio: "ignore" },
|
|
431
|
+
);
|
|
432
|
+
child.unref();
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function commandStart(flags) {
|
|
184
437
|
const options = loadRunConfig(flags, "start");
|
|
438
|
+
if (flags.attempt !== undefined) {
|
|
439
|
+
if (flags.attempt !== "new") {
|
|
440
|
+
throw new Error(`--attempt must be "new" (auto-number the next run of this family), got: ${flags.attempt}`);
|
|
441
|
+
}
|
|
442
|
+
// The run config names the family; the kernel numbers the attempt. One
|
|
443
|
+
// config per work, not one per retry (the campaign hand-numbered -01..-30).
|
|
444
|
+
options.runId = nextAttemptId(options.repoRoot, options.workId, options.runId);
|
|
445
|
+
console.log(`attempt: ${options.runId}`);
|
|
446
|
+
}
|
|
447
|
+
if (options.envelope) {
|
|
448
|
+
console.log(`envelope: ${options.envelope.source} (${Object.keys(options.envelope.prompts).join(", ")}) ${options.envelope.digest}`);
|
|
449
|
+
}
|
|
185
450
|
if (process.stdout.isTTY) {
|
|
186
451
|
// Run launch discipline (real incident: a host-tool timeout killed a
|
|
187
452
|
// verify worker mid-run): anything longer than minutes belongs in a
|
|
188
453
|
// detached process, not an interactive foreground shell.
|
|
189
454
|
console.log("tip: long runs should be started detached (nohup/setsid); interactive shells die with their host");
|
|
190
455
|
}
|
|
191
|
-
const
|
|
456
|
+
const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
|
|
457
|
+
if (watching) {
|
|
458
|
+
console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
|
|
459
|
+
}
|
|
460
|
+
let result;
|
|
461
|
+
try {
|
|
462
|
+
result = startRun(options);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (/another run is active/.test(error.message ?? "")) {
|
|
465
|
+
// Say who holds the repository and how to watch it: a pilot session
|
|
466
|
+
// waited 3h23m behind another work's run with nothing but the lock
|
|
467
|
+
// message to go on ("二十分钟了哎").
|
|
468
|
+
const label = repoLabelFor(options.repoRoot);
|
|
469
|
+
const holders = listHeldRunLocks(options.repoRoot);
|
|
470
|
+
if (holders.length === 0) {
|
|
471
|
+
console.error("blocked by: a stale active-run lock with no run holding it (a killed process?); `gc` clears locks of terminal runs, or remove .buildbeat/runtime/locks/active-run.lock after checking no driver process is alive");
|
|
472
|
+
}
|
|
473
|
+
for (const holder of holders) {
|
|
474
|
+
const ledgerPath = join(options.repoRoot, ".buildbeat", "runtime", "runs", holder, "events.jsonl");
|
|
475
|
+
let summary = "(no ledger found)";
|
|
476
|
+
if (existsSync(ledgerPath)) {
|
|
477
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
478
|
+
const state = ledger.state;
|
|
479
|
+
const step = state.currentStep ? `step ${state.currentStep} attempt ${state.steps[state.currentStep]?.attempts ?? "?"}` : "between steps";
|
|
480
|
+
const since = ledger.events[ledger.events.length - 1]?.ts;
|
|
481
|
+
summary = `${state.run?.work ?? "?"} ${state.run?.status ?? "?"} ${step}${since ? `, last event ${formatMs(Date.now() - Date.parse(since))} ago` : ""}`;
|
|
482
|
+
}
|
|
483
|
+
console.error(`blocked by ${holder}: ${summary}`);
|
|
484
|
+
console.error(` watch it: buildbeat-v2 status --repo ${label} --run ${holder}`);
|
|
485
|
+
}
|
|
486
|
+
console.error("queue position: next after the holder(s) above stop or wait on a human (the repository allows one driving run at a time; worktrees are already isolated)");
|
|
487
|
+
}
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
const repoLabel = repoLabelFor(options.repoRoot);
|
|
491
|
+
for (const run of result.superseded ?? []) {
|
|
492
|
+
console.log(`superseded ${run} (was waiting on a human for the same work; now SUPERSEDED)`);
|
|
493
|
+
}
|
|
494
|
+
for (const row of result.supersedeSkipped ?? []) {
|
|
495
|
+
console.log(`could not supersede ${row.run}: ${row.reason}`);
|
|
496
|
+
}
|
|
192
497
|
console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
|
|
193
|
-
|
|
498
|
+
const ledger = EventLedger.open(result.ledgerPath);
|
|
499
|
+
printState(ledger.state, ledger, {
|
|
500
|
+
repoRoot: options.repoRoot,
|
|
501
|
+
repoLabel,
|
|
502
|
+
stallAfterMs: options.stallAfterMs,
|
|
503
|
+
});
|
|
504
|
+
await notifyForState(options.repoRoot, repoLabel, ledger.state);
|
|
194
505
|
}
|
|
195
506
|
|
|
196
|
-
function commandResume(flags) {
|
|
507
|
+
async function commandResume(flags) {
|
|
197
508
|
const options = loadRunConfig(flags, "resume");
|
|
509
|
+
if (flags.adopt !== undefined) {
|
|
510
|
+
const resumeAt = nextStep(options.workflow, "fix", "succeeded") ?? "verify";
|
|
511
|
+
const adopted = adoptCandidate(options.repoRoot, options.runId, {
|
|
512
|
+
sha: flags.adopt,
|
|
513
|
+
by: flags.by ?? "human",
|
|
514
|
+
resumeAt,
|
|
515
|
+
});
|
|
516
|
+
console.log(`adopted ${adopted.adopted} as candidate (${adopted.decisionRef}, answers ${adopted.transition}); resuming at ${adopted.resumeAt}`);
|
|
517
|
+
}
|
|
518
|
+
const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
|
|
519
|
+
if (watching) {
|
|
520
|
+
console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
|
|
521
|
+
}
|
|
198
522
|
const result = resumeRun(options);
|
|
523
|
+
const repoLabel = repoLabelFor(options.repoRoot);
|
|
199
524
|
if (!result.resumed) {
|
|
200
525
|
console.log(`nothing to resume: ${result.reason}`);
|
|
201
526
|
}
|
|
202
527
|
console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
|
|
203
|
-
|
|
528
|
+
const ledger = EventLedger.open(result.ledgerPath);
|
|
529
|
+
printState(ledger.state, ledger, {
|
|
530
|
+
repoRoot: options.repoRoot,
|
|
531
|
+
repoLabel,
|
|
532
|
+
stallAfterMs: options.stallAfterMs,
|
|
533
|
+
});
|
|
534
|
+
if (result.resumed) {
|
|
535
|
+
await notifyForState(options.repoRoot, repoLabel, ledger.state);
|
|
536
|
+
}
|
|
204
537
|
}
|
|
205
538
|
|
|
206
539
|
function commandInbox(flags) {
|
|
207
540
|
if (!flags.repo) {
|
|
208
541
|
throw new Error("inbox requires --repo");
|
|
209
542
|
}
|
|
210
|
-
const
|
|
543
|
+
const repoRoot = resolve(flags.repo);
|
|
544
|
+
const rows = listInbox(repoRoot);
|
|
211
545
|
if (rows.length === 0) {
|
|
212
546
|
console.log("inbox empty: no runs waiting on a human");
|
|
213
547
|
return;
|
|
214
548
|
}
|
|
215
|
-
|
|
549
|
+
const sorted = [...rows].sort((a, b) => `${a.work ?? ""}${a.run}`.localeCompare(`${b.work ?? ""}${b.run}`));
|
|
550
|
+
let lastWork = null;
|
|
551
|
+
for (const row of sorted) {
|
|
216
552
|
if (row.corrupted) {
|
|
217
553
|
console.log(`${row.run}: LEDGER CORRUPTED after seq=${row.corrupted.afterSeq} (${row.corrupted.reason})`);
|
|
218
554
|
continue;
|
|
219
555
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
556
|
+
if (row.work !== lastWork) {
|
|
557
|
+
console.log(`work ${row.work}:`);
|
|
558
|
+
lastWork = row.work;
|
|
559
|
+
}
|
|
560
|
+
const ledger = EventLedger.open(ledgerPathFor(repoRoot, row.run));
|
|
561
|
+
const requested = [...ledger.events].reverse().find((event) => event.type === "HUMAN_REQUESTED");
|
|
562
|
+
const age = requested ? formatMs(Date.now() - Date.parse(requested.ts)) : "?";
|
|
563
|
+
console.log(` ${row.run} [${row.kind}] ${row.transition} — waiting ${age}${requested ? ` (since ${requested.ts})` : ""}`);
|
|
564
|
+
console.log(` candidate: ${row.subject.candidate}`);
|
|
565
|
+
console.log(` planDigest: ${row.subject.planDigest}`);
|
|
566
|
+
console.log(` evidenceDigest: ${row.subject.evidenceDigest}`);
|
|
224
567
|
for (const reason of row.reasons) {
|
|
225
|
-
console.log(`
|
|
568
|
+
console.log(` reason: ${reason}`);
|
|
569
|
+
}
|
|
570
|
+
for (const line of nextReply({ repoLabel: repoLabelFor(repoRoot, flags.repo), state: ledger.state })) {
|
|
571
|
+
console.log(` next: ${line}`);
|
|
226
572
|
}
|
|
227
573
|
}
|
|
228
574
|
}
|
|
@@ -279,6 +625,26 @@ function commandAccept(flags) {
|
|
|
279
625
|
console.log(" note: editing the artifact after acceptance makes this acceptance stale");
|
|
280
626
|
}
|
|
281
627
|
|
|
628
|
+
// Artifacts a policy rule requires to be accepted (artifact.accepted leaves
|
|
629
|
+
// anywhere under all/any/not).
|
|
630
|
+
function artifactsRequiredBy(rule, found = []) {
|
|
631
|
+
if (!rule || typeof rule !== "object") {
|
|
632
|
+
return found;
|
|
633
|
+
}
|
|
634
|
+
for (const [key, value] of Object.entries(rule)) {
|
|
635
|
+
if (key === "artifact.accepted" && value && typeof value.artifact === "string") {
|
|
636
|
+
found.push(value.artifact);
|
|
637
|
+
} else if (Array.isArray(value)) {
|
|
638
|
+
for (const item of value) {
|
|
639
|
+
artifactsRequiredBy(item, found);
|
|
640
|
+
}
|
|
641
|
+
} else if (value && typeof value === "object") {
|
|
642
|
+
artifactsRequiredBy(value, found);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return found;
|
|
646
|
+
}
|
|
647
|
+
|
|
282
648
|
function commandDoctor(flags) {
|
|
283
649
|
const options = loadRunConfig(flags, "doctor");
|
|
284
650
|
console.log(`risk preset: ${options.riskPreset}`);
|
|
@@ -323,6 +689,51 @@ function commandDoctor(flags) {
|
|
|
323
689
|
console.log("push protection: repository has no remotes (nothing to protect)");
|
|
324
690
|
}
|
|
325
691
|
console.log("kernel capabilities: merge/deploy/publish have no call path in the runner (invariant 20)");
|
|
692
|
+
const budgetLines = [];
|
|
693
|
+
for (const step of options.workflow.steps) {
|
|
694
|
+
if (!step.worker) {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const fromRun = options.budgets.maxAttempts?.[step.id];
|
|
698
|
+
const fromPreset = options.workflow.budgets?.maxAttempts?.[step.id];
|
|
699
|
+
const effective = fromRun ?? fromPreset ?? options.maxAttemptsPerStep;
|
|
700
|
+
const source = fromRun !== undefined ? "run config" : fromPreset !== undefined ? "workflow preset" : "default";
|
|
701
|
+
budgetLines.push(`${step.id}=${effective} (${source})`);
|
|
702
|
+
}
|
|
703
|
+
console.log(`budgets (maxAttempts per step; approving resume-<step> after exhaustion grants +1): ${budgetLines.join(", ")}`);
|
|
704
|
+
if (options.budgets.reviewRoundsPerWork !== undefined) {
|
|
705
|
+
console.log(`budgets.reviewRoundsPerWork: ${options.budgets.reviewRoundsPerWork} (counted across every run of the work, superseded ones included)`);
|
|
706
|
+
}
|
|
707
|
+
// Same preconditions start's first gate will read (real incident, twice:
|
|
708
|
+
// doctor passed, start stopped at build because plan.md was not mirrored
|
|
709
|
+
// into the repository the run was started in).
|
|
710
|
+
const workDir = join(options.repoRoot, "delivery", "work", options.workId);
|
|
711
|
+
const decisions = readJsonl(join(workDir, "decisions.jsonl"));
|
|
712
|
+
console.log(`work artifacts in this repository (delivery/work/${options.workId}):`);
|
|
713
|
+
const artifactState = {};
|
|
714
|
+
for (const artifact of ["intent", "plan"]) {
|
|
715
|
+
const status = artifactStatus(workDir, decisions, artifact);
|
|
716
|
+
artifactState[artifact] = status;
|
|
717
|
+
const label = !status.exists
|
|
718
|
+
? "MISSING"
|
|
719
|
+
: status.stale
|
|
720
|
+
? "accepted but edited since (stale)"
|
|
721
|
+
: status.accepted
|
|
722
|
+
? `accepted${status.by ? ` by ${status.by}` : ""}`
|
|
723
|
+
: "draft (not accepted)";
|
|
724
|
+
console.log(` ${artifact}.md: ${label}`);
|
|
725
|
+
}
|
|
726
|
+
for (const policy of options.policies) {
|
|
727
|
+
for (const artifact of artifactsRequiredBy(policy.rule)) {
|
|
728
|
+
const status = artifactState[artifact] ?? artifactStatus(workDir, decisions, artifact);
|
|
729
|
+
if (!status.accepted) {
|
|
730
|
+
console.log(
|
|
731
|
+
` WARNING policy ${policy.name} (${policy.type} ${policy.appliesTo}) needs an accepted ${artifact}.md; start will stop at ${policy.appliesTo}` +
|
|
732
|
+
(!status.exists ? " (file missing here: mirror it into this repository, then accept)" : " (accept it first)"),
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
326
737
|
if (options.requires.length > 0) {
|
|
327
738
|
console.log("environment contract (requires):");
|
|
328
739
|
const check = checkRequires(options.requires);
|
|
@@ -335,6 +746,20 @@ function commandDoctor(flags) {
|
|
|
335
746
|
} else {
|
|
336
747
|
console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
|
|
337
748
|
}
|
|
749
|
+
console.log(`supersede: ${options.supersede} (new run for the same work ${options.supersede === "off" ? "leaves" : "supersedes"} older WAITING_HUMAN runs)`);
|
|
750
|
+
console.log(`stall threshold: ${formatMs(options.stallAfterMs)} without worker output`);
|
|
751
|
+
const { config: notify, error: notifyError } = notifyConfigFor(options.repoRoot);
|
|
752
|
+
if (notifyError) {
|
|
753
|
+
console.log(`notify: PROBLEM ${notifyError}`);
|
|
754
|
+
} else if (!notify) {
|
|
755
|
+
console.log(`notify: none (${NOTIFY_CONFIG} absent; a waiting run reaches nobody until someone runs inbox)`);
|
|
756
|
+
} else {
|
|
757
|
+
console.log("notify channels:");
|
|
758
|
+
for (const channel of notify.channels) {
|
|
759
|
+
const urlState = process.env[channel.urlEnv] ? "url env set" : `WARNING env ${channel.urlEnv} not set in this shell`;
|
|
760
|
+
console.log(` ${channel.id}: type=${channel.type} events=${channel.events.join(",")} (${urlState})`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
338
763
|
}
|
|
339
764
|
|
|
340
765
|
// Preflight: run one step's configured worker command directly in the main
|
|
@@ -469,7 +894,7 @@ function commandReplay(flags) {
|
|
|
469
894
|
} else {
|
|
470
895
|
console.log(`chain OK: ${ledger.events.length} events verified (digest/prev/seq)`);
|
|
471
896
|
}
|
|
472
|
-
printState(ledger.state, { corruption: null });
|
|
897
|
+
printState(ledger.state, { corruption: null }, {});
|
|
473
898
|
}
|
|
474
899
|
|
|
475
900
|
function commandMetrics(flags) {
|
|
@@ -488,8 +913,13 @@ function commandStatus(flags) {
|
|
|
488
913
|
if (!flags.repo || !flags.run) {
|
|
489
914
|
throw new Error("status requires --repo and --run");
|
|
490
915
|
}
|
|
491
|
-
const
|
|
492
|
-
|
|
916
|
+
const repoRoot = resolve(flags.repo);
|
|
917
|
+
const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
|
|
918
|
+
printState(ledger.state, ledger, {
|
|
919
|
+
repoRoot,
|
|
920
|
+
repoLabel: repoLabelFor(repoRoot, flags.repo),
|
|
921
|
+
stallAfterMs: stallAfterFromFlags(flags),
|
|
922
|
+
});
|
|
493
923
|
}
|
|
494
924
|
|
|
495
925
|
function commandStop(flags) {
|
|
@@ -519,6 +949,147 @@ function commandStop(flags) {
|
|
|
519
949
|
}
|
|
520
950
|
}
|
|
521
951
|
|
|
952
|
+
function commandOverview(flags) {
|
|
953
|
+
if (!flags.repo) {
|
|
954
|
+
throw new Error("overview requires --repo");
|
|
955
|
+
}
|
|
956
|
+
const repoRoot = resolve(flags.repo);
|
|
957
|
+
const rows = computeOverview(repoRoot, { work: flags.work ?? null, repoLabel: repoLabelFor(repoRoot, flags.repo) });
|
|
958
|
+
if (flags.json === "true") {
|
|
959
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
console.log(renderOverview(rows));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function commandGc(flags) {
|
|
966
|
+
if (!flags.repo) {
|
|
967
|
+
throw new Error("gc requires --repo");
|
|
968
|
+
}
|
|
969
|
+
const repoRoot = resolve(flags.repo);
|
|
970
|
+
const rows = planGc(repoRoot);
|
|
971
|
+
if (rows.length === 0) {
|
|
972
|
+
console.log("gc: no run ledgers found");
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
let actionable = 0;
|
|
976
|
+
for (const row of rows) {
|
|
977
|
+
const summary = row.actions.map((action) => {
|
|
978
|
+
if (action.kind === "remove-worktree") {
|
|
979
|
+
return `remove worktree${action.dirty ? " (DIRTY, needs --force)" : ""}`;
|
|
980
|
+
}
|
|
981
|
+
if (action.kind === "delete-branch") {
|
|
982
|
+
return `delete branch ${action.branch} (${action.reason})`;
|
|
983
|
+
}
|
|
984
|
+
return "remove stale lock";
|
|
985
|
+
});
|
|
986
|
+
actionable += row.actions.length;
|
|
987
|
+
const keep = row.keep.map((reason) => `keep: ${reason}`);
|
|
988
|
+
const parts = [...summary, ...keep];
|
|
989
|
+
console.log(`${row.run} [${row.status}] ${parts.length > 0 ? parts.join("; ") : "nothing to do"}`);
|
|
990
|
+
}
|
|
991
|
+
if (flags.apply !== "true") {
|
|
992
|
+
console.log(
|
|
993
|
+
actionable > 0
|
|
994
|
+
? `plan only: ${actionable} action(s); rerun with --apply true to execute (branches whose candidate lives only there are always kept)`
|
|
995
|
+
: "nothing to collect",
|
|
996
|
+
);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const results = applyGc(repoRoot, rows, { force: flags.force === "true" });
|
|
1000
|
+
let done = 0;
|
|
1001
|
+
for (const result of results) {
|
|
1002
|
+
const target = result.kind === "delete-branch" ? result.branch : result.path;
|
|
1003
|
+
if (result.done) {
|
|
1004
|
+
done += 1;
|
|
1005
|
+
console.log(` ${result.kind} ${target}: done`);
|
|
1006
|
+
} else {
|
|
1007
|
+
console.log(` ${result.kind} ${target}: NOT done (${result.error})`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
console.log(`gc applied: ${done}/${results.length} action(s)`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function processAlive(pid) {
|
|
1014
|
+
try {
|
|
1015
|
+
process.kill(pid, 0);
|
|
1016
|
+
return true;
|
|
1017
|
+
} catch {
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
1023
|
+
|
|
1024
|
+
// Watches one RUNNING run and reports a stall once per step attempt. Exits
|
|
1025
|
+
// when the run leaves RUNNING, the parent process (if given) dies, or after a
|
|
1026
|
+
// single pass with --once true.
|
|
1027
|
+
async function commandWatch(flags) {
|
|
1028
|
+
if (!flags.repo || !flags.run) {
|
|
1029
|
+
throw new Error("watch requires --repo and --run");
|
|
1030
|
+
}
|
|
1031
|
+
const repoRoot = resolve(flags.repo);
|
|
1032
|
+
const stallAfterMs = stallAfterFromFlags(flags);
|
|
1033
|
+
const intervalMs = Number(flags.interval ?? 30) * 1000;
|
|
1034
|
+
const parent = flags.parent ? Number(flags.parent) : null;
|
|
1035
|
+
const repoLabel = repoLabelFor(repoRoot, flags.repo);
|
|
1036
|
+
const { config } = notifyConfigFor(repoRoot);
|
|
1037
|
+
const notified = new Set();
|
|
1038
|
+
let notifiedThisPass = false;
|
|
1039
|
+
for (;;) {
|
|
1040
|
+
notifiedThisPass = false;
|
|
1041
|
+
const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
|
|
1042
|
+
if (!ledger.state.run) {
|
|
1043
|
+
console.log(`watch: no ledger for ${flags.run} yet`);
|
|
1044
|
+
} else if (ledger.state.run.status !== "RUNNING") {
|
|
1045
|
+
console.log(`watch: run is ${ledger.state.run.status}; done`);
|
|
1046
|
+
return;
|
|
1047
|
+
} else {
|
|
1048
|
+
const liveness = describeLiveness({ repoRoot, runId: flags.run, ledger, stallAfterMs });
|
|
1049
|
+
const live = liveness.inFlight;
|
|
1050
|
+
if (live?.stalled) {
|
|
1051
|
+
const key = `${live.step}#${live.attempt}`;
|
|
1052
|
+
if (!notified.has(key)) {
|
|
1053
|
+
notified.add(key);
|
|
1054
|
+
notifiedThisPass = true;
|
|
1055
|
+
console.log(
|
|
1056
|
+
`watch: STALLED ${live.step} attempt ${live.attempt} — no output for ${formatMs(live.sinceOutputMs)} (threshold ${formatMs(stallAfterMs)})`,
|
|
1057
|
+
);
|
|
1058
|
+
if (subscribes(config, "STALLED")) {
|
|
1059
|
+
const notification = buildNotification("STALLED", {
|
|
1060
|
+
repoLabel,
|
|
1061
|
+
state: ledger.state,
|
|
1062
|
+
detail: {
|
|
1063
|
+
step: live.step,
|
|
1064
|
+
attempt: live.attempt,
|
|
1065
|
+
startedAt: live.startedAt,
|
|
1066
|
+
elapsed: formatMs(live.elapsedMs),
|
|
1067
|
+
sinceOutput: formatMs(live.sinceOutputMs),
|
|
1068
|
+
threshold: formatMs(stallAfterMs),
|
|
1069
|
+
command: live.command,
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
const results = await dispatchNotification(config, notification, { repoRoot });
|
|
1073
|
+
for (const row of results) {
|
|
1074
|
+
console.log(`watch: notify -> ${row.channel}: ${row.ok ? "sent" : row.error}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
} else if (live) {
|
|
1079
|
+
console.log(`watch: ${live.step} attempt ${live.attempt} elapsed ${formatMs(live.elapsedMs)}, last output ${formatMs(live.sinceOutputMs)} ago`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (flags.once === "true") {
|
|
1083
|
+
return notifiedThisPass;
|
|
1084
|
+
}
|
|
1085
|
+
if (parent !== null && !processAlive(parent)) {
|
|
1086
|
+
console.log("watch: parent process gone; done");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
await sleep(intervalMs);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
522
1093
|
function commandObserve(rest) {
|
|
523
1094
|
const [sub, ...args] = rest;
|
|
524
1095
|
const flags = parseFlags(args);
|
|
@@ -577,7 +1148,7 @@ function commandObserve(rest) {
|
|
|
577
1148
|
}
|
|
578
1149
|
}
|
|
579
1150
|
|
|
580
|
-
function main() {
|
|
1151
|
+
async function main() {
|
|
581
1152
|
const [command, ...rest] = process.argv.slice(2);
|
|
582
1153
|
if (command === "observe" || command === "findings") {
|
|
583
1154
|
try {
|
|
@@ -591,9 +1162,9 @@ function main() {
|
|
|
591
1162
|
try {
|
|
592
1163
|
const flags = parseFlags(rest);
|
|
593
1164
|
if (command === "start") {
|
|
594
|
-
commandStart(flags);
|
|
1165
|
+
await commandStart(flags);
|
|
595
1166
|
} else if (command === "resume") {
|
|
596
|
-
commandResume(flags);
|
|
1167
|
+
await commandResume(flags);
|
|
597
1168
|
} else if (command === "inbox") {
|
|
598
1169
|
commandInbox(flags);
|
|
599
1170
|
} else if (command === "approve") {
|
|
@@ -614,6 +1185,12 @@ function main() {
|
|
|
614
1185
|
commandStatus(flags);
|
|
615
1186
|
} else if (command === "stop") {
|
|
616
1187
|
commandStop(flags);
|
|
1188
|
+
} else if (command === "gc") {
|
|
1189
|
+
commandGc(flags);
|
|
1190
|
+
} else if (command === "overview") {
|
|
1191
|
+
commandOverview(flags);
|
|
1192
|
+
} else if (command === "watch") {
|
|
1193
|
+
await commandWatch(flags);
|
|
617
1194
|
} else if (command === "preflight") {
|
|
618
1195
|
commandPreflight(flags);
|
|
619
1196
|
} else {
|
|
@@ -627,4 +1204,4 @@ function main() {
|
|
|
627
1204
|
}
|
|
628
1205
|
}
|
|
629
1206
|
|
|
630
|
-
main();
|
|
1207
|
+
await main();
|