@haiyangbg/buildbeat 2.0.0-beta.4 → 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 +19 -1
- package/SKILL.md +14 -4
- package/docs/CLI.md +1 -1
- package/docs/RELEASING.md +1 -1
- package/docs/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +9 -0
- package/docs/v2/SPEC-0001-events-v1.md +5 -4
- package/docs/v2/guide/02-workflow-guide.md +34 -1
- package/docs/v2/guide/07-approval-guide.md +14 -0
- package/docs/v2/guide/10-recovery.md +3 -2
- package/example/.buildbeat/manifest.json +1 -1
- package/lessons.md +25 -0
- package/package.json +1 -1
- package/src/v2/adapters/mock.js +9 -2
- package/src/v2/cli/run.js +147 -6
- package/src/v2/domain/event-registry.js +1 -0
- package/src/v2/engine/reducer.js +27 -1
- package/src/v2/runtime/decisions.js +80 -0
- package/src/v2/runtime/orchestrator.js +122 -11
- package/src/v2/runtime/overview.js +56 -19
- 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 +3 -2
package/src/v2/engine/reducer.js
CHANGED
|
@@ -23,6 +23,8 @@ export function initialState() {
|
|
|
23
23
|
fingerprints: [],
|
|
24
24
|
consecutiveSameFailure: 0,
|
|
25
25
|
budgets: {},
|
|
26
|
+
budgetExtensions: {},
|
|
27
|
+
workReviewGrants: 0,
|
|
26
28
|
pendingHuman: null,
|
|
27
29
|
decisions: [],
|
|
28
30
|
approvals: [],
|
|
@@ -107,7 +109,12 @@ export function applyEvent(state, event) {
|
|
|
107
109
|
`step ${data.step} expected attempt ${expectedAttempt}, got ${data.attempt}`,
|
|
108
110
|
);
|
|
109
111
|
}
|
|
110
|
-
next.steps[data.step] = {
|
|
112
|
+
next.steps[data.step] = {
|
|
113
|
+
status: "RUNNING",
|
|
114
|
+
attempts: data.attempt,
|
|
115
|
+
detail: null,
|
|
116
|
+
infraAttempts: state.steps[data.step]?.infraAttempts ?? 0,
|
|
117
|
+
};
|
|
111
118
|
next.currentStep = data.step;
|
|
112
119
|
break;
|
|
113
120
|
}
|
|
@@ -120,6 +127,11 @@ export function applyEvent(state, event) {
|
|
|
120
127
|
}
|
|
121
128
|
next.steps[data.step].status = data.status === "succeeded" ? "SUCCEEDED" : "FAILED";
|
|
122
129
|
next.steps[data.step].detail = data.status;
|
|
130
|
+
if (data.infra === true) {
|
|
131
|
+
// A worker-infrastructure failure (backend outage, timeout, garbage
|
|
132
|
+
// output, exit 75) is not charged to the step's budget.
|
|
133
|
+
next.steps[data.step].infraAttempts = (step.infraAttempts ?? 0) + 1;
|
|
134
|
+
}
|
|
123
135
|
next.currentStep = null;
|
|
124
136
|
break;
|
|
125
137
|
}
|
|
@@ -168,6 +180,19 @@ export function applyEvent(state, event) {
|
|
|
168
180
|
next.budgets[data.kind] = { consumed: consumed + data.amount, remaining: data.remaining };
|
|
169
181
|
break;
|
|
170
182
|
}
|
|
183
|
+
case "BUDGET_EXTENDED": {
|
|
184
|
+
// A human approving resume-<step> after its budget ran out grants
|
|
185
|
+
// exactly one more attempt; the grant is a ledger fact, not a config
|
|
186
|
+
// edit, so the effective cap is replayable.
|
|
187
|
+
if (data.scope === "work") {
|
|
188
|
+
// Work-level review cap (budgets.reviewRoundsPerWork) lifted once by
|
|
189
|
+
// a human for this run.
|
|
190
|
+
next.workReviewGrants = (state.workReviewGrants ?? 0) + data.amount;
|
|
191
|
+
} else {
|
|
192
|
+
next.budgetExtensions[data.step] = (state.budgetExtensions?.[data.step] ?? 0) + data.amount;
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
171
196
|
case "HUMAN_REQUESTED": {
|
|
172
197
|
next.pendingHuman = {
|
|
173
198
|
transition: data.transition,
|
|
@@ -194,6 +219,7 @@ export function applyEvent(state, event) {
|
|
|
194
219
|
transition: data.transition,
|
|
195
220
|
subject: data.subject,
|
|
196
221
|
stale: false,
|
|
222
|
+
...(data.resumeAt ? { resumeAt: data.resumeAt } : {}),
|
|
197
223
|
});
|
|
198
224
|
}
|
|
199
225
|
next.pendingHuman = null;
|
|
@@ -185,6 +185,86 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// A human (or the driving session) supplies the candidate: the fix was made
|
|
189
|
+
// by hand in the run's worktree and committed, so the fixer step has nothing
|
|
190
|
+
// to do. The pending request is answered with the adopted commit as its
|
|
191
|
+
// subject and the run resumes at `resumeAt` (verify, by convention). Real
|
|
192
|
+
// incident: hand fixes inside a run cost a no-op fixer and an extra verify
|
|
193
|
+
// each time (a frontend run reached verify #5 and fix #3 for three hand
|
|
194
|
+
// fixes). The commit must already be the worktree HEAD: git is read back,
|
|
195
|
+
// the claim is not trusted.
|
|
196
|
+
export function adoptCandidate(repoRoot, runId, { sha, by = "human", resumeAt, ts } = {}) {
|
|
197
|
+
if (!sha || typeof sha !== "string" || sha.length < 7) {
|
|
198
|
+
throw new DecisionError("adopt requires a commit sha (at least 7 characters)");
|
|
199
|
+
}
|
|
200
|
+
if (!resumeAt) {
|
|
201
|
+
throw new DecisionError("adopt requires the step to resume at (resumeAt)");
|
|
202
|
+
}
|
|
203
|
+
const ledger = openWaiting(repoRoot, runId);
|
|
204
|
+
const pending = ledger.state.pendingHuman;
|
|
205
|
+
if (pending.kind === "final-decision") {
|
|
206
|
+
throw new DecisionError("adopt is for a run waiting before fix/verify, not at the merge decision");
|
|
207
|
+
}
|
|
208
|
+
acquireLock(repoRoot, runId);
|
|
209
|
+
try {
|
|
210
|
+
const bound = ledger.state.workspaces[runId];
|
|
211
|
+
const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
|
|
212
|
+
if (!bound || !existsSync(worktreePath)) {
|
|
213
|
+
throw new DecisionError(`worktree missing for ${runId}; cannot adopt a candidate`);
|
|
214
|
+
}
|
|
215
|
+
const tree = readback(worktreePath);
|
|
216
|
+
if (tree.dirty) {
|
|
217
|
+
throw new DecisionError("worktree is dirty; commit the hand fix before adopting it");
|
|
218
|
+
}
|
|
219
|
+
if (!tree.head.startsWith(sha)) {
|
|
220
|
+
throw new DecisionError(`worktree HEAD is ${tree.head}, not ${sha}; adopt what git reads back`);
|
|
221
|
+
}
|
|
222
|
+
const when = ts ?? new Date().toISOString();
|
|
223
|
+
const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
|
|
224
|
+
if (bound.candidate !== tree.head) {
|
|
225
|
+
ledger.append({
|
|
226
|
+
type: "CANDIDATE_PINNED",
|
|
227
|
+
actor: { kind: "human", id: by },
|
|
228
|
+
ts: when,
|
|
229
|
+
data: { workspaceId: runId, base: bound.base, candidate: tree.head, adopted: true },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const subject = {
|
|
233
|
+
candidate: tree.head,
|
|
234
|
+
planDigest: ledger.state.run.planDigest,
|
|
235
|
+
evidenceDigest: lastEvidence?.digest ?? "UNVERIFIED",
|
|
236
|
+
};
|
|
237
|
+
const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
|
|
238
|
+
ledger.append({
|
|
239
|
+
type: "DECISION_RECORDED",
|
|
240
|
+
actor: { kind: "human", id: by },
|
|
241
|
+
ts: when,
|
|
242
|
+
data: {
|
|
243
|
+
decision: "approved",
|
|
244
|
+
transition: pending.transition,
|
|
245
|
+
subject,
|
|
246
|
+
decisionRef,
|
|
247
|
+
adopted: tree.head,
|
|
248
|
+
resumeAt,
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
recordDecisionFile(repoRoot, ledger.state.run.work, {
|
|
252
|
+
ts: when,
|
|
253
|
+
run: runId,
|
|
254
|
+
decisionRef,
|
|
255
|
+
decision: "approved",
|
|
256
|
+
transition: pending.transition,
|
|
257
|
+
subject,
|
|
258
|
+
by,
|
|
259
|
+
adopted: tree.head,
|
|
260
|
+
resumeAt,
|
|
261
|
+
});
|
|
262
|
+
return { adopted: tree.head, decisionRef, transition: pending.transition, resumeAt, state: ledger.state };
|
|
263
|
+
} finally {
|
|
264
|
+
releaseLock(repoRoot, runId);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
188
268
|
// Accepts a work artifact (plan, intent, spec) by binding a decision to the
|
|
189
269
|
// file's current digest in the Git plane. If the file changes afterwards,
|
|
190
270
|
// artifact.accepted evaluates false again — acceptance cannot go stale
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
releaseLock,
|
|
27
27
|
} from "../workspace/workspace-manager.js";
|
|
28
28
|
import { writeRunRecord } from "./run-record.js";
|
|
29
|
+
import { computeWorkCost } from "./work-cost.js";
|
|
29
30
|
import { assertRequires } from "./env-contract.js";
|
|
30
31
|
import { materialisePrompt } from "./envelope.js";
|
|
31
32
|
import { cacheKey, findReusableEvidence, lastReviewedCandidate, treeHash } from "./cache.js";
|
|
@@ -142,8 +143,18 @@ function makeContext(options, ledger, workspace) {
|
|
|
142
143
|
ledger,
|
|
143
144
|
workspace,
|
|
144
145
|
};
|
|
146
|
+
// Effective cap per step: run config `budgets:` beats the preset, the
|
|
147
|
+
// preset beats the global default, and every BUDGET_EXTENDED a human
|
|
148
|
+
// granted on this ledger adds to it. Real incident: the preset's two
|
|
149
|
+
// review rounds could not be raised from the run config, and approving
|
|
150
|
+
// resume-review re-asked the same question forever.
|
|
151
|
+
context.runBudgets = options.budgets ?? {};
|
|
145
152
|
context.maxAttemptsFor = (step) =>
|
|
146
|
-
|
|
153
|
+
(context.runBudgets.maxAttempts?.[step] ??
|
|
154
|
+
workflow.budgets?.maxAttempts?.[step] ??
|
|
155
|
+
maxAttemptsPerStep) +
|
|
156
|
+
(ledger.state.budgetExtensions?.[step] ?? 0) +
|
|
157
|
+
(ledger.state.steps[step]?.infraAttempts ?? 0);
|
|
147
158
|
context.policies = options.policies ?? [];
|
|
148
159
|
context.allowedPaths = options.allowedPaths ?? null;
|
|
149
160
|
context.reviewTriage = options.reviewTriage ?? null;
|
|
@@ -240,6 +251,7 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
240
251
|
if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
|
|
241
252
|
context.waitHuman(`resume-${step}`, [
|
|
242
253
|
`budget exhausted: ${step} failed its final attempt (maxAttempts=${context.maxAttemptsFor(step)}); not routing to fix`,
|
|
254
|
+
`approving resume-${step} grants one more attempt; rejecting ends the run`,
|
|
243
255
|
]);
|
|
244
256
|
return null;
|
|
245
257
|
}
|
|
@@ -266,13 +278,13 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
266
278
|
},
|
|
267
279
|
});
|
|
268
280
|
if (!to) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
281
|
+
// A workflow without an edge for this outcome is not a verdict on the
|
|
282
|
+
// candidate; the person decides whether to rerun the step or end the
|
|
283
|
+
// run. Terminal FAILED here used to kill runs whose reviewer had merely
|
|
284
|
+
// errored out.
|
|
285
|
+
context.waitHuman(`resume-${step}`, [
|
|
286
|
+
`no transition for (${step}, ${outcome}); approve resume-${step} to rerun the step, reject to end the run`,
|
|
287
|
+
]);
|
|
276
288
|
return null;
|
|
277
289
|
}
|
|
278
290
|
ledger.append({
|
|
@@ -320,6 +332,30 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
320
332
|
return;
|
|
321
333
|
}
|
|
322
334
|
|
|
335
|
+
// Work-level review cap (iteration 09): review rounds are counted across
|
|
336
|
+
// every run of the work, superseded ones included, so "one run per
|
|
337
|
+
// round" cannot slip past the per-run budget. Reaching the cap is a
|
|
338
|
+
// human decision (review once more, or merge/close as-is), not a stop.
|
|
339
|
+
const workCap = context.runBudgets.reviewRoundsPerWork;
|
|
340
|
+
if (workCap !== undefined && (stepDef.worker === "reviewer" || step === "review")) {
|
|
341
|
+
const prior = computeWorkCost(context.repoRoot, ledger.state.run.work, {
|
|
342
|
+
excludeRun: ledger.state.run.id,
|
|
343
|
+
});
|
|
344
|
+
const rounds = prior.reviewRounds + (ledger.state.steps[step]?.attempts ?? 0);
|
|
345
|
+
const allowed = workCap + (ledger.state.workReviewGrants ?? 0);
|
|
346
|
+
if (rounds >= allowed) {
|
|
347
|
+
context.waitHuman(
|
|
348
|
+
`enter-${step}`,
|
|
349
|
+
[
|
|
350
|
+
`work review cap reached: ${rounds} review round(s) across ${prior.runs + 1} run(s) of ${ledger.state.run.work} (budgets.reviewRoundsPerWork=${workCap})`,
|
|
351
|
+
`approve enter-${step} to review once more, or reject and merge/close the work on the evidence you have`,
|
|
352
|
+
],
|
|
353
|
+
"work-review-cap",
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
323
359
|
const preGate = runPolicyGate(context, "pre", step);
|
|
324
360
|
if (preGate.action === "block") {
|
|
325
361
|
ledger.append({
|
|
@@ -341,6 +377,7 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
341
377
|
if (attempt > maxAttempts) {
|
|
342
378
|
context.waitHuman(`resume-${step}`, [
|
|
343
379
|
`budget exhausted: ${step} would exceed maxAttempts=${maxAttempts}`,
|
|
380
|
+
`approving resume-${step} grants one more attempt; rejecting ends the run`,
|
|
344
381
|
]);
|
|
345
382
|
return;
|
|
346
383
|
}
|
|
@@ -531,18 +568,53 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
531
568
|
} else {
|
|
532
569
|
stepStatus = "succeeded";
|
|
533
570
|
}
|
|
571
|
+
// Infrastructure failure vs candidate failure. A timeout, a crash,
|
|
572
|
+
// garbage output or the worker's own "environment unavailable" signal
|
|
573
|
+
// (exit 75, EX_TEMPFAIL) says nothing about the candidate: no failure
|
|
574
|
+
// fingerprint, no fixer, the attempt is refunded, and a human decides
|
|
575
|
+
// when the backend is back. Real incidents: a worker backend outage
|
|
576
|
+
// (review exit 97) and non-JSON reviewer output killed five runs in two
|
|
577
|
+
// days as "no transition for (review, failed)"; PATH, port and host-load
|
|
578
|
+
// verify failures dispatched fixers five times.
|
|
579
|
+
const infra =
|
|
580
|
+
stepStatus === "timeout" ||
|
|
581
|
+
stepStatus === "crashed" ||
|
|
582
|
+
stepStatus === "invalid-output" ||
|
|
583
|
+
(stepStatus === "failed" && exec.exitCode === 75);
|
|
534
584
|
ledger.append({
|
|
535
585
|
type: "STEP_FINISHED",
|
|
536
586
|
actor: KERNEL,
|
|
537
587
|
ts: now(),
|
|
538
|
-
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode },
|
|
588
|
+
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode, ...(infra ? { infra: true } : {}) },
|
|
539
589
|
});
|
|
540
590
|
ledger.append({
|
|
541
591
|
type: "BUDGET_CONSUMED",
|
|
542
592
|
actor: KERNEL,
|
|
543
593
|
ts: now(),
|
|
544
|
-
data: {
|
|
594
|
+
data: {
|
|
595
|
+
kind: "attempts",
|
|
596
|
+
amount: infra ? 0 : 1,
|
|
597
|
+
remaining: context.maxAttemptsFor(step) - attempt,
|
|
598
|
+
},
|
|
545
599
|
});
|
|
600
|
+
if (infra) {
|
|
601
|
+
const cause =
|
|
602
|
+
stepStatus === "failed"
|
|
603
|
+
? "exit 75 (worker reports its environment unavailable)"
|
|
604
|
+
: stepStatus === "invalid-output"
|
|
605
|
+
? "output is not a worker envelope"
|
|
606
|
+
: stepStatus;
|
|
607
|
+
context.waitHuman(
|
|
608
|
+
`resume-${step}`,
|
|
609
|
+
[
|
|
610
|
+
`worker infrastructure failure at ${step}: ${cause}; not a candidate defect, attempt not charged`,
|
|
611
|
+
...(tree.dirty ? [`the failed worker left the worktree dirty; inspect before rerunning`] : []),
|
|
612
|
+
`approve resume-${step} to rerun once the backend/environment is back; reject to end the run`,
|
|
613
|
+
],
|
|
614
|
+
"infra",
|
|
615
|
+
);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
546
618
|
|
|
547
619
|
let blockingFindings = [];
|
|
548
620
|
if (envelope?.findings) {
|
|
@@ -920,12 +992,51 @@ export function resumeRun(options) {
|
|
|
920
992
|
});
|
|
921
993
|
return { runId, ledgerPath, state: ledger.state, resumed: true, stale: true, reason: null };
|
|
922
994
|
}
|
|
923
|
-
|
|
995
|
+
// An adopted candidate names where to resume (verify, by convention):
|
|
996
|
+
// the fixer step the request was waiting on has nothing left to do.
|
|
997
|
+
const step = approval.resumeAt ?? resumeStepFromTransition(approval.transition);
|
|
924
998
|
if (!step || !context.workflow.stepIds.has(step)) {
|
|
925
999
|
throw new OrchestratorError(
|
|
926
1000
|
`cannot derive a resume step from approved transition ${approval.transition}`,
|
|
927
1001
|
);
|
|
928
1002
|
}
|
|
1003
|
+
// An approved resume-<step> on an exhausted budget is the human saying
|
|
1004
|
+
// "one more"; record the grant before driving or the same request
|
|
1005
|
+
// comes straight back (the pilot's app-login runs ended CANCELLED
|
|
1006
|
+
// with their candidates in production because of exactly that).
|
|
1007
|
+
const requestKind = [...ledger.events]
|
|
1008
|
+
.reverse()
|
|
1009
|
+
.find((event) => event.type === "HUMAN_REQUESTED" && event.data.transition === approval.transition)
|
|
1010
|
+
?.data.kind;
|
|
1011
|
+
if (requestKind === "work-review-cap") {
|
|
1012
|
+
ledger.append({
|
|
1013
|
+
type: "BUDGET_EXTENDED",
|
|
1014
|
+
actor: KERNEL,
|
|
1015
|
+
ts: now(),
|
|
1016
|
+
data: {
|
|
1017
|
+
step,
|
|
1018
|
+
amount: 1,
|
|
1019
|
+
scope: "work",
|
|
1020
|
+
maxAttempts: (context.runBudgets.reviewRoundsPerWork ?? 0) + (state.workReviewGrants ?? 0) + 1,
|
|
1021
|
+
approvalRef: approval.decisionRef,
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
} else if (
|
|
1025
|
+
approval.transition.startsWith("resume-") &&
|
|
1026
|
+
(state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)
|
|
1027
|
+
) {
|
|
1028
|
+
ledger.append({
|
|
1029
|
+
type: "BUDGET_EXTENDED",
|
|
1030
|
+
actor: KERNEL,
|
|
1031
|
+
ts: now(),
|
|
1032
|
+
data: {
|
|
1033
|
+
step,
|
|
1034
|
+
amount: 1,
|
|
1035
|
+
maxAttempts: context.maxAttemptsFor(step) + 1,
|
|
1036
|
+
approvalRef: approval.decisionRef,
|
|
1037
|
+
},
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
929
1040
|
ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
|
|
930
1041
|
drive(context, step, { skipBoundaryOnce: true });
|
|
931
1042
|
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
@@ -16,12 +16,13 @@ import { join } from "node:path";
|
|
|
16
16
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
17
17
|
import { latestAdjudications, readFindingsAccount } from "./findings.js";
|
|
18
18
|
import { nextReply } from "./notify.js";
|
|
19
|
+
import { computeWorkCost, renderWorkCost } from "./work-cost.js";
|
|
19
20
|
|
|
20
21
|
function sha256File(path) {
|
|
21
22
|
return `sha256:${createHash("sha256").update(readFileSync(path, "utf8"), "utf8").digest("hex")}`;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function readJsonl(path) {
|
|
25
|
+
export function readJsonl(path) {
|
|
25
26
|
if (!existsSync(path)) {
|
|
26
27
|
return [];
|
|
27
28
|
}
|
|
@@ -38,7 +39,7 @@ function readJsonl(path) {
|
|
|
38
39
|
.filter(Boolean);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function artifactStatus(workDir, decisions, artifact) {
|
|
42
|
+
export function artifactStatus(workDir, decisions, artifact) {
|
|
42
43
|
const path = join(workDir, `${artifact}.md`);
|
|
43
44
|
if (!existsSync(path)) {
|
|
44
45
|
return { exists: false, accepted: false, stale: false };
|
|
@@ -93,6 +94,8 @@ function runsFor(repoRoot, workId) {
|
|
|
93
94
|
candidate: state.workspaces[state.run.id]?.candidate ?? null,
|
|
94
95
|
createdAt: ledger.events[0]?.ts ?? null,
|
|
95
96
|
lastAt: ledger.events[ledger.events.length - 1]?.ts ?? null,
|
|
97
|
+
workflow: state.run.workflowRef ?? null,
|
|
98
|
+
steps: Object.keys(state.steps),
|
|
96
99
|
state,
|
|
97
100
|
source: "runtime",
|
|
98
101
|
});
|
|
@@ -118,6 +121,8 @@ function runsFor(repoRoot, workId) {
|
|
|
118
121
|
candidate: record.workspaces?.[entry]?.candidate ?? null,
|
|
119
122
|
createdAt: record.startedAt ?? null,
|
|
120
123
|
lastAt: record.finishedAt ?? null,
|
|
124
|
+
workflow: record.workflow ?? null,
|
|
125
|
+
steps: Object.keys(record.attempts ?? {}),
|
|
121
126
|
state: null,
|
|
122
127
|
source: "run-record",
|
|
123
128
|
});
|
|
@@ -156,14 +161,31 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
156
161
|
const runs = runsFor(repoRoot, workId);
|
|
157
162
|
const live = runs.filter((run) => run.status !== "SUPERSEDED");
|
|
158
163
|
const latest = live[live.length - 1] ?? null;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
// "Merged" is a fact about any candidate of the work, not only the
|
|
165
|
+
// latest run's: a pilot's shipped candidate sat behind a CANCELLED run
|
|
166
|
+
// (its in-run review budget ran out and closure happened elsewhere) and
|
|
167
|
+
// overview reported the shipped work as STOPPED_CANCELLED.
|
|
168
|
+
const mergedRun =
|
|
169
|
+
[...runs].reverse().find((run) => run.candidate && isAncestor(repoRoot, run.candidate, mainRef)) ?? null;
|
|
170
|
+
const merged = Boolean(mergedRun);
|
|
171
|
+
const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
|
|
172
|
+
const isReleaseLane = (run) =>
|
|
173
|
+
Boolean(run) && (run.workflow === "release-readback" || run.steps.some((step) => RELEASE_STEPS.includes(step)));
|
|
174
|
+
|
|
175
|
+
// A Work is closed by an explicit row in decisions.jsonl:
|
|
176
|
+
// {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
|
|
177
|
+
// A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
|
|
178
|
+
// stale close row can never hide something that still needs a human.
|
|
179
|
+
const closure = [...decisions].reverse().find((row) => row.transition === "close-work");
|
|
180
|
+
const liveRun = latest && (latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
|
|
163
181
|
|
|
164
182
|
let stage;
|
|
165
183
|
let next;
|
|
166
|
-
if (!
|
|
184
|
+
if (closure && !liveRun) {
|
|
185
|
+
stage = closure.decision === "cancelled" ? "CANCELLED" : "CLOSED";
|
|
186
|
+
const result = typeof closure.subject?.result === "string" && closure.subject.result.length > 0 ? closure.subject.result : "see decisions.jsonl";
|
|
187
|
+
next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
|
|
188
|
+
} else if (!intent.exists) {
|
|
167
189
|
stage = "NO_INTENT";
|
|
168
190
|
next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
|
|
169
191
|
} else if (!latest) {
|
|
@@ -181,7 +203,7 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
181
203
|
next =
|
|
182
204
|
configs.length > 0
|
|
183
205
|
? `buildbeat-v2 start --config delivery/work/${workId}/${configs[0]} --attempt new`
|
|
184
|
-
: `no run-config in delivery/work/${workId}: write one, or
|
|
206
|
+
: `no run-config in delivery/work/${workId}: write one, or close it with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"..."}} if it was doc-only`;
|
|
185
207
|
}
|
|
186
208
|
} else if (latest.status === "RUNNING") {
|
|
187
209
|
stage = "RUNNING";
|
|
@@ -190,16 +212,21 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
190
212
|
stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
|
|
191
213
|
const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
|
|
192
214
|
next = replies[0] ?? `buildbeat-v2 inbox --repo ${repoLabel}`;
|
|
215
|
+
} else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
|
|
216
|
+
// A release-readback lane that reached wait-close and was approved is
|
|
217
|
+
// a closed release window, not "nothing to merge".
|
|
218
|
+
stage = "RELEASED";
|
|
219
|
+
next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
|
|
220
|
+
} else if (merged) {
|
|
221
|
+
stage = "MERGED";
|
|
222
|
+
next =
|
|
223
|
+
`candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}` +
|
|
224
|
+
(latest.status !== "SUCCEEDED" ? ` # latest run ${latest.id} ended ${latest.status} after the merge` : "");
|
|
193
225
|
} else if (latest.status === "SUCCEEDED") {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
}
|
|
226
|
+
stage = "MERGE_READY";
|
|
227
|
+
next = latest.candidate
|
|
228
|
+
? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
|
|
229
|
+
: "run succeeded without a candidate; nothing to merge";
|
|
203
230
|
} else {
|
|
204
231
|
stage = `STOPPED_${latest.status}`;
|
|
205
232
|
next = plan.accepted
|
|
@@ -214,10 +241,12 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
214
241
|
envFacts,
|
|
215
242
|
openFindings,
|
|
216
243
|
runs: runs.length,
|
|
244
|
+
cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
|
|
217
245
|
latest: latest
|
|
218
246
|
? { 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
247
|
: null,
|
|
220
248
|
merged,
|
|
249
|
+
mergedCandidate: mergedRun?.candidate ?? null,
|
|
221
250
|
next,
|
|
222
251
|
});
|
|
223
252
|
}
|
|
@@ -242,7 +271,8 @@ export function renderOverview(rows) {
|
|
|
242
271
|
for (const row of rows) {
|
|
243
272
|
lines.push(`${row.work} ${row.stage}`);
|
|
244
273
|
const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
|
|
245
|
-
|
|
274
|
+
const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(row.stage);
|
|
275
|
+
if (row.openFindings > 0 && !settled) {
|
|
246
276
|
// Unadjudicated, not necessarily unresolved: a fixer may have closed
|
|
247
277
|
// them without anyone recording a verdict. The number says "nobody
|
|
248
278
|
// ruled on these", which is exactly what a human should know.
|
|
@@ -252,8 +282,15 @@ export function renderOverview(rows) {
|
|
|
252
282
|
parts.push("env-facts ✓");
|
|
253
283
|
}
|
|
254
284
|
lines.push(` ${parts.join(" · ")}`);
|
|
285
|
+
if (row.cost) {
|
|
286
|
+
// What this work has already consumed across every run, superseded
|
|
287
|
+
// ones included: the number a "continue or cut" decision needs.
|
|
288
|
+
lines.push(` cost: ${renderWorkCost(row.cost)}`);
|
|
289
|
+
}
|
|
255
290
|
if (row.latest) {
|
|
256
|
-
const cand = row.latest.candidate
|
|
291
|
+
const cand = row.latest.candidate
|
|
292
|
+
? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
|
|
293
|
+
: "";
|
|
257
294
|
const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
|
|
258
295
|
const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
|
|
259
296
|
lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
|
|
@@ -9,6 +9,7 @@ import { join, relative } from "node:path";
|
|
|
9
9
|
|
|
10
10
|
import { canonicalJson } from "../storage/event-ledger.js";
|
|
11
11
|
import { normalizeRepoRef } from "./repo-ref.js";
|
|
12
|
+
import { ledgerCost } from "./work-cost.js";
|
|
12
13
|
|
|
13
14
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
14
15
|
|
|
@@ -40,12 +41,14 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
|
|
|
40
41
|
const record = {
|
|
41
42
|
run: first.run,
|
|
42
43
|
work: first.work,
|
|
44
|
+
workflow: state.run?.workflowRef ?? null,
|
|
43
45
|
terminal: state.terminal,
|
|
44
46
|
events: { from: first.seq, to: last.seq, lastDigest: last.digest },
|
|
45
47
|
startedAt: first.ts,
|
|
46
48
|
finishedAt: last.ts,
|
|
47
49
|
attempts,
|
|
48
50
|
budgets: state.budgets,
|
|
51
|
+
cost: ledgerCost(ledger),
|
|
49
52
|
workspaces,
|
|
50
53
|
evidence,
|
|
51
54
|
decisions: state.decisions,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Work-level cost (iteration 09): what a work has already consumed across
|
|
2
|
+
// every run of it, superseded ones included. Per-run budgets were bypassed
|
|
3
|
+
// by "one run per review round" (a pilot work ran 21 runs and 9 review
|
|
4
|
+
// rounds while the preset's two-round cap never fired), and a work that ate
|
|
5
|
+
// ten runs and a day was cut by the owner as "cost > benefit" with no
|
|
6
|
+
// number in front of them. Derived only: runtime ledgers first, run-records
|
|
7
|
+
// for runs whose runtime was wiped. Nothing is written.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
13
|
+
import { readFindingsAccount } from "./findings.js";
|
|
14
|
+
|
|
15
|
+
function emptyCost() {
|
|
16
|
+
return {
|
|
17
|
+
runs: 0,
|
|
18
|
+
reviewRounds: 0,
|
|
19
|
+
findings: 0,
|
|
20
|
+
humanWaits: 0,
|
|
21
|
+
infraFailures: 0,
|
|
22
|
+
workerMs: 0,
|
|
23
|
+
firstAt: null,
|
|
24
|
+
lastAt: null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Cost facts of one ledger: review rounds, human waits, infra failures and
|
|
29
|
+
// worker wall time (STEP_STARTED → STEP_FINISHED per attempt).
|
|
30
|
+
export function ledgerCost(ledger) {
|
|
31
|
+
const cost = { reviewRounds: 0, humanWaits: 0, infraFailures: 0, workerMs: 0 };
|
|
32
|
+
const open = new Map();
|
|
33
|
+
for (const event of ledger.events) {
|
|
34
|
+
if (event.type === "STEP_STARTED") {
|
|
35
|
+
open.set(`${event.data.step}#${event.data.attempt}`, Date.parse(event.ts));
|
|
36
|
+
} else if (event.type === "STEP_FINISHED") {
|
|
37
|
+
const key = `${event.data.step}#${event.data.attempt}`;
|
|
38
|
+
const startedAt = open.get(key);
|
|
39
|
+
if (startedAt !== undefined) {
|
|
40
|
+
const ms = Date.parse(event.ts) - startedAt;
|
|
41
|
+
if (Number.isFinite(ms) && ms > 0) {
|
|
42
|
+
cost.workerMs += ms;
|
|
43
|
+
}
|
|
44
|
+
open.delete(key);
|
|
45
|
+
}
|
|
46
|
+
if (event.data.step === "review") {
|
|
47
|
+
cost.reviewRounds += 1;
|
|
48
|
+
}
|
|
49
|
+
if (event.data.infra === true) {
|
|
50
|
+
cost.infraFailures += 1;
|
|
51
|
+
}
|
|
52
|
+
} else if (event.type === "HUMAN_REQUESTED") {
|
|
53
|
+
cost.humanWaits += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return cost;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function computeWorkCost(repoRoot, workId, { excludeRun = null } = {}) {
|
|
60
|
+
const cost = emptyCost();
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
63
|
+
if (existsSync(runsDir)) {
|
|
64
|
+
for (const entry of readdirSync(runsDir)) {
|
|
65
|
+
const path = join(runsDir, entry, "events.jsonl");
|
|
66
|
+
if (!existsSync(path)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const ledger = EventLedger.open(path);
|
|
70
|
+
const state = ledger.state;
|
|
71
|
+
if (ledger.corruption || !state.run || state.run.work !== workId) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
seen.add(state.run.id);
|
|
75
|
+
if (state.run.id === excludeRun) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const row = ledgerCost(ledger);
|
|
79
|
+
cost.runs += 1;
|
|
80
|
+
cost.reviewRounds += row.reviewRounds;
|
|
81
|
+
cost.humanWaits += row.humanWaits;
|
|
82
|
+
cost.infraFailures += row.infraFailures;
|
|
83
|
+
cost.workerMs += row.workerMs;
|
|
84
|
+
track(cost, ledger.events[0]?.ts, ledger.events[ledger.events.length - 1]?.ts);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
|
|
88
|
+
if (existsSync(recordsDir)) {
|
|
89
|
+
for (const entry of readdirSync(recordsDir)) {
|
|
90
|
+
if (seen.has(entry) || entry === excludeRun) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const path = join(recordsDir, entry, "run-record.json");
|
|
94
|
+
if (!existsSync(path)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let record;
|
|
98
|
+
try {
|
|
99
|
+
record = JSON.parse(readFileSync(path, "utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
cost.runs += 1;
|
|
104
|
+
// Records written before iteration 09 carry attempts and decisions
|
|
105
|
+
// only; the cost block is preferred when present.
|
|
106
|
+
cost.reviewRounds += record.cost?.reviewRounds ?? record.attempts?.review ?? 0;
|
|
107
|
+
cost.humanWaits += record.cost?.humanWaits ?? record.decisions?.length ?? 0;
|
|
108
|
+
cost.infraFailures += record.cost?.infraFailures ?? 0;
|
|
109
|
+
cost.workerMs += record.cost?.workerMs ?? 0;
|
|
110
|
+
track(cost, record.startedAt, record.finishedAt);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
cost.findings = readFindingsAccount(repoRoot, workId).filter((row) => row.kind === "finding").length;
|
|
114
|
+
return cost;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function track(cost, firstAt, lastAt) {
|
|
118
|
+
if (firstAt && (!cost.firstAt || firstAt < cost.firstAt)) {
|
|
119
|
+
cost.firstAt = firstAt;
|
|
120
|
+
}
|
|
121
|
+
if (lastAt && (!cost.lastAt || lastAt > cost.lastAt)) {
|
|
122
|
+
cost.lastAt = lastAt;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatWorkerMs(ms) {
|
|
127
|
+
if (!ms || ms < 1000) {
|
|
128
|
+
return "0s";
|
|
129
|
+
}
|
|
130
|
+
if (ms < 60000) {
|
|
131
|
+
return `${Math.round(ms / 1000)}s`;
|
|
132
|
+
}
|
|
133
|
+
const totalMinutes = Math.round(ms / 60000);
|
|
134
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
135
|
+
const minutes = totalMinutes % 60;
|
|
136
|
+
return hours > 0 ? `${hours}h${String(minutes).padStart(2, "0")}m` : `${minutes}m`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderWorkCost(cost) {
|
|
140
|
+
return [
|
|
141
|
+
`review rounds ${cost.reviewRounds}`,
|
|
142
|
+
`findings ${cost.findings}`,
|
|
143
|
+
`human waits ${cost.humanWaits}`,
|
|
144
|
+
...(cost.infraFailures > 0 ? [`infra failures ${cost.infraFailures}`] : []),
|
|
145
|
+
`worker ${formatWorkerMs(cost.workerMs)}`,
|
|
146
|
+
].join(" · ");
|
|
147
|
+
}
|