@haiyangbg/buildbeat 2.0.0-beta.2 → 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 +42 -6
- package/SKILL.md +76 -2
- package/bin/buildbeat-v2.js +13 -1
- 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 +8 -0
- 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 +6 -0
- package/docs/v2/guide/02-workflow-guide.md +31 -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 +10 -0
- package/docs/v2/guide/07-approval-guide.md +50 -0
- package/docs/v2/guide/10-recovery.md +25 -2
- 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 +602 -30
- package/src/v2/domain/event-registry.js +24 -0
- package/src/v2/engine/reducer.js +5 -0
- package/src/v2/engine/workflow.js +8 -1
- package/src/v2/evidence/collector.js +14 -3
- package/src/v2/observe/observe.js +9 -2
- package/src/v2/presets/release-readback.yaml +36 -0
- package/src/v2/presets/risk/release.yaml +21 -0
- package/src/v2/presets/software-delivery.yaml +5 -0
- package/src/v2/runtime/cache.js +124 -0
- package/src/v2/runtime/decisions.js +6 -4
- package/src/v2/runtime/env-contract.js +135 -0
- package/src/v2/runtime/envelope.js +183 -0
- package/src/v2/runtime/findings.js +158 -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 +252 -24
- package/src/v2/runtime/overview.js +264 -0
- package/src/v2/runtime/repo-ref.js +38 -0
- package/src/v2/runtime/run-record.js +18 -3
- package/src/v2/workspace/workspace-manager.js +4 -1
- package/templates/v2/AGENTS.md +72 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// anything changed goes APPROVAL_STALE and back to WAITING_HUMAN.
|
|
12
12
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
|
|
17
17
|
import { nextStep } from "../engine/workflow.js";
|
|
@@ -26,6 +26,17 @@ import {
|
|
|
26
26
|
releaseLock,
|
|
27
27
|
} from "../workspace/workspace-manager.js";
|
|
28
28
|
import { writeRunRecord } from "./run-record.js";
|
|
29
|
+
import { assertRequires } from "./env-contract.js";
|
|
30
|
+
import { materialisePrompt } from "./envelope.js";
|
|
31
|
+
import { cacheKey, findReusableEvidence, lastReviewedCandidate, treeHash } from "./cache.js";
|
|
32
|
+
import {
|
|
33
|
+
buildAnchor,
|
|
34
|
+
fingerprintFinding,
|
|
35
|
+
latestAdjudications,
|
|
36
|
+
readFindingsAccount,
|
|
37
|
+
recordReviewFindings,
|
|
38
|
+
} from "./findings.js";
|
|
39
|
+
import { resolveRepoRef, toRepoRef } from "./repo-ref.js";
|
|
29
40
|
|
|
30
41
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
31
42
|
|
|
@@ -135,6 +146,11 @@ function makeContext(options, ledger, workspace) {
|
|
|
135
146
|
workflow.budgets?.maxAttempts?.[step] ?? maxAttemptsPerStep;
|
|
136
147
|
context.policies = options.policies ?? [];
|
|
137
148
|
context.allowedPaths = options.allowedPaths ?? null;
|
|
149
|
+
context.reviewTriage = options.reviewTriage ?? null;
|
|
150
|
+
context.envelope = options.envelope ?? null;
|
|
151
|
+
context.cache = options.cache ?? {};
|
|
152
|
+
context.redact = options.redact ?? [];
|
|
153
|
+
context.adapterConfigs = options.adapterConfigs ?? {};
|
|
138
154
|
context.policyCtx = () => ({
|
|
139
155
|
state: ledger.state,
|
|
140
156
|
candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
|
|
@@ -219,6 +235,14 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
219
235
|
]);
|
|
220
236
|
return null;
|
|
221
237
|
}
|
|
238
|
+
// A step that failed its final attempt can never run again, so routing
|
|
239
|
+
// to fix would spend a worker on a candidate nothing can verify.
|
|
240
|
+
if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
|
|
241
|
+
context.waitHuman(`resume-${step}`, [
|
|
242
|
+
`budget exhausted: ${step} failed its final attempt (maxAttempts=${context.maxAttemptsFor(step)}); not routing to fix`,
|
|
243
|
+
]);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
222
246
|
}
|
|
223
247
|
const to = nextStep(workflow, step, outcome);
|
|
224
248
|
let result = "PASS";
|
|
@@ -337,14 +361,100 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
337
361
|
const outputsDir = join(context.runtimeDir, "runs", ledger.state.run.id, "outputs");
|
|
338
362
|
mkdirSync(outputsDir, { recursive: true });
|
|
339
363
|
const outputPath = join(outputsDir, `${step}-${attempt}.json`);
|
|
340
|
-
|
|
341
|
-
|
|
364
|
+
// Anchored review: readonly (reviewer) steps receive the adjudicated
|
|
365
|
+
// findings history so a fresh reviewer inherits settled verdicts instead
|
|
366
|
+
// of re-litigating them; writing steps get the latest review findings
|
|
367
|
+
// with their adjudication status (the fixer's worklist).
|
|
368
|
+
const input = { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt };
|
|
369
|
+
const anchor = buildAnchor(context.repoRoot, ledger.state.run.work);
|
|
370
|
+
if (anchor && stepDef.readonly) {
|
|
371
|
+
input.anchor = anchor;
|
|
372
|
+
} else if (anchor) {
|
|
373
|
+
const lastReview = [...ledger.state.evidence]
|
|
374
|
+
.reverse()
|
|
375
|
+
.find((item) => item.kind === "review");
|
|
376
|
+
if (lastReview?.findings?.length) {
|
|
377
|
+
const adjudicated = latestAdjudications(
|
|
378
|
+
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
379
|
+
);
|
|
380
|
+
input.findings = lastReview.findings.map((finding) => ({
|
|
381
|
+
severity: finding.severity,
|
|
382
|
+
summary: finding.summary,
|
|
383
|
+
fingerprint: fingerprintFinding(finding),
|
|
384
|
+
adjudication: adjudicated.get(fingerprintFinding(finding))?.action ?? "open",
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Envelope (C6): the worker's prompt, materialised into the run
|
|
389
|
+
// directory and handed over as BUILDBEAT_PROMPT / input.envelope.
|
|
390
|
+
const prompt = materialisePrompt({
|
|
391
|
+
envelope: context.envelope,
|
|
342
392
|
worker: stepDef.worker,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
393
|
+
runtimeDir: context.runtimeDir,
|
|
394
|
+
runId: ledger.state.run.id,
|
|
395
|
+
step,
|
|
396
|
+
attempt,
|
|
397
|
+
repoRoot: context.repoRoot,
|
|
347
398
|
});
|
|
399
|
+
if (prompt) {
|
|
400
|
+
input.envelope = { promptRef: prompt.ref, file: prompt.file, digest: context.envelope.digest, vars: context.envelope.vars };
|
|
401
|
+
}
|
|
402
|
+
// Incremental review (C7): tell a reviewer which candidate the last
|
|
403
|
+
// review saw when it is an ancestor of this one.
|
|
404
|
+
if (stepDef.readonly) {
|
|
405
|
+
const head = before.head;
|
|
406
|
+
const lastReviewed = lastReviewedCandidate(context.repoRoot, ledger.state.run.work, workspace.worktreePath, head);
|
|
407
|
+
if (lastReviewed) {
|
|
408
|
+
input.lastReviewed = lastReviewed;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Verification reuse (C7): same tree + same worker + same envelope that
|
|
412
|
+
// already passed is referenced, not re-run. Failures always re-run.
|
|
413
|
+
let stepCacheKey = null;
|
|
414
|
+
let reused = null;
|
|
415
|
+
if (context.cache[step] === "tree") {
|
|
416
|
+
const current = readback(workspace.worktreePath);
|
|
417
|
+
if (!current.dirty) {
|
|
418
|
+
stepCacheKey = cacheKey({
|
|
419
|
+
tree: treeHash(workspace.worktreePath),
|
|
420
|
+
worker: stepDef.worker,
|
|
421
|
+
adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
|
|
422
|
+
adapterName: adapter.name,
|
|
423
|
+
envelopeDigest: context.envelope?.digest ?? null,
|
|
424
|
+
});
|
|
425
|
+
reused = findReusableEvidence(context.repoRoot, stepCacheKey);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
let exec;
|
|
429
|
+
if (reused) {
|
|
430
|
+
const at = now();
|
|
431
|
+
exec = {
|
|
432
|
+
adapter: "cache",
|
|
433
|
+
command: `reuse ${reused.run} ${reused.evidenceRef}`,
|
|
434
|
+
exitCode: 0,
|
|
435
|
+
signal: null,
|
|
436
|
+
stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
|
|
437
|
+
stderr: "",
|
|
438
|
+
timedOut: false,
|
|
439
|
+
spawnError: null,
|
|
440
|
+
startedAt: at,
|
|
441
|
+
finishedAt: at,
|
|
442
|
+
};
|
|
443
|
+
} else {
|
|
444
|
+
exec = adapter.execute({
|
|
445
|
+
step,
|
|
446
|
+
worker: stepDef.worker,
|
|
447
|
+
workspacePath: workspace.worktreePath,
|
|
448
|
+
input,
|
|
449
|
+
timeoutMs: context.stepTimeoutMs,
|
|
450
|
+
outputPath,
|
|
451
|
+
// Live output streams + marker land in the run directory so `status`
|
|
452
|
+
// can answer "is it still doing something" while the step runs.
|
|
453
|
+
liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
|
|
454
|
+
promptPath: prompt?.path ?? null,
|
|
455
|
+
vars: context.envelope?.vars ?? null,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
348
458
|
const tree = readback(workspace.worktreePath);
|
|
349
459
|
const evidence = collectCommandEvidence({
|
|
350
460
|
runtimeDir: context.runtimeDir,
|
|
@@ -353,18 +463,22 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
353
463
|
attempt,
|
|
354
464
|
execResult: exec,
|
|
355
465
|
subject: tree.head,
|
|
466
|
+
grade: reused ? reused.grade : stepDef.grade ?? "L2",
|
|
467
|
+
redact: context.redact,
|
|
356
468
|
});
|
|
357
469
|
ledger.append({
|
|
358
470
|
type: "EVIDENCE_RECORDED",
|
|
359
471
|
actor: KERNEL,
|
|
360
472
|
ts: now(),
|
|
361
473
|
data: {
|
|
362
|
-
evidenceRef: evidence.location,
|
|
474
|
+
evidenceRef: toRepoRef(context.repoRoot, evidence.location),
|
|
363
475
|
kind: evidence.kind,
|
|
364
476
|
subject: evidence.subject,
|
|
365
477
|
digest: evidence.digest,
|
|
366
478
|
status: evidence.status,
|
|
367
479
|
grade: evidence.grade,
|
|
480
|
+
...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
|
|
481
|
+
...(reused ? { reused: { run: reused.run, evidenceRef: reused.evidenceRef, digest: reused.digest } } : {}),
|
|
368
482
|
},
|
|
369
483
|
});
|
|
370
484
|
|
|
@@ -432,21 +546,39 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
432
546
|
|
|
433
547
|
let blockingFindings = [];
|
|
434
548
|
if (envelope?.findings) {
|
|
435
|
-
|
|
436
|
-
|
|
549
|
+
recordReviewFindings(context.repoRoot, ledger.state.run.work, {
|
|
550
|
+
run: ledger.state.run.id,
|
|
551
|
+
step,
|
|
552
|
+
attempt,
|
|
553
|
+
findings: envelope.findings,
|
|
554
|
+
ts: now(),
|
|
555
|
+
});
|
|
556
|
+
// A fingerprint a human dismissed stays visible in the evidence but no
|
|
557
|
+
// longer blocks: settled verdicts do not reopen without a human.
|
|
558
|
+
const adjudicated = latestAdjudications(
|
|
559
|
+
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
437
560
|
);
|
|
561
|
+
const suppressed = [];
|
|
562
|
+
blockingFindings = envelope.findings.filter((finding) => {
|
|
563
|
+
if (adjudicated.get(fingerprintFinding(finding))?.action === "dismiss") {
|
|
564
|
+
suppressed.push(fingerprintFinding(finding));
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
return finding.severity === "P0" || finding.severity === "P1";
|
|
568
|
+
});
|
|
438
569
|
ledger.append({
|
|
439
570
|
type: "EVIDENCE_RECORDED",
|
|
440
571
|
actor: KERNEL,
|
|
441
572
|
ts: now(),
|
|
442
573
|
data: {
|
|
443
|
-
evidenceRef: outputPath,
|
|
574
|
+
evidenceRef: toRepoRef(context.repoRoot, outputPath),
|
|
444
575
|
kind: "review",
|
|
445
576
|
subject: tree.head,
|
|
446
577
|
digest: sha256(canonicalJson(envelope)),
|
|
447
578
|
status: blockingFindings.length > 0 ? "failed" : "passed",
|
|
448
579
|
grade: "L2",
|
|
449
580
|
findings: envelope.findings,
|
|
581
|
+
...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
|
|
450
582
|
},
|
|
451
583
|
});
|
|
452
584
|
}
|
|
@@ -531,8 +663,81 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
531
663
|
} else {
|
|
532
664
|
outcome = "succeeded";
|
|
533
665
|
}
|
|
534
|
-
|
|
666
|
+
const routed = settleOutcome(context, step, outcome, tree, exec);
|
|
667
|
+
// Finding triage gate (reviewTriage: required): blocking findings stop
|
|
668
|
+
// for a human verdict before any fixer runs. Findings are prescriptions,
|
|
669
|
+
// not facts — auto-routing them to a fixer burned four oscillation
|
|
670
|
+
// rounds in the deploy campaign before a human stopped the loop.
|
|
671
|
+
if (routed && outcome === "findings-blocking" && context.reviewTriage === "required") {
|
|
672
|
+
context.waitHuman(
|
|
673
|
+
`enter-${routed}`,
|
|
674
|
+
[
|
|
675
|
+
`review found ${blockingFindings.length} blocking finding(s); triage before ${routed} runs`,
|
|
676
|
+
...blockingFindings
|
|
677
|
+
.slice(0, 5)
|
|
678
|
+
.map(
|
|
679
|
+
(finding) =>
|
|
680
|
+
`[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`,
|
|
681
|
+
),
|
|
682
|
+
`adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
|
|
683
|
+
],
|
|
684
|
+
"finding-triage",
|
|
685
|
+
);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
step = routed;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Supersede (iteration 08, C2): a new Run for the same Work makes any older
|
|
693
|
+
// Run still waiting on a human moot — the human would be approving a
|
|
694
|
+
// candidate nobody intends to merge. Real incident: two WAITING_HUMAN runs
|
|
695
|
+
// sat in a pilot repo's inbox for a day after their successor had already
|
|
696
|
+
// shipped. Only WAITING_HUMAN runs are touched; RUNNING ones are protected
|
|
697
|
+
// by the active lock, terminal ones are already settled.
|
|
698
|
+
function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
|
|
699
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
700
|
+
const superseded = [];
|
|
701
|
+
const skipped = [];
|
|
702
|
+
if (!existsSync(runsDir)) {
|
|
703
|
+
return { superseded, skipped };
|
|
704
|
+
}
|
|
705
|
+
for (const entry of readdirSync(runsDir).sort()) {
|
|
706
|
+
if (entry === newRunId) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const ledgerPath = join(runsDir, entry, "events.jsonl");
|
|
710
|
+
if (!existsSync(ledgerPath)) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
714
|
+
const state = ledger.state;
|
|
715
|
+
if (ledger.corruption || !state.run || state.run.work !== workId) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (state.terminal || state.run.status !== "WAITING_HUMAN") {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
acquireLock(repoRoot, entry);
|
|
723
|
+
} catch {
|
|
724
|
+
skipped.push({ run: entry, reason: "locked by another process" });
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
ledger.append({
|
|
729
|
+
type: "RUN_TERMINAL",
|
|
730
|
+
actor: KERNEL,
|
|
731
|
+
ts: now(),
|
|
732
|
+
data: { status: "SUPERSEDED", reason: `superseded by ${newRunId} (same work ${workId})` },
|
|
733
|
+
});
|
|
734
|
+
writeRunRecord({ repoRoot, ledger, ts: now() });
|
|
735
|
+
superseded.push(entry);
|
|
736
|
+
} finally {
|
|
737
|
+
releaseLock(repoRoot, entry);
|
|
738
|
+
}
|
|
535
739
|
}
|
|
740
|
+
return { superseded, skipped };
|
|
536
741
|
}
|
|
537
742
|
|
|
538
743
|
function openLedgerFor(repoRoot, runId) {
|
|
@@ -568,6 +773,11 @@ export function startRun(options) {
|
|
|
568
773
|
if (!workflowDigest) {
|
|
569
774
|
throw new OrchestratorError("workflowDigest is required (pin what you run)");
|
|
570
775
|
}
|
|
776
|
+
// Environment contract first: a missing or too-old binary fails the start
|
|
777
|
+
// with a readable cause instead of burning a run on an implicit PATH fact.
|
|
778
|
+
if (options.requires?.length) {
|
|
779
|
+
assertRequires(options.requires);
|
|
780
|
+
}
|
|
571
781
|
const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
|
|
572
782
|
if (ledger.events.length > 0) {
|
|
573
783
|
throw new OrchestratorError(`run ${runId} already has a ledger; use resumeRun`);
|
|
@@ -577,6 +787,10 @@ export function startRun(options) {
|
|
|
577
787
|
const workspace = createWorkspace({ repoRoot, runId, base });
|
|
578
788
|
const context = makeContext(options, ledger, workspace);
|
|
579
789
|
const now = context.now;
|
|
790
|
+
const supersession =
|
|
791
|
+
options.supersede === "off"
|
|
792
|
+
? { superseded: [], skipped: [] }
|
|
793
|
+
: supersedeWaitingRuns(repoRoot, workId, runId, now);
|
|
580
794
|
ledger.append({
|
|
581
795
|
type: "RUN_CREATED",
|
|
582
796
|
actor: KERNEL,
|
|
@@ -591,6 +805,8 @@ export function startRun(options) {
|
|
|
591
805
|
entry,
|
|
592
806
|
planDigest: planDigest ?? "UNVERIFIED",
|
|
593
807
|
intentDigest: intentDigest ?? "UNVERIFIED",
|
|
808
|
+
...(supersession.superseded.length > 0 ? { supersedes: supersession.superseded } : {}),
|
|
809
|
+
...(options.envelope ? { envelopeDigest: options.envelope.digest, envelopeSource: options.envelope.source } : {}),
|
|
594
810
|
},
|
|
595
811
|
});
|
|
596
812
|
ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
|
|
@@ -600,14 +816,22 @@ export function startRun(options) {
|
|
|
600
816
|
ts: now(),
|
|
601
817
|
data: {
|
|
602
818
|
workspaceId: workspace.workspaceId,
|
|
603
|
-
repo: repoRoot,
|
|
819
|
+
repo: toRepoRef(repoRoot, repoRoot),
|
|
604
820
|
branch: workspace.branch,
|
|
605
|
-
worktreePath: workspace.worktreePath,
|
|
821
|
+
worktreePath: toRepoRef(repoRoot, workspace.worktreePath),
|
|
606
822
|
base: workspace.base,
|
|
607
823
|
},
|
|
608
824
|
});
|
|
609
825
|
drive(context, entry);
|
|
610
|
-
return {
|
|
826
|
+
return {
|
|
827
|
+
runId,
|
|
828
|
+
workId,
|
|
829
|
+
ledgerPath,
|
|
830
|
+
state: ledger.state,
|
|
831
|
+
workspace,
|
|
832
|
+
superseded: supersession.superseded,
|
|
833
|
+
supersedeSkipped: supersession.skipped,
|
|
834
|
+
};
|
|
611
835
|
});
|
|
612
836
|
}
|
|
613
837
|
|
|
@@ -626,6 +850,9 @@ export function resumeRun(options) {
|
|
|
626
850
|
if (!repoRoot || !runId) {
|
|
627
851
|
throw new OrchestratorError("repoRoot and runId are required");
|
|
628
852
|
}
|
|
853
|
+
if (options.requires?.length) {
|
|
854
|
+
assertRequires(options.requires);
|
|
855
|
+
}
|
|
629
856
|
const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
|
|
630
857
|
const state = ledger.state;
|
|
631
858
|
if (!state.run) {
|
|
@@ -647,15 +874,16 @@ export function resumeRun(options) {
|
|
|
647
874
|
if (!bound) {
|
|
648
875
|
throw new OrchestratorError(`run ${runId} has no bound workspace; cannot resume`);
|
|
649
876
|
}
|
|
650
|
-
|
|
877
|
+
const worktreePath = resolveRepoRef(repoRoot, bound.worktreePath);
|
|
878
|
+
if (!existsSync(worktreePath)) {
|
|
651
879
|
throw new OrchestratorError(
|
|
652
|
-
|
|
880
|
+
"worktree missing; recovery requires a human decision",
|
|
653
881
|
);
|
|
654
882
|
}
|
|
655
883
|
const workspace = {
|
|
656
884
|
workspaceId: runId,
|
|
657
885
|
repoRoot,
|
|
658
|
-
worktreePath
|
|
886
|
+
worktreePath,
|
|
659
887
|
branch: bound.branch,
|
|
660
888
|
base: bound.base,
|
|
661
889
|
};
|
|
@@ -727,12 +955,12 @@ export function resumeRun(options) {
|
|
|
727
955
|
]);
|
|
728
956
|
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
729
957
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
958
|
+
// An interrupted attempt says nothing about the candidate, so the step
|
|
959
|
+
// itself reruns (the lost attempt still counts against its budget)
|
|
960
|
+
// instead of settling as a step failure — routing a crash through the
|
|
961
|
+
// failure edge dispatched a fixer with no verifier evidence (real
|
|
962
|
+
// incident: deploy-18's verify worker was killed by a host timeout).
|
|
963
|
+
startStep = step;
|
|
736
964
|
} else if (tree.dirty) {
|
|
737
965
|
context.waitHuman("resume-run", [
|
|
738
966
|
"worktree is dirty at resume with no step in flight; human triage required",
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Work-level overview (iteration 08, C5): "where is this thing, and who
|
|
2
|
+
// moves next" — the question the owner opened three sessions with
|
|
3
|
+
// ("X 上线了吗 / 离上线还差多远 / 从每个系统说待办") and that inbox,
|
|
4
|
+
// which only knows about runs waiting on a human, cannot answer.
|
|
5
|
+
//
|
|
6
|
+
// Everything here is derived: work directories and decision ledgers in the
|
|
7
|
+
// Git plane, run ledgers in the runtime plane (run-records fill in for runs
|
|
8
|
+
// whose runtime was wiped), and git ancestry for "merged". Nothing is
|
|
9
|
+
// written.
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
17
|
+
import { latestAdjudications, readFindingsAccount } from "./findings.js";
|
|
18
|
+
import { nextReply } from "./notify.js";
|
|
19
|
+
|
|
20
|
+
function sha256File(path) {
|
|
21
|
+
return `sha256:${createHash("sha256").update(readFileSync(path, "utf8"), "utf8").digest("hex")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readJsonl(path) {
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return readFileSync(path, "utf8")
|
|
29
|
+
.split("\n")
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((line) => {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(line);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function artifactStatus(workDir, decisions, artifact) {
|
|
42
|
+
const path = join(workDir, `${artifact}.md`);
|
|
43
|
+
if (!existsSync(path)) {
|
|
44
|
+
return { exists: false, accepted: false, stale: false };
|
|
45
|
+
}
|
|
46
|
+
const digest = sha256File(path);
|
|
47
|
+
const accepts = decisions.filter((row) => row.transition === `accept-${artifact}` && row.decision === "approved");
|
|
48
|
+
const latest = accepts[accepts.length - 1];
|
|
49
|
+
if (!latest) {
|
|
50
|
+
return { exists: true, accepted: false, stale: false };
|
|
51
|
+
}
|
|
52
|
+
const stale = latest.subject?.digest !== digest;
|
|
53
|
+
return { exists: true, accepted: !stale, stale, by: latest.by, at: latest.ts };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAncestor(repoRoot, sha, ref) {
|
|
57
|
+
try {
|
|
58
|
+
execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref], { stdio: "ignore" });
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function headRef(repoRoot) {
|
|
66
|
+
try {
|
|
67
|
+
return execFileSync("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
68
|
+
} catch {
|
|
69
|
+
return "HEAD";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Runs for a work: runtime ledgers first, run-records for the rest.
|
|
74
|
+
function runsFor(repoRoot, workId) {
|
|
75
|
+
const runs = new Map();
|
|
76
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
77
|
+
if (existsSync(runsDir)) {
|
|
78
|
+
for (const entry of readdirSync(runsDir)) {
|
|
79
|
+
const path = join(runsDir, entry, "events.jsonl");
|
|
80
|
+
if (!existsSync(path)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const ledger = EventLedger.open(path);
|
|
84
|
+
const state = ledger.state;
|
|
85
|
+
if (!state.run || state.run.work !== workId) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
runs.set(state.run.id, {
|
|
89
|
+
id: state.run.id,
|
|
90
|
+
status: state.run.status,
|
|
91
|
+
terminal: state.terminal,
|
|
92
|
+
pendingHuman: state.pendingHuman,
|
|
93
|
+
candidate: state.workspaces[state.run.id]?.candidate ?? null,
|
|
94
|
+
createdAt: ledger.events[0]?.ts ?? null,
|
|
95
|
+
lastAt: ledger.events[ledger.events.length - 1]?.ts ?? null,
|
|
96
|
+
state,
|
|
97
|
+
source: "runtime",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
|
|
102
|
+
if (existsSync(recordsDir)) {
|
|
103
|
+
for (const entry of readdirSync(recordsDir)) {
|
|
104
|
+
if (runs.has(entry)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const path = join(recordsDir, entry, "run-record.json");
|
|
108
|
+
if (!existsSync(path)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const record = JSON.parse(readFileSync(path, "utf8"));
|
|
113
|
+
runs.set(entry, {
|
|
114
|
+
id: entry,
|
|
115
|
+
status: record.terminal?.status ?? "UNKNOWN",
|
|
116
|
+
terminal: record.terminal ?? null,
|
|
117
|
+
pendingHuman: null,
|
|
118
|
+
candidate: record.workspaces?.[entry]?.candidate ?? null,
|
|
119
|
+
createdAt: record.startedAt ?? null,
|
|
120
|
+
lastAt: record.finishedAt ?? null,
|
|
121
|
+
state: null,
|
|
122
|
+
source: "run-record",
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// an unreadable record is reported as absent, not guessed
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...runs.values()].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {}) {
|
|
133
|
+
const workRoot = join(repoRoot, "delivery", "work");
|
|
134
|
+
const rows = [];
|
|
135
|
+
if (!existsSync(workRoot)) {
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
const mainRef = headRef(repoRoot);
|
|
139
|
+
for (const workId of readdirSync(workRoot).sort()) {
|
|
140
|
+
if (work && workId !== work) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const workDir = join(workRoot, workId);
|
|
144
|
+
if (!existsSync(join(workDir, "intent.md")) && !existsSync(join(workDir, "plan.md")) && !existsSync(join(workDir, "decisions.jsonl")) && !existsSync(join(workDir, "runs"))) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const decisions = readJsonl(join(workDir, "decisions.jsonl"));
|
|
148
|
+
const intent = artifactStatus(workDir, decisions, "intent");
|
|
149
|
+
const plan = artifactStatus(workDir, decisions, "plan");
|
|
150
|
+
const envFacts = existsSync(join(workDir, "env-facts.md"));
|
|
151
|
+
const findingRows = readFindingsAccount(repoRoot, workId);
|
|
152
|
+
const adjudicated = latestAdjudications(findingRows);
|
|
153
|
+
const openFindings = findingRows.filter(
|
|
154
|
+
(row) => row.kind === "finding" && (row.severity === "P0" || row.severity === "P1") && !adjudicated.has(row.fingerprint),
|
|
155
|
+
).length;
|
|
156
|
+
const runs = runsFor(repoRoot, workId);
|
|
157
|
+
const live = runs.filter((run) => run.status !== "SUPERSEDED");
|
|
158
|
+
const latest = live[live.length - 1] ?? null;
|
|
159
|
+
let merged = false;
|
|
160
|
+
if (latest?.candidate) {
|
|
161
|
+
merged = isAncestor(repoRoot, latest.candidate, mainRef);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let stage;
|
|
165
|
+
let next;
|
|
166
|
+
if (!intent.exists) {
|
|
167
|
+
stage = "NO_INTENT";
|
|
168
|
+
next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
|
|
169
|
+
} else if (!latest) {
|
|
170
|
+
if (!plan.exists) {
|
|
171
|
+
stage = intent.accepted ? "INTENT_ACCEPTED" : "INTENT_DRAFT";
|
|
172
|
+
next = intent.accepted
|
|
173
|
+
? `write delivery/work/${workId}/plan.md, then accept it`
|
|
174
|
+
: `buildbeat-v2 accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
|
|
175
|
+
} else if (!plan.accepted) {
|
|
176
|
+
stage = plan.stale ? "PLAN_STALE" : "PLAN_DRAFT";
|
|
177
|
+
next = `buildbeat-v2 accept --repo ${repoLabel} --work ${workId} --artifact plan --by <you>${plan.stale ? " # plan changed since acceptance" : ""}`;
|
|
178
|
+
} else {
|
|
179
|
+
stage = "READY_TO_RUN";
|
|
180
|
+
const configs = readdirSync(workDir).filter((name) => /^run-config.*\.ya?ml$/.test(name));
|
|
181
|
+
next =
|
|
182
|
+
configs.length > 0
|
|
183
|
+
? `buildbeat-v2 start --config delivery/work/${workId}/${configs[0]} --attempt new`
|
|
184
|
+
: `no run-config in delivery/work/${workId}: write one, or record the work as closed in decisions.jsonl if it was doc-only`;
|
|
185
|
+
}
|
|
186
|
+
} else if (latest.status === "RUNNING") {
|
|
187
|
+
stage = "RUNNING";
|
|
188
|
+
next = `buildbeat-v2 status --repo ${repoLabel} --run ${latest.id}`;
|
|
189
|
+
} else if (latest.status === "WAITING_HUMAN") {
|
|
190
|
+
stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
|
|
191
|
+
const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
|
|
192
|
+
next = replies[0] ?? `buildbeat-v2 inbox --repo ${repoLabel}`;
|
|
193
|
+
} else if (latest.status === "SUCCEEDED") {
|
|
194
|
+
if (merged) {
|
|
195
|
+
stage = "MERGED";
|
|
196
|
+
next = `release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}`;
|
|
197
|
+
} else {
|
|
198
|
+
stage = "MERGE_READY";
|
|
199
|
+
next = latest.candidate
|
|
200
|
+
? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
|
|
201
|
+
: "run succeeded without a candidate; nothing to merge";
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
stage = `STOPPED_${latest.status}`;
|
|
205
|
+
next = plan.accepted
|
|
206
|
+
? `decide: retry (buildbeat-v2 start ... --attempt new) or close the work`
|
|
207
|
+
: `plan not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
|
|
208
|
+
}
|
|
209
|
+
rows.push({
|
|
210
|
+
work: workId,
|
|
211
|
+
stage,
|
|
212
|
+
intent,
|
|
213
|
+
plan,
|
|
214
|
+
envFacts,
|
|
215
|
+
openFindings,
|
|
216
|
+
runs: runs.length,
|
|
217
|
+
latest: latest
|
|
218
|
+
? { id: latest.id, status: latest.status, candidate: latest.candidate, at: latest.lastAt, source: latest.source, terminalReason: latest.terminal?.reason ?? null, waiting: latest.pendingHuman?.transition ?? null }
|
|
219
|
+
: null,
|
|
220
|
+
merged,
|
|
221
|
+
next,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return rows;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function mark(status) {
|
|
228
|
+
if (!status.exists) {
|
|
229
|
+
return "–";
|
|
230
|
+
}
|
|
231
|
+
if (status.stale) {
|
|
232
|
+
return "stale";
|
|
233
|
+
}
|
|
234
|
+
return status.accepted ? "✓" : "draft";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function renderOverview(rows) {
|
|
238
|
+
if (rows.length === 0) {
|
|
239
|
+
return "overview: no work items under delivery/work";
|
|
240
|
+
}
|
|
241
|
+
const lines = [];
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
lines.push(`${row.work} ${row.stage}`);
|
|
244
|
+
const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
|
|
245
|
+
if (row.openFindings > 0) {
|
|
246
|
+
// Unadjudicated, not necessarily unresolved: a fixer may have closed
|
|
247
|
+
// them without anyone recording a verdict. The number says "nobody
|
|
248
|
+
// ruled on these", which is exactly what a human should know.
|
|
249
|
+
parts.push(`unadjudicated P0/P1 findings ${row.openFindings}`);
|
|
250
|
+
}
|
|
251
|
+
if (row.envFacts) {
|
|
252
|
+
parts.push("env-facts ✓");
|
|
253
|
+
}
|
|
254
|
+
lines.push(` ${parts.join(" · ")}`);
|
|
255
|
+
if (row.latest) {
|
|
256
|
+
const cand = row.latest.candidate ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.merged ? " (merged)" : ""}` : "";
|
|
257
|
+
const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
|
|
258
|
+
const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
|
|
259
|
+
lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
|
|
260
|
+
}
|
|
261
|
+
lines.push(` next: ${row.next}`);
|
|
262
|
+
}
|
|
263
|
+
return lines.join("\n");
|
|
264
|
+
}
|