@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
|
@@ -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,7 +26,10 @@ 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";
|
|
31
|
+
import { materialisePrompt } from "./envelope.js";
|
|
32
|
+
import { cacheKey, findReusableEvidence, lastReviewedCandidate, treeHash } from "./cache.js";
|
|
30
33
|
import {
|
|
31
34
|
buildAnchor,
|
|
32
35
|
fingerprintFinding,
|
|
@@ -140,11 +143,25 @@ function makeContext(options, ledger, workspace) {
|
|
|
140
143
|
ledger,
|
|
141
144
|
workspace,
|
|
142
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 ?? {};
|
|
143
152
|
context.maxAttemptsFor = (step) =>
|
|
144
|
-
|
|
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);
|
|
145
158
|
context.policies = options.policies ?? [];
|
|
146
159
|
context.allowedPaths = options.allowedPaths ?? null;
|
|
147
160
|
context.reviewTriage = options.reviewTriage ?? null;
|
|
161
|
+
context.envelope = options.envelope ?? null;
|
|
162
|
+
context.cache = options.cache ?? {};
|
|
163
|
+
context.redact = options.redact ?? [];
|
|
164
|
+
context.adapterConfigs = options.adapterConfigs ?? {};
|
|
148
165
|
context.policyCtx = () => ({
|
|
149
166
|
state: ledger.state,
|
|
150
167
|
candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
|
|
@@ -234,6 +251,7 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
234
251
|
if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
|
|
235
252
|
context.waitHuman(`resume-${step}`, [
|
|
236
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`,
|
|
237
255
|
]);
|
|
238
256
|
return null;
|
|
239
257
|
}
|
|
@@ -260,13 +278,13 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
260
278
|
},
|
|
261
279
|
});
|
|
262
280
|
if (!to) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
]);
|
|
270
288
|
return null;
|
|
271
289
|
}
|
|
272
290
|
ledger.append({
|
|
@@ -314,6 +332,30 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
314
332
|
return;
|
|
315
333
|
}
|
|
316
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
|
+
|
|
317
359
|
const preGate = runPolicyGate(context, "pre", step);
|
|
318
360
|
if (preGate.action === "block") {
|
|
319
361
|
ledger.append({
|
|
@@ -335,6 +377,7 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
335
377
|
if (attempt > maxAttempts) {
|
|
336
378
|
context.waitHuman(`resume-${step}`, [
|
|
337
379
|
`budget exhausted: ${step} would exceed maxAttempts=${maxAttempts}`,
|
|
380
|
+
`approving resume-${step} grants one more attempt; rejecting ends the run`,
|
|
338
381
|
]);
|
|
339
382
|
return;
|
|
340
383
|
}
|
|
@@ -379,14 +422,76 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
379
422
|
}));
|
|
380
423
|
}
|
|
381
424
|
}
|
|
382
|
-
|
|
383
|
-
|
|
425
|
+
// Envelope (C6): the worker's prompt, materialised into the run
|
|
426
|
+
// directory and handed over as BUILDBEAT_PROMPT / input.envelope.
|
|
427
|
+
const prompt = materialisePrompt({
|
|
428
|
+
envelope: context.envelope,
|
|
384
429
|
worker: stepDef.worker,
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
430
|
+
runtimeDir: context.runtimeDir,
|
|
431
|
+
runId: ledger.state.run.id,
|
|
432
|
+
step,
|
|
433
|
+
attempt,
|
|
434
|
+
repoRoot: context.repoRoot,
|
|
389
435
|
});
|
|
436
|
+
if (prompt) {
|
|
437
|
+
input.envelope = { promptRef: prompt.ref, file: prompt.file, digest: context.envelope.digest, vars: context.envelope.vars };
|
|
438
|
+
}
|
|
439
|
+
// Incremental review (C7): tell a reviewer which candidate the last
|
|
440
|
+
// review saw when it is an ancestor of this one.
|
|
441
|
+
if (stepDef.readonly) {
|
|
442
|
+
const head = before.head;
|
|
443
|
+
const lastReviewed = lastReviewedCandidate(context.repoRoot, ledger.state.run.work, workspace.worktreePath, head);
|
|
444
|
+
if (lastReviewed) {
|
|
445
|
+
input.lastReviewed = lastReviewed;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Verification reuse (C7): same tree + same worker + same envelope that
|
|
449
|
+
// already passed is referenced, not re-run. Failures always re-run.
|
|
450
|
+
let stepCacheKey = null;
|
|
451
|
+
let reused = null;
|
|
452
|
+
if (context.cache[step] === "tree") {
|
|
453
|
+
const current = readback(workspace.worktreePath);
|
|
454
|
+
if (!current.dirty) {
|
|
455
|
+
stepCacheKey = cacheKey({
|
|
456
|
+
tree: treeHash(workspace.worktreePath),
|
|
457
|
+
worker: stepDef.worker,
|
|
458
|
+
adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
|
|
459
|
+
adapterName: adapter.name,
|
|
460
|
+
envelopeDigest: context.envelope?.digest ?? null,
|
|
461
|
+
});
|
|
462
|
+
reused = findReusableEvidence(context.repoRoot, stepCacheKey);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
let exec;
|
|
466
|
+
if (reused) {
|
|
467
|
+
const at = now();
|
|
468
|
+
exec = {
|
|
469
|
+
adapter: "cache",
|
|
470
|
+
command: `reuse ${reused.run} ${reused.evidenceRef}`,
|
|
471
|
+
exitCode: 0,
|
|
472
|
+
signal: null,
|
|
473
|
+
stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
|
|
474
|
+
stderr: "",
|
|
475
|
+
timedOut: false,
|
|
476
|
+
spawnError: null,
|
|
477
|
+
startedAt: at,
|
|
478
|
+
finishedAt: at,
|
|
479
|
+
};
|
|
480
|
+
} else {
|
|
481
|
+
exec = adapter.execute({
|
|
482
|
+
step,
|
|
483
|
+
worker: stepDef.worker,
|
|
484
|
+
workspacePath: workspace.worktreePath,
|
|
485
|
+
input,
|
|
486
|
+
timeoutMs: context.stepTimeoutMs,
|
|
487
|
+
outputPath,
|
|
488
|
+
// Live output streams + marker land in the run directory so `status`
|
|
489
|
+
// can answer "is it still doing something" while the step runs.
|
|
490
|
+
liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
|
|
491
|
+
promptPath: prompt?.path ?? null,
|
|
492
|
+
vars: context.envelope?.vars ?? null,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
390
495
|
const tree = readback(workspace.worktreePath);
|
|
391
496
|
const evidence = collectCommandEvidence({
|
|
392
497
|
runtimeDir: context.runtimeDir,
|
|
@@ -395,6 +500,8 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
395
500
|
attempt,
|
|
396
501
|
execResult: exec,
|
|
397
502
|
subject: tree.head,
|
|
503
|
+
grade: reused ? reused.grade : stepDef.grade ?? "L2",
|
|
504
|
+
redact: context.redact,
|
|
398
505
|
});
|
|
399
506
|
ledger.append({
|
|
400
507
|
type: "EVIDENCE_RECORDED",
|
|
@@ -407,6 +514,8 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
407
514
|
digest: evidence.digest,
|
|
408
515
|
status: evidence.status,
|
|
409
516
|
grade: evidence.grade,
|
|
517
|
+
...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
|
|
518
|
+
...(reused ? { reused: { run: reused.run, evidenceRef: reused.evidenceRef, digest: reused.digest } } : {}),
|
|
410
519
|
},
|
|
411
520
|
});
|
|
412
521
|
|
|
@@ -459,18 +568,53 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
459
568
|
} else {
|
|
460
569
|
stepStatus = "succeeded";
|
|
461
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);
|
|
462
584
|
ledger.append({
|
|
463
585
|
type: "STEP_FINISHED",
|
|
464
586
|
actor: KERNEL,
|
|
465
587
|
ts: now(),
|
|
466
|
-
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode },
|
|
588
|
+
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode, ...(infra ? { infra: true } : {}) },
|
|
467
589
|
});
|
|
468
590
|
ledger.append({
|
|
469
591
|
type: "BUDGET_CONSUMED",
|
|
470
592
|
actor: KERNEL,
|
|
471
593
|
ts: now(),
|
|
472
|
-
data: {
|
|
594
|
+
data: {
|
|
595
|
+
kind: "attempts",
|
|
596
|
+
amount: infra ? 0 : 1,
|
|
597
|
+
remaining: context.maxAttemptsFor(step) - attempt,
|
|
598
|
+
},
|
|
473
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
|
+
}
|
|
474
618
|
|
|
475
619
|
let blockingFindings = [];
|
|
476
620
|
if (envelope?.findings) {
|
|
@@ -617,6 +761,57 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
617
761
|
}
|
|
618
762
|
}
|
|
619
763
|
|
|
764
|
+
// Supersede (iteration 08, C2): a new Run for the same Work makes any older
|
|
765
|
+
// Run still waiting on a human moot — the human would be approving a
|
|
766
|
+
// candidate nobody intends to merge. Real incident: two WAITING_HUMAN runs
|
|
767
|
+
// sat in a pilot repo's inbox for a day after their successor had already
|
|
768
|
+
// shipped. Only WAITING_HUMAN runs are touched; RUNNING ones are protected
|
|
769
|
+
// by the active lock, terminal ones are already settled.
|
|
770
|
+
function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
|
|
771
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
772
|
+
const superseded = [];
|
|
773
|
+
const skipped = [];
|
|
774
|
+
if (!existsSync(runsDir)) {
|
|
775
|
+
return { superseded, skipped };
|
|
776
|
+
}
|
|
777
|
+
for (const entry of readdirSync(runsDir).sort()) {
|
|
778
|
+
if (entry === newRunId) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
const ledgerPath = join(runsDir, entry, "events.jsonl");
|
|
782
|
+
if (!existsSync(ledgerPath)) {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
786
|
+
const state = ledger.state;
|
|
787
|
+
if (ledger.corruption || !state.run || state.run.work !== workId) {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (state.terminal || state.run.status !== "WAITING_HUMAN") {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
acquireLock(repoRoot, entry);
|
|
795
|
+
} catch {
|
|
796
|
+
skipped.push({ run: entry, reason: "locked by another process" });
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
ledger.append({
|
|
801
|
+
type: "RUN_TERMINAL",
|
|
802
|
+
actor: KERNEL,
|
|
803
|
+
ts: now(),
|
|
804
|
+
data: { status: "SUPERSEDED", reason: `superseded by ${newRunId} (same work ${workId})` },
|
|
805
|
+
});
|
|
806
|
+
writeRunRecord({ repoRoot, ledger, ts: now() });
|
|
807
|
+
superseded.push(entry);
|
|
808
|
+
} finally {
|
|
809
|
+
releaseLock(repoRoot, entry);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return { superseded, skipped };
|
|
813
|
+
}
|
|
814
|
+
|
|
620
815
|
function openLedgerFor(repoRoot, runId) {
|
|
621
816
|
const ledgerPath = join(repoRoot, ".buildbeat", "runtime", "runs", runId, "events.jsonl");
|
|
622
817
|
const ledger = EventLedger.open(ledgerPath);
|
|
@@ -664,6 +859,10 @@ export function startRun(options) {
|
|
|
664
859
|
const workspace = createWorkspace({ repoRoot, runId, base });
|
|
665
860
|
const context = makeContext(options, ledger, workspace);
|
|
666
861
|
const now = context.now;
|
|
862
|
+
const supersession =
|
|
863
|
+
options.supersede === "off"
|
|
864
|
+
? { superseded: [], skipped: [] }
|
|
865
|
+
: supersedeWaitingRuns(repoRoot, workId, runId, now);
|
|
667
866
|
ledger.append({
|
|
668
867
|
type: "RUN_CREATED",
|
|
669
868
|
actor: KERNEL,
|
|
@@ -678,6 +877,8 @@ export function startRun(options) {
|
|
|
678
877
|
entry,
|
|
679
878
|
planDigest: planDigest ?? "UNVERIFIED",
|
|
680
879
|
intentDigest: intentDigest ?? "UNVERIFIED",
|
|
880
|
+
...(supersession.superseded.length > 0 ? { supersedes: supersession.superseded } : {}),
|
|
881
|
+
...(options.envelope ? { envelopeDigest: options.envelope.digest, envelopeSource: options.envelope.source } : {}),
|
|
681
882
|
},
|
|
682
883
|
});
|
|
683
884
|
ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
|
|
@@ -694,7 +895,15 @@ export function startRun(options) {
|
|
|
694
895
|
},
|
|
695
896
|
});
|
|
696
897
|
drive(context, entry);
|
|
697
|
-
return {
|
|
898
|
+
return {
|
|
899
|
+
runId,
|
|
900
|
+
workId,
|
|
901
|
+
ledgerPath,
|
|
902
|
+
state: ledger.state,
|
|
903
|
+
workspace,
|
|
904
|
+
superseded: supersession.superseded,
|
|
905
|
+
supersedeSkipped: supersession.skipped,
|
|
906
|
+
};
|
|
698
907
|
});
|
|
699
908
|
}
|
|
700
909
|
|
|
@@ -783,12 +992,51 @@ export function resumeRun(options) {
|
|
|
783
992
|
});
|
|
784
993
|
return { runId, ledgerPath, state: ledger.state, resumed: true, stale: true, reason: null };
|
|
785
994
|
}
|
|
786
|
-
|
|
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);
|
|
787
998
|
if (!step || !context.workflow.stepIds.has(step)) {
|
|
788
999
|
throw new OrchestratorError(
|
|
789
1000
|
`cannot derive a resume step from approved transition ${approval.transition}`,
|
|
790
1001
|
);
|
|
791
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
|
+
}
|
|
792
1040
|
ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
|
|
793
1041
|
drive(context, step, { skipBoundaryOnce: true });
|
|
794
1042
|
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|