@haiyangbg/buildbeat 3.0.0 → 3.1.0
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 +27 -0
- package/README.en.md +4 -4
- package/README.md +1 -1
- package/SKILL.md +8 -5
- package/docs/README.md +3 -2
- package/docs/RELEASING.md +3 -1
- package/docs/v2/guide/01-quickstart.en.md +165 -0
- package/docs/v2/guide/01-quickstart.md +7 -3
- package/docs/v2/guide/02-workflow-guide.md +11 -0
- package/docs/v2/guide/06-evidence-guide.en.md +51 -0
- package/docs/v2/guide/06-evidence-guide.md +2 -0
- package/docs/v2/guide/07-approval-guide.en.md +117 -0
- package/docs/v2/guide/07-approval-guide.md +16 -2
- package/docs/v2/guide/10-recovery.en.md +99 -0
- package/docs/v2/guide/10-recovery.md +22 -4
- package/docs/v2/guide/README.md +4 -4
- package/example/.buildbeat/notify.yaml +13 -0
- package/example/.buildbeat/observe.yaml +31 -0
- package/example/AGENTS.md +73 -0
- package/example/BUILDBEAT.md +14 -0
- package/example/CLAUDE.md +7 -0
- package/example/README.md +25 -0
- package/example/delivery/envelope/prompts/builder.md +10 -0
- package/example/delivery/envelope/prompts/fixer.md +10 -0
- package/example/delivery/envelope/prompts/reviewer.md +13 -0
- package/example/delivery/envelope/worker.sh +70 -0
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/decisions.jsonl +3 -0
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/intent.md +24 -0
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/plan.md +20 -0
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/run-config.yaml +66 -0
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/runs/RUN-EXPORT-01/run-record.json +108 -0
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/workflow.yaml +44 -0
- package/example/gitignore.template +20 -0
- package/example/package.json +13 -0
- package/example/pm/decisions.md +8 -0
- package/example/src/export.js +25 -0
- package/example/src/ledger.js +16 -0
- package/example/tests/export.test.js +35 -0
- package/example//346/214/207/346/214/245/345/217/260.md +40 -0
- package/package.json +2 -1
- package/src/v2/cli/run-config-check.js +223 -0
- package/src/v2/cli/run.js +74 -14
- package/src/v2/engine/reducer.js +6 -0
- package/src/v2/engine/yaml-subset.js +44 -9
- package/src/v2/runtime/decisions.js +32 -30
- package/src/v2/runtime/gc.js +28 -3
- package/src/v2/runtime/orchestrator.js +223 -96
- package/src/v2/storage/event-ledger.js +22 -3
- package/src/v2/workspace/workspace-manager.js +182 -13
- package/templates/v2/run-config.example.yaml +3 -1
|
@@ -59,9 +59,13 @@ const ACTIVE_LOCK = "active-run";
|
|
|
59
59
|
function lockActive(repoRoot) {
|
|
60
60
|
try {
|
|
61
61
|
acquireLock(repoRoot, ACTIVE_LOCK);
|
|
62
|
-
} catch {
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// A lock whose owner is gone was already reclaimed inside acquireLock;
|
|
64
|
+
// what reaches here is held (or unreadable), and the owner is the one
|
|
65
|
+
// fact a blocked caller needs.
|
|
66
|
+
const detail = error.lock?.detail;
|
|
63
67
|
throw new OrchestratorError(
|
|
64
|
-
|
|
68
|
+
`another run is active in this repository (MVP allows a single active run)${detail ? `; ${detail}` : ""}`,
|
|
65
69
|
);
|
|
66
70
|
}
|
|
67
71
|
}
|
|
@@ -149,12 +153,17 @@ function makeContext(options, ledger, workspace) {
|
|
|
149
153
|
// review rounds could not be raised from the run config, and approving
|
|
150
154
|
// resume-review re-asked the same question forever.
|
|
151
155
|
context.runBudgets = options.budgets ?? {};
|
|
152
|
-
context.
|
|
156
|
+
context.budgetLimitFor = (step) =>
|
|
153
157
|
(context.runBudgets.maxAttempts?.[step] ??
|
|
154
158
|
workflow.budgets?.maxAttempts?.[step] ??
|
|
155
159
|
maxAttemptsPerStep) +
|
|
156
|
-
(ledger.state.budgetExtensions?.[step] ?? 0)
|
|
157
|
-
|
|
160
|
+
(ledger.state.budgetExtensions?.[step] ?? 0);
|
|
161
|
+
context.maxAttemptsFor = (step) => context.budgetLimitFor(step) +
|
|
162
|
+
(ledger.state.steps[step]?.infraAttempts ?? 0) +
|
|
163
|
+
(ledger.state.steps[step]?.freeAttempts ?? 0);
|
|
164
|
+
// Refunds must not move this ceiling: otherwise a success-only loop has
|
|
165
|
+
// an ever-growing limit. Human extensions deliberately raise it.
|
|
166
|
+
context.totalAttemptsFor = (step) => context.budgetLimitFor(step) * 3;
|
|
158
167
|
context.policies = options.policies ?? [];
|
|
159
168
|
context.allowedPaths = options.allowedPaths ?? null;
|
|
160
169
|
context.reviewTriage = options.reviewTriage ?? null;
|
|
@@ -179,17 +188,64 @@ function makeContext(options, ledger, workspace) {
|
|
|
179
188
|
evidenceDigest: lastEvidence?.digest ?? "UNVERIFIED",
|
|
180
189
|
};
|
|
181
190
|
};
|
|
182
|
-
context.waitHuman = (transition, reasons, kind = "boundary") => {
|
|
191
|
+
context.waitHuman = (transition, reasons, kind = "boundary", grants = []) => {
|
|
183
192
|
ledger.append({
|
|
184
193
|
type: "HUMAN_REQUESTED",
|
|
185
194
|
actor: KERNEL,
|
|
186
195
|
ts: context.now(),
|
|
187
|
-
data: { transition, subject: context.subjectNow(), reasons, kind },
|
|
196
|
+
data: { transition, subject: context.subjectNow(), reasons, kind, ...(grants.length ? { grants } : {}) },
|
|
188
197
|
});
|
|
189
198
|
};
|
|
190
199
|
return context;
|
|
191
200
|
}
|
|
192
201
|
|
|
202
|
+
// Keep the charged budget distinct from the worker invocation number.
|
|
203
|
+
function budgetUsage(context, step) {
|
|
204
|
+
const state = context.ledger.state.steps[step];
|
|
205
|
+
const attempts = state?.attempts ?? 0;
|
|
206
|
+
const used = attempts - (state?.infraAttempts ?? 0) - (state?.freeAttempts ?? 0);
|
|
207
|
+
const failures = context.ledger.events.filter((event) =>
|
|
208
|
+
event.type === "STEP_FINISHED" && event.data.step === step &&
|
|
209
|
+
event.data.status !== "succeeded" && event.data.infra !== true).length;
|
|
210
|
+
return { attempts, used, failures, limit: context.budgetLimitFor(step) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function budgetReasons(context, step, safeguard = false) {
|
|
214
|
+
const { attempts, used, failures, limit } = budgetUsage(context, step);
|
|
215
|
+
const charging = context.workflow.steps.find((item) => item.id === step)?.readonly
|
|
216
|
+
? "each round is charged" : "successful attempts are not charged";
|
|
217
|
+
return [
|
|
218
|
+
safeguard
|
|
219
|
+
? `${step} budget exhausted (runaway safeguard): ${attempts}/${context.totalAttemptsFor(step)} total attempt(s), ${failures} real failure(s)`
|
|
220
|
+
: `${step} budget exhausted: ${used}/${limit} charged attempt(s) used, ${failures} real failure(s) (${charging})`,
|
|
221
|
+
`approve resume-${step} = ${safeguard ? "raise the safeguard and continue" : "one more attempt"}; reject = end this run and decide the merge on the evidence you have`,
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function workReviewBudget(context, step) {
|
|
226
|
+
const stepDef = context.workflow.steps.find((item) => item.id === step);
|
|
227
|
+
const cap = context.runBudgets.reviewRoundsPerWork;
|
|
228
|
+
if (cap === undefined || !(step === "review" || stepDef?.worker === "reviewer")) return null;
|
|
229
|
+
const prior = computeWorkCost(context.repoRoot, context.ledger.state.run.work, {
|
|
230
|
+
excludeRun: context.ledger.state.run.id,
|
|
231
|
+
});
|
|
232
|
+
const rounds = prior.reviewRounds + (context.ledger.state.steps[step]?.attempts ?? 0);
|
|
233
|
+
const allowed = cap + (context.ledger.state.workReviewGrants ?? 0);
|
|
234
|
+
return { rounds, allowed, exhausted: rounds >= allowed };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Every cap the next attempt of `step` would hit. Recorded on the request
|
|
238
|
+
// at stop time, so one approval lifts both layers (run and work) at once.
|
|
239
|
+
function budgetGrants(context, step) {
|
|
240
|
+
const grants = [];
|
|
241
|
+
if ((context.ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step) ||
|
|
242
|
+
(context.ledger.state.steps[step]?.attempts ?? 0) >= context.totalAttemptsFor(step)) {
|
|
243
|
+
grants.push({ step, scope: "run" });
|
|
244
|
+
}
|
|
245
|
+
if (workReviewBudget(context, step)?.exhausted) grants.push({ step, scope: "work" });
|
|
246
|
+
return grants;
|
|
247
|
+
}
|
|
248
|
+
|
|
193
249
|
// Evaluates configured policies of `type` for `appliesTo`, records every
|
|
194
250
|
// verdict as a POLICY_EVALUATED event, and reports what the kernel must do.
|
|
195
251
|
// ADVISORY failures are recorded but never gate (doctor reports the gap).
|
|
@@ -249,10 +305,7 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
249
305
|
// A step that failed its final attempt can never run again, so routing
|
|
250
306
|
// to fix would spend a worker on a candidate nothing can verify.
|
|
251
307
|
if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
|
|
252
|
-
context.waitHuman(`resume-${step}`,
|
|
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`,
|
|
255
|
-
]);
|
|
308
|
+
context.waitHuman(`resume-${step}`, budgetReasons(context, step), "budget", budgetGrants(context, step));
|
|
256
309
|
return null;
|
|
257
310
|
}
|
|
258
311
|
}
|
|
@@ -336,24 +389,19 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
336
389
|
// every run of the work, superseded ones included, so "one run per
|
|
337
390
|
// round" cannot slip past the per-run budget. Reaching the cap is a
|
|
338
391
|
// human decision (review once more, or merge/close as-is), not a stop.
|
|
339
|
-
const
|
|
340
|
-
if (
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
],
|
|
353
|
-
"work-review-cap",
|
|
354
|
-
);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
392
|
+
const workBudget = workReviewBudget(context, step);
|
|
393
|
+
if (workBudget?.exhausted) {
|
|
394
|
+
const { failures } = budgetUsage(context, step);
|
|
395
|
+
context.waitHuman(
|
|
396
|
+
`enter-${step}`,
|
|
397
|
+
[
|
|
398
|
+
`${step} work review budget exhausted: ${workBudget.rounds}/${workBudget.allowed} review round(s) across the work, ${failures} real failure(s) in this run`,
|
|
399
|
+
`approve enter-${step} = one more review round (also lifts this run's review cap if it is spent); reject = end this run and decide the merge on the evidence you have`,
|
|
400
|
+
],
|
|
401
|
+
"work-review-cap",
|
|
402
|
+
budgetGrants(context, step),
|
|
403
|
+
);
|
|
404
|
+
return;
|
|
357
405
|
}
|
|
358
406
|
|
|
359
407
|
const preGate = runPolicyGate(context, "pre", step);
|
|
@@ -375,10 +423,12 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
375
423
|
const attempt = (ledger.state.steps[step]?.attempts ?? 0) + 1;
|
|
376
424
|
const maxAttempts = context.maxAttemptsFor(step);
|
|
377
425
|
if (attempt > maxAttempts) {
|
|
378
|
-
context.waitHuman(`resume-${step}`,
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
426
|
+
context.waitHuman(`resume-${step}`, budgetReasons(context, step), "budget", budgetGrants(context, step));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (attempt > context.totalAttemptsFor(step)) {
|
|
431
|
+
context.waitHuman(`resume-${step}`, budgetReasons(context, step, true), "budget", budgetGrants(context, step));
|
|
382
432
|
return;
|
|
383
433
|
}
|
|
384
434
|
|
|
@@ -581,11 +631,13 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
581
631
|
stepStatus === "crashed" ||
|
|
582
632
|
stepStatus === "invalid-output" ||
|
|
583
633
|
(stepStatus === "failed" && exec.exitCode === 75);
|
|
634
|
+
const free = stepStatus === "succeeded" && stepDef.readonly !== true;
|
|
584
635
|
ledger.append({
|
|
585
636
|
type: "STEP_FINISHED",
|
|
586
637
|
actor: KERNEL,
|
|
587
638
|
ts: now(),
|
|
588
|
-
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode,
|
|
639
|
+
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode,
|
|
640
|
+
...(infra ? { infra: true } : {}), ...(free ? { free: true } : {}) },
|
|
589
641
|
});
|
|
590
642
|
ledger.append({
|
|
591
643
|
type: "BUDGET_CONSUMED",
|
|
@@ -593,7 +645,7 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
593
645
|
ts: now(),
|
|
594
646
|
data: {
|
|
595
647
|
kind: "attempts",
|
|
596
|
-
amount: infra ? 0 : 1,
|
|
648
|
+
amount: infra || free ? 0 : 1,
|
|
597
649
|
remaining: context.maxAttemptsFor(step) - attempt,
|
|
598
650
|
},
|
|
599
651
|
});
|
|
@@ -736,26 +788,31 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
736
788
|
outcome = "succeeded";
|
|
737
789
|
}
|
|
738
790
|
const routed = settleOutcome(context, step, outcome, tree, exec);
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
context.
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
),
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
791
|
+
// Ask before spending fix/verify workers: one approval covers the next
|
|
792
|
+
// round and both review caps, with the grant bound to this request.
|
|
793
|
+
if (routed && outcome === "findings-blocking") {
|
|
794
|
+
const isReview = step === "review" || stepDef.worker === "reviewer";
|
|
795
|
+
const grants = isReview ? budgetGrants(context, step) : [];
|
|
796
|
+
const triage = context.reviewTriage === "required";
|
|
797
|
+
if (triage || grants.length) {
|
|
798
|
+
const { used, limit, failures } = budgetUsage(context, step);
|
|
799
|
+
const workBudget = workReviewBudget(context, step);
|
|
800
|
+
context.waitHuman(
|
|
801
|
+
`enter-${routed}`,
|
|
802
|
+
[
|
|
803
|
+
...(grants.length ? [
|
|
804
|
+
`${step} budget exhausted: ${used}/${limit} review round(s) used in this run${workBudget ? `, ${workBudget.rounds}/${workBudget.allowed} across the work` : ""}, ${failures} real failure(s); approve enter-${routed} = fix + re-verify + one more review round; reject = end this run and decide the merge on the evidence you have`,
|
|
805
|
+
] : []),
|
|
806
|
+
`review found ${blockingFindings.length} blocking finding(s); ${triage ? "triage" : "approve another round"} before ${routed} runs`,
|
|
807
|
+
...blockingFindings.slice(0, 5).map((finding) =>
|
|
808
|
+
`[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`),
|
|
809
|
+
`adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
|
|
810
|
+
],
|
|
811
|
+
triage ? "finding-triage" : "budget",
|
|
812
|
+
grants,
|
|
813
|
+
);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
759
816
|
}
|
|
760
817
|
step = routed;
|
|
761
818
|
}
|
|
@@ -797,13 +854,26 @@ function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
|
|
|
797
854
|
continue;
|
|
798
855
|
}
|
|
799
856
|
try {
|
|
800
|
-
|
|
857
|
+
// Re-read under the lock: the run may have been approved, resumed or
|
|
858
|
+
// stopped since the scan, and the scan's read must not be written to.
|
|
859
|
+
const locked = EventLedger.open(ledgerPath);
|
|
860
|
+
const fresh = locked.state;
|
|
861
|
+
if (
|
|
862
|
+
locked.corruption ||
|
|
863
|
+
!fresh.run ||
|
|
864
|
+
fresh.run.work !== workId ||
|
|
865
|
+
fresh.terminal ||
|
|
866
|
+
fresh.run.status !== "WAITING_HUMAN"
|
|
867
|
+
) {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
locked.append({
|
|
801
871
|
type: "RUN_TERMINAL",
|
|
802
872
|
actor: KERNEL,
|
|
803
873
|
ts: now(),
|
|
804
874
|
data: { status: "SUPERSEDED", reason: `superseded by ${newRunId} (same work ${workId})` },
|
|
805
875
|
});
|
|
806
|
-
writeRunRecord({ repoRoot, ledger, ts: now() });
|
|
876
|
+
writeRunRecord({ repoRoot, ledger: locked, ts: now() });
|
|
807
877
|
superseded.push(entry);
|
|
808
878
|
} finally {
|
|
809
879
|
releaseLock(repoRoot, entry);
|
|
@@ -917,15 +987,8 @@ function resumeStepFromTransition(transition) {
|
|
|
917
987
|
return null;
|
|
918
988
|
}
|
|
919
989
|
|
|
920
|
-
|
|
921
|
-
const { repoRoot,
|
|
922
|
-
if (!repoRoot || !runId) {
|
|
923
|
-
throw new OrchestratorError("repoRoot and runId are required");
|
|
924
|
-
}
|
|
925
|
-
if (options.requires?.length) {
|
|
926
|
-
assertRequires(options.requires);
|
|
927
|
-
}
|
|
928
|
-
const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
|
|
990
|
+
function resumeTarget(options, { ledger, ledgerPath }) {
|
|
991
|
+
const { repoRoot, workflowDigest, runId } = options;
|
|
929
992
|
const state = ledger.state;
|
|
930
993
|
if (!state.run) {
|
|
931
994
|
throw new OrchestratorError(`no ledger for run ${runId}; use startRun`);
|
|
@@ -936,12 +999,11 @@ export function resumeRun(options) {
|
|
|
936
999
|
);
|
|
937
1000
|
}
|
|
938
1001
|
if (state.terminal) {
|
|
939
|
-
return { runId, ledgerPath, state, resumed: false, reason: "run is terminal" };
|
|
1002
|
+
return { early: { runId, ledgerPath, state, resumed: false, reason: "run is terminal" } };
|
|
940
1003
|
}
|
|
941
1004
|
if (state.run.status === "WAITING_HUMAN" && state.pendingHuman) {
|
|
942
|
-
return { runId, ledgerPath, state, resumed: false, reason: "waiting on a human decision" };
|
|
1005
|
+
return { early: { runId, ledgerPath, state, resumed: false, reason: "waiting on a human decision" } };
|
|
943
1006
|
}
|
|
944
|
-
|
|
945
1007
|
const bound = state.workspaces[runId];
|
|
946
1008
|
if (!bound) {
|
|
947
1009
|
throw new OrchestratorError(`run ${runId} has no bound workspace; cannot resume`);
|
|
@@ -952,15 +1014,43 @@ export function resumeRun(options) {
|
|
|
952
1014
|
"worktree missing; recovery requires a human decision",
|
|
953
1015
|
);
|
|
954
1016
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1017
|
+
return {
|
|
1018
|
+
workspace: {
|
|
1019
|
+
workspaceId: runId,
|
|
1020
|
+
repoRoot,
|
|
1021
|
+
worktreePath,
|
|
1022
|
+
branch: bound.branch,
|
|
1023
|
+
base: bound.base,
|
|
1024
|
+
},
|
|
961
1025
|
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export function resumeRun(options) {
|
|
1029
|
+
const { repoRoot, runId, planDigest } = options;
|
|
1030
|
+
if (!repoRoot || !runId) {
|
|
1031
|
+
throw new OrchestratorError("repoRoot and runId are required");
|
|
1032
|
+
}
|
|
1033
|
+
if (options.requires?.length) {
|
|
1034
|
+
assertRequires(options.requires);
|
|
1035
|
+
}
|
|
1036
|
+
// The read before the locks only answers early (terminal, waiting on a
|
|
1037
|
+
// human) without taking the repository lock. Everything resume decides is
|
|
1038
|
+
// decided again on a ledger read under the locks: another session may
|
|
1039
|
+
// have approved, resumed or stopped the run in between, and writing
|
|
1040
|
+
// through the earlier read would fork the hash chain.
|
|
1041
|
+
const outside = resumeTarget(options, openLedgerFor(repoRoot, runId));
|
|
1042
|
+
if (outside.early) {
|
|
1043
|
+
return outside.early;
|
|
1044
|
+
}
|
|
962
1045
|
|
|
963
1046
|
return withRunLocks(repoRoot, runId, () => {
|
|
1047
|
+
const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
|
|
1048
|
+
const target = resumeTarget(options, { ledger, ledgerPath });
|
|
1049
|
+
if (target.early) {
|
|
1050
|
+
return target.early;
|
|
1051
|
+
}
|
|
1052
|
+
const { workspace } = target;
|
|
1053
|
+
const state = ledger.state;
|
|
964
1054
|
const context = makeContext(options, ledger, workspace);
|
|
965
1055
|
const now = context.now;
|
|
966
1056
|
|
|
@@ -1004,36 +1094,73 @@ export function resumeRun(options) {
|
|
|
1004
1094
|
// "one more"; record the grant before driving or the same request
|
|
1005
1095
|
// comes straight back (the pilot's app-login runs ended CANCELLED
|
|
1006
1096
|
// with their candidates in production because of exactly that).
|
|
1007
|
-
const
|
|
1008
|
-
.
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1097
|
+
const decisionIndex = ledger.events.findIndex((event) =>
|
|
1098
|
+
event.type === "DECISION_RECORDED" && event.data.decisionRef === approval.decisionRef);
|
|
1099
|
+
// APPROVAL_STALE is itself a new request, with no inherited grants.
|
|
1100
|
+
// Restrict lookup to this decision, rather than an older request for
|
|
1101
|
+
// the same transition (or one whose subject was refreshed).
|
|
1102
|
+
const request = ledger.events.slice(0, decisionIndex).reverse().find((event) =>
|
|
1103
|
+
event.type === "HUMAN_REQUESTED" || event.type === "APPROVAL_STALE");
|
|
1104
|
+
const requestData = request?.type === "HUMAN_REQUESTED" &&
|
|
1105
|
+
request.data.transition === approval.transition ? request.data : null;
|
|
1106
|
+
// The grant plan is fixed once, then pinned on the first
|
|
1107
|
+
// BUDGET_EXTENDED it produces; a resume after a crash between two
|
|
1108
|
+
// grants replays that plan instead of re-deriving it from a state the
|
|
1109
|
+
// first grant already raised.
|
|
1110
|
+
const applied = ledger.events.filter((event) =>
|
|
1111
|
+
event.type === "BUDGET_EXTENDED" && event.data.approvalRef === approval.decisionRef);
|
|
1112
|
+
let grants;
|
|
1113
|
+
if (Array.isArray(applied[0]?.data.grants)) {
|
|
1114
|
+
grants = applied[0].data.grants;
|
|
1024
1115
|
} else if (
|
|
1025
|
-
|
|
1026
|
-
(
|
|
1116
|
+
Array.isArray(requestData?.grants) &&
|
|
1117
|
+
(canonicalJson(requestData.subject) === canonicalJson(approval.subject) ||
|
|
1118
|
+
// resume --adopt answers this very request with a new candidate by
|
|
1119
|
+
// design; the grant belongs to the round, not to a candidate. Real
|
|
1120
|
+
// incident: the session's hand fix was adopted and the run stopped
|
|
1121
|
+
// again at resume-review for the round the human had just granted.
|
|
1122
|
+
(ledger.events[decisionIndex]?.data.adopted &&
|
|
1123
|
+
approval.subject.planDigest === requestData.subject.planDigest))
|
|
1027
1124
|
) {
|
|
1125
|
+
grants = requestData.grants;
|
|
1126
|
+
} else {
|
|
1127
|
+
// Requests without grants: ledgers written before grants existed,
|
|
1128
|
+
// or a request refreshed by APPROVAL_STALE.
|
|
1129
|
+
grants = [];
|
|
1130
|
+
const attempts = state.steps[step]?.attempts ?? 0;
|
|
1131
|
+
const runCapApproval = approval.transition.startsWith("resume-") &&
|
|
1132
|
+
(attempts >= context.maxAttemptsFor(step) || attempts >= context.totalAttemptsFor(step));
|
|
1133
|
+
if (requestData?.kind === "work-review-cap") grants.push({ step, scope: "work" });
|
|
1134
|
+
if (runCapApproval) grants.push({ step, scope: "run" });
|
|
1135
|
+
if (grants.length > 0) grants.push(...budgetGrants(context, step));
|
|
1136
|
+
}
|
|
1137
|
+
const plan = [];
|
|
1138
|
+
const planned = new Set();
|
|
1139
|
+
for (const grant of grants) {
|
|
1140
|
+
const key = `${grant.step}:${grant.scope}`;
|
|
1141
|
+
if (!planned.has(key)) {
|
|
1142
|
+
planned.add(key);
|
|
1143
|
+
plan.push({ step: grant.step, scope: grant.scope });
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const extended = new Set(applied.map((event) => `${event.data.step}:${event.data.scope ?? "run"}`));
|
|
1147
|
+
for (const grant of plan) {
|
|
1148
|
+
const key = `${grant.step}:${grant.scope}`;
|
|
1149
|
+
if (extended.has(key)) continue;
|
|
1150
|
+
extended.add(key);
|
|
1028
1151
|
ledger.append({
|
|
1029
1152
|
type: "BUDGET_EXTENDED",
|
|
1030
1153
|
actor: KERNEL,
|
|
1031
1154
|
ts: now(),
|
|
1032
1155
|
data: {
|
|
1033
|
-
step,
|
|
1156
|
+
step: grant.step,
|
|
1034
1157
|
amount: 1,
|
|
1035
|
-
|
|
1158
|
+
...(grant.scope === "work" ? { scope: "work" } : {}),
|
|
1159
|
+
maxAttempts: grant.scope === "work"
|
|
1160
|
+
? (context.runBudgets.reviewRoundsPerWork ?? 0) + (ledger.state.workReviewGrants ?? 0) + 1
|
|
1161
|
+
: context.maxAttemptsFor(grant.step) + 1,
|
|
1036
1162
|
approvalRef: approval.decisionRef,
|
|
1163
|
+
grants: plan,
|
|
1037
1164
|
},
|
|
1038
1165
|
});
|
|
1039
1166
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// further appends — recovery is a human decision, never a silent repair.
|
|
5
5
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
9
|
|
|
10
10
|
import { ENVELOPE_VERSION, GENESIS_DIGEST, validateEventInput } from "../domain/event-registry.js";
|
|
@@ -49,6 +49,9 @@ export class EventLedger {
|
|
|
49
49
|
#run = null;
|
|
50
50
|
#work = null;
|
|
51
51
|
#reducer;
|
|
52
|
+
// Bytes of the file this instance has read or written. A writer whose
|
|
53
|
+
// count no longer matches the file is stale: someone else appended since.
|
|
54
|
+
#size = 0;
|
|
52
55
|
|
|
53
56
|
constructor(filePath, reducer = RUN_REDUCER) {
|
|
54
57
|
this.path = filePath;
|
|
@@ -72,7 +75,10 @@ export class EventLedger {
|
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
#load() {
|
|
75
|
-
const
|
|
78
|
+
const raw = readFileSync(this.path);
|
|
79
|
+
this.#size = raw.length;
|
|
80
|
+
const lines = raw
|
|
81
|
+
.toString("utf8")
|
|
76
82
|
.split("\n")
|
|
77
83
|
.filter((line) => line.length > 0);
|
|
78
84
|
for (const [index, line] of lines.entries()) {
|
|
@@ -142,8 +148,21 @@ export class EventLedger {
|
|
|
142
148
|
};
|
|
143
149
|
event.digest = eventDigest(event);
|
|
144
150
|
const nextState = this.#reducer.applyEvent(this.state, event);
|
|
151
|
+
// Guard against a stale writer: every event carries seq and prev from
|
|
152
|
+
// this instance's memory, so appending after another writer would fork
|
|
153
|
+
// the hash chain and leave the ledger corrupted for good. Refuse
|
|
154
|
+
// instead; re-reading and retrying is always safe. Writers re-read under
|
|
155
|
+
// the run lock, so this only fires on a writer that forgot to.
|
|
156
|
+
const onDisk = existsSync(this.path) ? statSync(this.path).size : 0;
|
|
157
|
+
if (onDisk !== this.#size) {
|
|
158
|
+
throw new LedgerError(
|
|
159
|
+
`ledger for ${runId} changed on disk since it was read (another writer); re-read it and retry`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
145
163
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
146
|
-
appendFileSync(this.path,
|
|
164
|
+
appendFileSync(this.path, line, "utf8");
|
|
165
|
+
this.#size += Buffer.byteLength(line, "utf8");
|
|
147
166
|
this.events.push(event);
|
|
148
167
|
this.state = nextState;
|
|
149
168
|
this.lastDigest = event.digest;
|