@haiyangbg/buildbeat 3.2.0 → 3.3.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 +21 -0
- package/README.en.md +3 -3
- package/SKILL.md +5 -7
- package/docs/CAPABILITY-MATRIX.md +1 -1
- package/docs/README.md +1 -0
- package/docs/RELEASING.md +3 -3
- package/docs/v2/RFC-0001-product-definition.md +1 -1
- package/docs/v2/RFC-0002-domain-model.md +1 -1
- package/docs/v2/RFC-0003-workflow-policy.md +1 -1
- package/docs/v2/SPEC-0001-events-v1.md +2 -2
- package/docs/v2/guide/00-how-to-talk.en.md +61 -0
- package/docs/v2/guide/00-how-to-talk.md +2 -0
- package/docs/v2/guide/01-quickstart.en.md +2 -2
- package/docs/v2/guide/01-quickstart.md +2 -2
- package/docs/v2/guide/02-workflow-guide.en.md +127 -0
- package/docs/v2/guide/02-workflow-guide.md +7 -3
- package/docs/v2/guide/03-policy-guide.en.md +55 -0
- package/docs/v2/guide/03-policy-guide.md +2 -0
- package/docs/v2/guide/04-adapter-guide.en.md +66 -0
- package/docs/v2/guide/04-adapter-guide.md +2 -0
- package/docs/v2/guide/05-worker-contract.en.md +60 -0
- package/docs/v2/guide/05-worker-contract.md +2 -0
- package/docs/v2/guide/07-approval-guide.en.md +2 -2
- package/docs/v2/guide/07-approval-guide.md +4 -3
- package/docs/v2/guide/09-security-boundaries.en.md +41 -0
- package/docs/v2/guide/09-security-boundaries.md +3 -1
- package/docs/v2/guide/README.en.md +36 -0
- package/docs/v2/guide/README.md +8 -6
- package/docs/v2/skill/03-collaboration-rules.md +1 -1
- package/docs/v2/skill/04-rhythm-and-rituals.md +3 -1
- package/docs/v2/skill/06-bootstrap-and-takeover.md +1 -1
- package/example/AGENTS.md +1 -1
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/run-config.yaml +1 -3
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/workflow.yaml +3 -5
- package/package.json +3 -3
- package/src/v2/cli/run.js +43 -22
- package/src/v2/presets/software-delivery.yaml +3 -5
- package/src/v2/runtime/decisions.js +16 -0
- package/src/v2/runtime/notify.js +1 -1
- package/src/v2/runtime/orchestrator.js +504 -404
- package/src/v2/runtime/overview.js +2 -1
- package/templates/v2/AGENTS.md +1 -1
- package/templates/v2/run-config.example.yaml +5 -7
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
import { resolveRepoRef, toRepoRef } from "./repo-ref.js";
|
|
44
44
|
|
|
45
45
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
46
|
+
export const DEFAULT_REVIEW_ROUNDS_PER_WORK = 6;
|
|
46
47
|
|
|
47
48
|
export class OrchestratorError extends Error {
|
|
48
49
|
constructor(message) {
|
|
@@ -225,11 +226,16 @@ function makeContext(options, ledger, workspace) {
|
|
|
225
226
|
// granted on this ledger adds to it. Real incident: the preset's two
|
|
226
227
|
// review rounds could not be raised from the run config, and approving
|
|
227
228
|
// resume-review re-asked the same question forever.
|
|
229
|
+
// A review step without an explicit cap takes the work-level cap as its run
|
|
230
|
+
// cap, so review rounds are counted in one unit: the work.
|
|
228
231
|
context.runBudgets = options.budgets ?? {};
|
|
232
|
+
context.reviewRoundsPerWork = context.runBudgets.reviewRoundsPerWork ?? DEFAULT_REVIEW_ROUNDS_PER_WORK;
|
|
229
233
|
context.budgetLimitFor = (step) =>
|
|
230
234
|
(context.runBudgets.maxAttempts?.[step] ??
|
|
231
235
|
workflow.budgets?.maxAttempts?.[step] ??
|
|
232
|
-
|
|
236
|
+
(isReviewStep(step, workflow.steps.find((item) => item.id === step))
|
|
237
|
+
? context.reviewRoundsPerWork
|
|
238
|
+
: maxAttemptsPerStep)) +
|
|
233
239
|
(ledger.state.budgetExtensions?.[step] ?? 0);
|
|
234
240
|
context.maxAttemptsFor = (step) => context.budgetLimitFor(step) +
|
|
235
241
|
(ledger.state.steps[step]?.infraAttempts ?? 0) +
|
|
@@ -297,8 +303,8 @@ function budgetReasons(context, step, safeguard = false) {
|
|
|
297
303
|
|
|
298
304
|
function workReviewBudget(context, step) {
|
|
299
305
|
const stepDef = context.workflow.steps.find((item) => item.id === step);
|
|
300
|
-
const cap = context.
|
|
301
|
-
if (
|
|
306
|
+
const cap = context.reviewRoundsPerWork;
|
|
307
|
+
if (!isReviewStep(step, stepDef)) return null;
|
|
302
308
|
const prior = computeWorkCost(context.repoRoot, context.ledger.state.run.work, {
|
|
303
309
|
excludeRun: context.ledger.state.run.id,
|
|
304
310
|
});
|
|
@@ -319,6 +325,42 @@ function budgetGrants(context, step) {
|
|
|
319
325
|
return grants;
|
|
320
326
|
}
|
|
321
327
|
|
|
328
|
+
function reviewRoundAttempt(ref, step) {
|
|
329
|
+
const name = ref.split(/[\\/]/).pop();
|
|
330
|
+
if (!name.startsWith(`${step}-`)) return null;
|
|
331
|
+
const match = /^(\d+)\.json$/.exec(name.slice(step.length + 1));
|
|
332
|
+
return match ? Number(match[1]) : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Compares this round's blocking findings with the earlier review rounds of
|
|
336
|
+
// the same step in this run. A finding a fixer was already sent after that
|
|
337
|
+
// comes back, or more blocking findings than last round, means another
|
|
338
|
+
// automatic round would not converge; a shrinking set of new findings keeps
|
|
339
|
+
// going without asking anyone. Returns the stop reason, or null.
|
|
340
|
+
function reviewNotConverging(context, step, blockingFindings) {
|
|
341
|
+
const rounds = context.ledger.state.evidence.filter((item) =>
|
|
342
|
+
item.kind === "review" && reviewRoundAttempt(item.ref ?? "", step) !== null);
|
|
343
|
+
if (rounds.length < 2) return null;
|
|
344
|
+
const adjudicated = latestAdjudications(
|
|
345
|
+
readFindingsAccount(context.repoRoot, context.ledger.state.run.work),
|
|
346
|
+
);
|
|
347
|
+
const blocking = (round) => (round.findings ?? [])
|
|
348
|
+
.filter((finding) => finding.severity === "P0" || finding.severity === "P1")
|
|
349
|
+
.map(fingerprintFinding)
|
|
350
|
+
.filter((fingerprint) => adjudicated.get(fingerprint)?.action !== "dismiss");
|
|
351
|
+
const earlier = new Set(rounds.slice(0, -1).flatMap(blocking));
|
|
352
|
+
const lastCount = new Set(blocking(rounds.at(-2))).size;
|
|
353
|
+
const current = [...new Set(blockingFindings.map(fingerprintFinding))];
|
|
354
|
+
const repeated = current.filter((fingerprint) => earlier.has(fingerprint));
|
|
355
|
+
if (repeated.length > 0) {
|
|
356
|
+
return `${step} is not converging: ${repeated.length} blocking finding(s) came back after fix (${repeated.join(", ")}); approving runs fix + re-verify + one more review round, dismissing a finding stops it blocking`;
|
|
357
|
+
}
|
|
358
|
+
if (current.length > lastCount) {
|
|
359
|
+
return `${step} is not converging: ${current.length} blocking finding(s) this round, ${lastCount} last round; approving runs fix + re-verify + one more review round`;
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
322
364
|
// Evaluates configured policies of `type` for `appliesTo`, records every
|
|
323
365
|
// verdict as a POLICY_EVALUATED event, and reports what the kernel must do.
|
|
324
366
|
// ADVISORY failures are recorded but never gate (doctor reports the gap).
|
|
@@ -431,463 +473,521 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
431
473
|
return to;
|
|
432
474
|
}
|
|
433
475
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (workflow.terminal.has(step)) {
|
|
440
|
-
context.waitHuman(
|
|
441
|
-
`enter-${step}`,
|
|
442
|
-
["terminal step requires a human decision"],
|
|
443
|
-
"final-decision",
|
|
444
|
-
);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
if (context.stopAt.includes(step) && !(skipBoundaryOnce && firstStep)) {
|
|
448
|
-
context.waitHuman(`enter-${step}`, [`automation boundary: stopAt includes ${step}`]);
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
firstStep = false;
|
|
452
|
-
const stepDef = workflow.steps.find((candidate) => candidate.id === step);
|
|
453
|
-
const adapter = stepDef.worker ? adapters[stepDef.worker] : null;
|
|
454
|
-
if (!adapter) {
|
|
455
|
-
context.waitHuman(`enter-${step}`, [
|
|
456
|
-
`no adapter configured for worker ${stepDef.worker ?? "(none)"}; attended handoff`,
|
|
457
|
-
]);
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
476
|
+
// A step's review rounds are budgeted and capped per work: the step named
|
|
477
|
+
// "review" or any step run by the reviewer worker.
|
|
478
|
+
function isReviewStep(step, stepDef) {
|
|
479
|
+
return step === "review" || stepDef?.worker === "reviewer";
|
|
480
|
+
}
|
|
460
481
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
482
|
+
// Everything that may stop a run before a step starts: terminal step, stop
|
|
483
|
+
// boundary, missing adapter, the work-level review cap, pre policies and the
|
|
484
|
+
// step budgets. Returns what the step needs, or null when the run stopped.
|
|
485
|
+
function checkBeforeStep(context, step, { skipBoundary }) {
|
|
486
|
+
const { ledger, workflow, adapters, now } = context;
|
|
487
|
+
if (workflow.terminal.has(step)) {
|
|
488
|
+
context.waitHuman(
|
|
489
|
+
`enter-${step}`,
|
|
490
|
+
["terminal step requires a human decision"],
|
|
491
|
+
"final-decision",
|
|
492
|
+
);
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
if (context.stopAt.includes(step) && !skipBoundary) {
|
|
496
|
+
context.waitHuman(`enter-${step}`, [`automation boundary: stopAt includes ${step}`]);
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
const stepDef = workflow.steps.find((candidate) => candidate.id === step);
|
|
500
|
+
const adapter = stepDef.worker ? adapters[stepDef.worker] : null;
|
|
501
|
+
if (!adapter) {
|
|
502
|
+
context.waitHuman(`enter-${step}`, [
|
|
503
|
+
`no adapter configured for worker ${stepDef.worker ?? "(none)"}; attended handoff`,
|
|
504
|
+
]);
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
479
507
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
508
|
+
// Work-level review cap (iteration 09): review rounds are counted across
|
|
509
|
+
// every run of the work, superseded ones included, so "one run per
|
|
510
|
+
// round" cannot slip past the per-run budget. Reaching the cap is a
|
|
511
|
+
// human decision (review once more, or merge/close as-is), not a stop.
|
|
512
|
+
const workBudget = workReviewBudget(context, step);
|
|
513
|
+
if (workBudget?.exhausted) {
|
|
514
|
+
const { failures } = budgetUsage(context, step);
|
|
515
|
+
context.waitHuman(
|
|
516
|
+
`enter-${step}`,
|
|
517
|
+
[
|
|
518
|
+
`${step} work review budget exhausted: ${workBudget.rounds}/${workBudget.allowed} review round(s) across the work, ${failures} real failure(s) in this run`,
|
|
519
|
+
`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`,
|
|
520
|
+
],
|
|
521
|
+
"work-review-cap",
|
|
522
|
+
budgetGrants(context, step),
|
|
523
|
+
);
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const preGate = runPolicyGate(context, "pre", step);
|
|
528
|
+
if (preGate.action === "block") {
|
|
529
|
+
ledger.append({
|
|
530
|
+
type: "RUN_TERMINAL",
|
|
531
|
+
actor: KERNEL,
|
|
532
|
+
ts: now(),
|
|
533
|
+
data: { status: "FAILED", reason: `pre policy blocked ${step}` },
|
|
534
|
+
});
|
|
535
|
+
writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
if (preGate.action === "wait") {
|
|
539
|
+
context.waitHuman(`resume-${step}`, policyReasons(preGate.rows));
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const attempt = (ledger.state.steps[step]?.attempts ?? 0) + 1;
|
|
544
|
+
const maxAttempts = context.maxAttemptsFor(step);
|
|
545
|
+
if (attempt > maxAttempts) {
|
|
546
|
+
context.waitHuman(`resume-${step}`, budgetReasons(context, step), "budget", budgetGrants(context, step));
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (attempt > context.totalAttemptsFor(step)) {
|
|
551
|
+
context.waitHuman(`resume-${step}`, budgetReasons(context, step, true), "budget", budgetGrants(context, step));
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return { stepDef, adapter, attempt };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Records STEP_STARTED and prepares the worker's input: the read-only
|
|
559
|
+
// snapshot, the output path, anchored findings, the envelope prompt and the
|
|
560
|
+
// incremental-review base.
|
|
561
|
+
function beginStep(context, step, stepDef, attempt) {
|
|
562
|
+
const { ledger, workspace, now } = context;
|
|
563
|
+
const adapter = context.adapters[stepDef.worker];
|
|
564
|
+
ledger.append({
|
|
565
|
+
type: "STEP_STARTED",
|
|
566
|
+
actor: KERNEL,
|
|
567
|
+
ts: now(),
|
|
568
|
+
data: {
|
|
569
|
+
step,
|
|
570
|
+
attempt,
|
|
571
|
+
worker: stepDef.worker,
|
|
572
|
+
adapter: adapter.name,
|
|
573
|
+
workspaceId: workspace.workspaceId,
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
const before = stepDef.readonly ? readback(workspace.worktreePath) : null;
|
|
577
|
+
const outputsDir = join(context.runtimeDir, "runs", ledger.state.run.id, "outputs");
|
|
578
|
+
mkdirSync(outputsDir, { recursive: true });
|
|
579
|
+
const outputPath = join(outputsDir, `${step}-${attempt}.json`);
|
|
580
|
+
// Anchored review: readonly (reviewer) steps receive the adjudicated
|
|
581
|
+
// findings history so a fresh reviewer inherits settled verdicts instead
|
|
582
|
+
// of re-litigating them; writing steps get the latest review findings
|
|
583
|
+
// with their adjudication status (the fixer's worklist).
|
|
584
|
+
const input = { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt };
|
|
585
|
+
const anchor = buildAnchor(context.repoRoot, ledger.state.run.work);
|
|
586
|
+
if (anchor && stepDef.readonly) {
|
|
587
|
+
input.anchor = anchor;
|
|
588
|
+
} else if (anchor) {
|
|
589
|
+
const lastReview = [...ledger.state.evidence]
|
|
590
|
+
.reverse()
|
|
591
|
+
.find((item) => item.kind === "review");
|
|
592
|
+
if (lastReview?.findings?.length) {
|
|
593
|
+
const adjudicated = latestAdjudications(
|
|
594
|
+
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
595
|
+
);
|
|
596
|
+
input.findings = lastReview.findings.map((finding) => ({
|
|
597
|
+
severity: finding.severity,
|
|
598
|
+
summary: finding.summary,
|
|
599
|
+
fingerprint: fingerprintFinding(finding),
|
|
600
|
+
adjudication: adjudicated.get(fingerprintFinding(finding))?.action ?? "open",
|
|
601
|
+
}));
|
|
490
602
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
603
|
+
}
|
|
604
|
+
// Envelope (C6): the worker's prompt, materialised into the run
|
|
605
|
+
// directory and handed over as BUILDBEAT_PROMPT / input.envelope.
|
|
606
|
+
const prompt = materialisePrompt({
|
|
607
|
+
envelope: context.envelope,
|
|
608
|
+
worker: stepDef.worker,
|
|
609
|
+
runtimeDir: context.runtimeDir,
|
|
610
|
+
runId: ledger.state.run.id,
|
|
611
|
+
step,
|
|
612
|
+
attempt,
|
|
613
|
+
repoRoot: context.repoRoot,
|
|
614
|
+
});
|
|
615
|
+
if (prompt) {
|
|
616
|
+
input.envelope = { promptRef: prompt.ref, file: prompt.file, digest: context.envelope.digest, vars: context.envelope.vars };
|
|
617
|
+
}
|
|
618
|
+
// Incremental review (C7): tell a reviewer which candidate the last
|
|
619
|
+
// review saw when it is an ancestor of this one.
|
|
620
|
+
if (stepDef.readonly) {
|
|
621
|
+
const head = before.head;
|
|
622
|
+
const lastReviewed = lastReviewedCandidate(context.repoRoot, ledger.state.run.work, workspace.worktreePath, head);
|
|
623
|
+
if (lastReviewed) {
|
|
624
|
+
input.lastReviewed = lastReviewed;
|
|
494
625
|
}
|
|
626
|
+
}
|
|
627
|
+
return { before, outputPath, input, prompt };
|
|
628
|
+
}
|
|
495
629
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
630
|
+
// Runs the worker, or references identical passed evidence (verification
|
|
631
|
+
// reuse), then records the command evidence for the tree git reads back.
|
|
632
|
+
function executeOrReuse(context, step, stepDef, adapter, attempt, { before, outputPath, input, prompt }) {
|
|
633
|
+
const { ledger, workspace, now } = context;
|
|
634
|
+
// Verification reuse (C7): same tree + same worker + same envelope that
|
|
635
|
+
// already passed is referenced, not re-run. Failures always re-run.
|
|
636
|
+
let stepCacheKey = null;
|
|
637
|
+
let reused = null;
|
|
638
|
+
if (context.cache[step] === "tree") {
|
|
639
|
+
const current = readback(workspace.worktreePath);
|
|
640
|
+
if (!current.dirty) {
|
|
641
|
+
stepCacheKey = cacheKey({
|
|
642
|
+
tree: treeHash(workspace.worktreePath),
|
|
643
|
+
worker: stepDef.worker,
|
|
644
|
+
adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
|
|
645
|
+
adapterName: adapter.name,
|
|
646
|
+
envelopeDigest: context.envelope?.digest ?? null,
|
|
647
|
+
});
|
|
648
|
+
reused = findReusableEvidence(context.repoRoot, stepCacheKey);
|
|
501
649
|
}
|
|
650
|
+
}
|
|
651
|
+
let exec;
|
|
652
|
+
if (reused) {
|
|
653
|
+
const at = now();
|
|
654
|
+
exec = {
|
|
655
|
+
adapter: "cache",
|
|
656
|
+
command: `reuse ${reused.run} ${reused.evidenceRef}`,
|
|
657
|
+
exitCode: 0,
|
|
658
|
+
signal: null,
|
|
659
|
+
stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
|
|
660
|
+
stderr: "",
|
|
661
|
+
timedOut: false,
|
|
662
|
+
spawnError: null,
|
|
663
|
+
startedAt: at,
|
|
664
|
+
finishedAt: at,
|
|
665
|
+
};
|
|
666
|
+
} else {
|
|
667
|
+
exec = adapter.execute({
|
|
668
|
+
step,
|
|
669
|
+
worker: stepDef.worker,
|
|
670
|
+
workspacePath: workspace.worktreePath,
|
|
671
|
+
input,
|
|
672
|
+
timeoutMs: context.stepTimeoutMs,
|
|
673
|
+
outputPath,
|
|
674
|
+
// Live output streams + marker land in the run directory so `status`
|
|
675
|
+
// can answer "is it still doing something" while the step runs.
|
|
676
|
+
liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
|
|
677
|
+
promptPath: prompt?.path ?? null,
|
|
678
|
+
vars: context.envelope?.vars ?? null,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
const tree = readback(workspace.worktreePath);
|
|
682
|
+
const evidence = collectCommandEvidence({
|
|
683
|
+
runtimeDir: context.runtimeDir,
|
|
684
|
+
runId: ledger.state.run.id,
|
|
685
|
+
step,
|
|
686
|
+
attempt,
|
|
687
|
+
execResult: exec,
|
|
688
|
+
subject: tree.head,
|
|
689
|
+
grade: reused ? reused.grade : stepDef.grade ?? "L2",
|
|
690
|
+
redact: context.redact,
|
|
691
|
+
});
|
|
692
|
+
ledger.append({
|
|
693
|
+
type: "EVIDENCE_RECORDED",
|
|
694
|
+
actor: KERNEL,
|
|
695
|
+
ts: now(),
|
|
696
|
+
data: {
|
|
697
|
+
evidenceRef: toRepoRef(context.repoRoot, evidence.location),
|
|
698
|
+
kind: evidence.kind,
|
|
699
|
+
subject: evidence.subject,
|
|
700
|
+
digest: evidence.digest,
|
|
701
|
+
status: evidence.status,
|
|
702
|
+
grade: evidence.grade,
|
|
703
|
+
...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
|
|
704
|
+
...(reused ? { reused: { run: reused.run, evidenceRef: reused.evidenceRef, digest: reused.digest } } : {}),
|
|
705
|
+
},
|
|
706
|
+
});
|
|
502
707
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
708
|
+
return { exec, tree };
|
|
709
|
+
}
|
|
507
710
|
|
|
711
|
+
// Classifies what the step did and records it: read-only enforcement, the
|
|
712
|
+
// worker envelope, status and infrastructure failures, review findings,
|
|
713
|
+
// scope, the pinned candidate and post policies. Returns the result to
|
|
714
|
+
// route, or null when the run stopped.
|
|
715
|
+
function recordStepResult(context, step, stepDef, attempt, { before, outputPath }, { exec, tree }) {
|
|
716
|
+
const { ledger, workspace, now } = context;
|
|
717
|
+
// Read-only enforcement: a reviewer that changed the workspace is a
|
|
718
|
+
// policy violation, not a candidate (invariants 9/17).
|
|
719
|
+
if (stepDef.readonly && (tree.head !== before.head || tree.dirty !== before.dirty)) {
|
|
508
720
|
ledger.append({
|
|
509
|
-
type: "
|
|
721
|
+
type: "POLICY_EVALUATED",
|
|
510
722
|
actor: KERNEL,
|
|
511
723
|
ts: now(),
|
|
512
724
|
data: {
|
|
513
|
-
step,
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
725
|
+
policy: "step.readonly",
|
|
726
|
+
phase: "action",
|
|
727
|
+
result: "BLOCK",
|
|
728
|
+
enforcement: "LOCAL_ENFORCED",
|
|
729
|
+
reason: `read-only step ${step} modified the workspace`,
|
|
518
730
|
},
|
|
519
731
|
});
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
732
|
+
ledger.append({
|
|
733
|
+
type: "STEP_FINISHED",
|
|
734
|
+
actor: KERNEL,
|
|
735
|
+
ts: now(),
|
|
736
|
+
data: { step, attempt, status: "blocked" },
|
|
737
|
+
});
|
|
738
|
+
context.waitHuman(`resume-${step}`, [
|
|
739
|
+
`read-only step ${step} modified the workspace; human triage required`,
|
|
740
|
+
]);
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
let envelopeRaw = exec.envelope;
|
|
745
|
+
if (exec.envelope !== undefined && exec.envelope !== null) {
|
|
746
|
+
writeFileSync(outputPath, `${JSON.stringify(exec.envelope, null, 2)}\n`, "utf8");
|
|
747
|
+
} else if (existsSync(outputPath)) {
|
|
748
|
+
envelopeRaw = readFileSync(outputPath, "utf8");
|
|
749
|
+
}
|
|
750
|
+
const { envelope, error: envelopeError } = parseEnvelope(envelopeRaw);
|
|
751
|
+
|
|
752
|
+
let stepStatus;
|
|
753
|
+
if (exec.spawnError) {
|
|
754
|
+
stepStatus = "crashed";
|
|
755
|
+
} else if (exec.timedOut) {
|
|
756
|
+
stepStatus = "timeout";
|
|
757
|
+
} else if (exec.signal) {
|
|
758
|
+
stepStatus = "crashed";
|
|
759
|
+
} else if (exec.exitCode !== 0) {
|
|
760
|
+
stepStatus = "failed";
|
|
761
|
+
} else if (envelopeError) {
|
|
762
|
+
stepStatus = "invalid-output";
|
|
763
|
+
} else {
|
|
764
|
+
stepStatus = "succeeded";
|
|
765
|
+
}
|
|
766
|
+
// Infrastructure failure vs candidate failure. A timeout, a crash,
|
|
767
|
+
// garbage output or the worker's own "environment unavailable" signal
|
|
768
|
+
// (exit 75, EX_TEMPFAIL) says nothing about the candidate: no failure
|
|
769
|
+
// fingerprint, no fixer, the attempt is refunded, and a human decides
|
|
770
|
+
// when the backend is back. Real incidents: a worker backend outage
|
|
771
|
+
// (review exit 97) and non-JSON reviewer output killed five runs in two
|
|
772
|
+
// days as "no transition for (review, failed)"; PATH, port and host-load
|
|
773
|
+
// verify failures dispatched fixers five times.
|
|
774
|
+
const infra =
|
|
775
|
+
stepStatus === "timeout" ||
|
|
776
|
+
stepStatus === "crashed" ||
|
|
777
|
+
stepStatus === "invalid-output" ||
|
|
778
|
+
(stepStatus === "failed" && exec.exitCode === 75);
|
|
779
|
+
const free = stepStatus === "succeeded" && stepDef.readonly !== true;
|
|
780
|
+
ledger.append({
|
|
781
|
+
type: "STEP_FINISHED",
|
|
782
|
+
actor: KERNEL,
|
|
783
|
+
ts: now(),
|
|
784
|
+
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode,
|
|
785
|
+
...(infra ? { infra: true } : {}), ...(free ? { free: true } : {}) },
|
|
786
|
+
});
|
|
787
|
+
ledger.append({
|
|
788
|
+
type: "BUDGET_CONSUMED",
|
|
789
|
+
actor: KERNEL,
|
|
790
|
+
ts: now(),
|
|
791
|
+
data: {
|
|
792
|
+
kind: "attempts",
|
|
793
|
+
amount: infra || free ? 0 : 1,
|
|
794
|
+
remaining: context.maxAttemptsFor(step) - attempt,
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
if (infra) {
|
|
798
|
+
const cause =
|
|
799
|
+
stepStatus === "failed"
|
|
800
|
+
? "exit 75 (worker reports its environment unavailable)"
|
|
801
|
+
: stepStatus === "invalid-output"
|
|
802
|
+
? "output is not a worker envelope"
|
|
803
|
+
: stepStatus;
|
|
804
|
+
context.waitHuman(
|
|
805
|
+
`resume-${step}`,
|
|
806
|
+
[
|
|
807
|
+
`worker infrastructure failure at ${step}: ${cause}; not a candidate defect, attempt not charged`,
|
|
808
|
+
...(tree.dirty ? [`the failed worker left the worktree dirty; inspect before rerunning`] : []),
|
|
809
|
+
`approve resume-${step} to rerun once the backend/environment is back; reject to end the run`,
|
|
810
|
+
],
|
|
811
|
+
"infra",
|
|
812
|
+
);
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
let blockingFindings = [];
|
|
817
|
+
if (envelope?.findings) {
|
|
818
|
+
recordReviewFindings(context.repoRoot, ledger.state.run.work, {
|
|
819
|
+
run: ledger.state.run.id,
|
|
555
820
|
step,
|
|
556
821
|
attempt,
|
|
557
|
-
|
|
822
|
+
findings: envelope.findings,
|
|
823
|
+
ts: now(),
|
|
558
824
|
});
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
// Verification reuse (C7): same tree + same worker + same envelope that
|
|
572
|
-
// already passed is referenced, not re-run. Failures always re-run.
|
|
573
|
-
let stepCacheKey = null;
|
|
574
|
-
let reused = null;
|
|
575
|
-
if (context.cache[step] === "tree") {
|
|
576
|
-
const current = readback(workspace.worktreePath);
|
|
577
|
-
if (!current.dirty) {
|
|
578
|
-
stepCacheKey = cacheKey({
|
|
579
|
-
tree: treeHash(workspace.worktreePath),
|
|
580
|
-
worker: stepDef.worker,
|
|
581
|
-
adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
|
|
582
|
-
adapterName: adapter.name,
|
|
583
|
-
envelopeDigest: context.envelope?.digest ?? null,
|
|
584
|
-
});
|
|
585
|
-
reused = findReusableEvidence(context.repoRoot, stepCacheKey);
|
|
825
|
+
// A fingerprint a human dismissed stays visible in the evidence but no
|
|
826
|
+
// longer blocks: settled verdicts do not reopen without a human.
|
|
827
|
+
const adjudicated = latestAdjudications(
|
|
828
|
+
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
829
|
+
);
|
|
830
|
+
const suppressed = [];
|
|
831
|
+
blockingFindings = envelope.findings.filter((finding) => {
|
|
832
|
+
if (adjudicated.get(fingerprintFinding(finding))?.action === "dismiss") {
|
|
833
|
+
suppressed.push(fingerprintFinding(finding));
|
|
834
|
+
return false;
|
|
586
835
|
}
|
|
587
|
-
|
|
588
|
-
let exec;
|
|
589
|
-
if (reused) {
|
|
590
|
-
const at = now();
|
|
591
|
-
exec = {
|
|
592
|
-
adapter: "cache",
|
|
593
|
-
command: `reuse ${reused.run} ${reused.evidenceRef}`,
|
|
594
|
-
exitCode: 0,
|
|
595
|
-
signal: null,
|
|
596
|
-
stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
|
|
597
|
-
stderr: "",
|
|
598
|
-
timedOut: false,
|
|
599
|
-
spawnError: null,
|
|
600
|
-
startedAt: at,
|
|
601
|
-
finishedAt: at,
|
|
602
|
-
};
|
|
603
|
-
} else {
|
|
604
|
-
exec = adapter.execute({
|
|
605
|
-
step,
|
|
606
|
-
worker: stepDef.worker,
|
|
607
|
-
workspacePath: workspace.worktreePath,
|
|
608
|
-
input,
|
|
609
|
-
timeoutMs: context.stepTimeoutMs,
|
|
610
|
-
outputPath,
|
|
611
|
-
// Live output streams + marker land in the run directory so `status`
|
|
612
|
-
// can answer "is it still doing something" while the step runs.
|
|
613
|
-
liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
|
|
614
|
-
promptPath: prompt?.path ?? null,
|
|
615
|
-
vars: context.envelope?.vars ?? null,
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
const tree = readback(workspace.worktreePath);
|
|
619
|
-
const evidence = collectCommandEvidence({
|
|
620
|
-
runtimeDir: context.runtimeDir,
|
|
621
|
-
runId: ledger.state.run.id,
|
|
622
|
-
step,
|
|
623
|
-
attempt,
|
|
624
|
-
execResult: exec,
|
|
625
|
-
subject: tree.head,
|
|
626
|
-
grade: reused ? reused.grade : stepDef.grade ?? "L2",
|
|
627
|
-
redact: context.redact,
|
|
836
|
+
return finding.severity === "P0" || finding.severity === "P1";
|
|
628
837
|
});
|
|
629
838
|
ledger.append({
|
|
630
839
|
type: "EVIDENCE_RECORDED",
|
|
631
840
|
actor: KERNEL,
|
|
632
841
|
ts: now(),
|
|
633
842
|
data: {
|
|
634
|
-
evidenceRef: toRepoRef(context.repoRoot,
|
|
635
|
-
kind:
|
|
636
|
-
subject:
|
|
637
|
-
digest:
|
|
638
|
-
status:
|
|
639
|
-
grade:
|
|
640
|
-
|
|
641
|
-
...(
|
|
843
|
+
evidenceRef: toRepoRef(context.repoRoot, outputPath),
|
|
844
|
+
kind: "review",
|
|
845
|
+
subject: tree.head,
|
|
846
|
+
digest: sha256(canonicalJson(envelope)),
|
|
847
|
+
status: blockingFindings.length > 0 ? "failed" : "passed",
|
|
848
|
+
grade: "L2",
|
|
849
|
+
findings: envelope.findings,
|
|
850
|
+
...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
|
|
642
851
|
},
|
|
643
852
|
});
|
|
853
|
+
}
|
|
644
854
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
855
|
+
// Scope enforcement (B §10: out-of-scope changes stop the loop): any
|
|
856
|
+
// path changed outside the allowed set means this candidate cannot
|
|
857
|
+
// proceed, whatever the exit code said.
|
|
858
|
+
if (!stepDef.readonly && context.allowedPaths) {
|
|
859
|
+
const changed = listChangedPaths(workspace.worktreePath, workspace.base);
|
|
860
|
+
const violations = changed.filter(
|
|
861
|
+
(path) =>
|
|
862
|
+
!context.allowedPaths.some(
|
|
863
|
+
(prefix) =>
|
|
864
|
+
path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
|
865
|
+
),
|
|
866
|
+
);
|
|
867
|
+
if (violations.length > 0) {
|
|
648
868
|
ledger.append({
|
|
649
869
|
type: "POLICY_EVALUATED",
|
|
650
870
|
actor: KERNEL,
|
|
651
871
|
ts: now(),
|
|
652
872
|
data: {
|
|
653
|
-
policy: "
|
|
873
|
+
policy: "workspace.scope",
|
|
654
874
|
phase: "action",
|
|
655
875
|
result: "BLOCK",
|
|
656
876
|
enforcement: "LOCAL_ENFORCED",
|
|
657
|
-
reason: `
|
|
877
|
+
reason: `out-of-scope changes: ${violations.slice(0, 5).join(", ")}`,
|
|
658
878
|
},
|
|
659
879
|
});
|
|
660
|
-
ledger.append({
|
|
661
|
-
type: "STEP_FINISHED",
|
|
662
|
-
actor: KERNEL,
|
|
663
|
-
ts: now(),
|
|
664
|
-
data: { step, attempt, status: "blocked" },
|
|
665
|
-
});
|
|
666
880
|
context.waitHuman(`resume-${step}`, [
|
|
667
|
-
`
|
|
881
|
+
`worker changed paths outside the allowed scope: ${violations.slice(0, 5).join(", ")}`,
|
|
668
882
|
]);
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
let envelopeRaw = exec.envelope;
|
|
673
|
-
if (exec.envelope !== undefined && exec.envelope !== null) {
|
|
674
|
-
writeFileSync(outputPath, `${JSON.stringify(exec.envelope, null, 2)}\n`, "utf8");
|
|
675
|
-
} else if (existsSync(outputPath)) {
|
|
676
|
-
envelopeRaw = readFileSync(outputPath, "utf8");
|
|
883
|
+
return null;
|
|
677
884
|
}
|
|
678
|
-
|
|
885
|
+
}
|
|
679
886
|
|
|
680
|
-
|
|
681
|
-
if (
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
stepStatus = "crashed";
|
|
687
|
-
} else if (exec.exitCode !== 0) {
|
|
688
|
-
stepStatus = "failed";
|
|
689
|
-
} else if (envelopeError) {
|
|
690
|
-
stepStatus = "invalid-output";
|
|
691
|
-
} else {
|
|
692
|
-
stepStatus = "succeeded";
|
|
693
|
-
}
|
|
694
|
-
// Infrastructure failure vs candidate failure. A timeout, a crash,
|
|
695
|
-
// garbage output or the worker's own "environment unavailable" signal
|
|
696
|
-
// (exit 75, EX_TEMPFAIL) says nothing about the candidate: no failure
|
|
697
|
-
// fingerprint, no fixer, the attempt is refunded, and a human decides
|
|
698
|
-
// when the backend is back. Real incidents: a worker backend outage
|
|
699
|
-
// (review exit 97) and non-JSON reviewer output killed five runs in two
|
|
700
|
-
// days as "no transition for (review, failed)"; PATH, port and host-load
|
|
701
|
-
// verify failures dispatched fixers five times.
|
|
702
|
-
const infra =
|
|
703
|
-
stepStatus === "timeout" ||
|
|
704
|
-
stepStatus === "crashed" ||
|
|
705
|
-
stepStatus === "invalid-output" ||
|
|
706
|
-
(stepStatus === "failed" && exec.exitCode === 75);
|
|
707
|
-
const free = stepStatus === "succeeded" && stepDef.readonly !== true;
|
|
708
|
-
ledger.append({
|
|
709
|
-
type: "STEP_FINISHED",
|
|
710
|
-
actor: KERNEL,
|
|
711
|
-
ts: now(),
|
|
712
|
-
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode,
|
|
713
|
-
...(infra ? { infra: true } : {}), ...(free ? { free: true } : {}) },
|
|
714
|
-
});
|
|
715
|
-
ledger.append({
|
|
716
|
-
type: "BUDGET_CONSUMED",
|
|
717
|
-
actor: KERNEL,
|
|
718
|
-
ts: now(),
|
|
719
|
-
data: {
|
|
720
|
-
kind: "attempts",
|
|
721
|
-
amount: infra || free ? 0 : 1,
|
|
722
|
-
remaining: context.maxAttemptsFor(step) - attempt,
|
|
723
|
-
},
|
|
724
|
-
});
|
|
725
|
-
if (infra) {
|
|
726
|
-
const cause =
|
|
727
|
-
stepStatus === "failed"
|
|
728
|
-
? "exit 75 (worker reports its environment unavailable)"
|
|
729
|
-
: stepStatus === "invalid-output"
|
|
730
|
-
? "output is not a worker envelope"
|
|
731
|
-
: stepStatus;
|
|
732
|
-
context.waitHuman(
|
|
733
|
-
`resume-${step}`,
|
|
734
|
-
[
|
|
735
|
-
`worker infrastructure failure at ${step}: ${cause}; not a candidate defect, attempt not charged`,
|
|
736
|
-
...(tree.dirty ? [`the failed worker left the worktree dirty; inspect before rerunning`] : []),
|
|
737
|
-
`approve resume-${step} to rerun once the backend/environment is back; reject to end the run`,
|
|
738
|
-
],
|
|
739
|
-
"infra",
|
|
740
|
-
);
|
|
741
|
-
return;
|
|
887
|
+
if (stepStatus === "succeeded" && !stepDef.readonly) {
|
|
888
|
+
if (tree.dirty) {
|
|
889
|
+
context.waitHuman(`resume-${step}`, [
|
|
890
|
+
`step ${step} left a dirty worktree; a candidate must be a committed state`,
|
|
891
|
+
]);
|
|
892
|
+
return null;
|
|
742
893
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
if (envelope?.findings) {
|
|
746
|
-
recordReviewFindings(context.repoRoot, ledger.state.run.work, {
|
|
747
|
-
run: ledger.state.run.id,
|
|
748
|
-
step,
|
|
749
|
-
attempt,
|
|
750
|
-
findings: envelope.findings,
|
|
751
|
-
ts: now(),
|
|
752
|
-
});
|
|
753
|
-
// A fingerprint a human dismissed stays visible in the evidence but no
|
|
754
|
-
// longer blocks: settled verdicts do not reopen without a human.
|
|
755
|
-
const adjudicated = latestAdjudications(
|
|
756
|
-
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
757
|
-
);
|
|
758
|
-
const suppressed = [];
|
|
759
|
-
blockingFindings = envelope.findings.filter((finding) => {
|
|
760
|
-
if (adjudicated.get(fingerprintFinding(finding))?.action === "dismiss") {
|
|
761
|
-
suppressed.push(fingerprintFinding(finding));
|
|
762
|
-
return false;
|
|
763
|
-
}
|
|
764
|
-
return finding.severity === "P0" || finding.severity === "P1";
|
|
765
|
-
});
|
|
894
|
+
const pinned = ledger.state.workspaces[workspace.workspaceId]?.candidate;
|
|
895
|
+
if (tree.head !== (pinned ?? workspace.base)) {
|
|
766
896
|
ledger.append({
|
|
767
|
-
type: "
|
|
897
|
+
type: "CANDIDATE_PINNED",
|
|
768
898
|
actor: KERNEL,
|
|
769
899
|
ts: now(),
|
|
770
900
|
data: {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
digest: sha256(canonicalJson(envelope)),
|
|
775
|
-
status: blockingFindings.length > 0 ? "failed" : "passed",
|
|
776
|
-
grade: "L2",
|
|
777
|
-
findings: envelope.findings,
|
|
778
|
-
...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
|
|
901
|
+
workspaceId: workspace.workspaceId,
|
|
902
|
+
base: workspace.base,
|
|
903
|
+
candidate: tree.head,
|
|
779
904
|
},
|
|
780
905
|
});
|
|
781
906
|
}
|
|
907
|
+
}
|
|
782
908
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
(
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
);
|
|
795
|
-
if (violations.length > 0) {
|
|
796
|
-
ledger.append({
|
|
797
|
-
type: "POLICY_EVALUATED",
|
|
798
|
-
actor: KERNEL,
|
|
799
|
-
ts: now(),
|
|
800
|
-
data: {
|
|
801
|
-
policy: "workspace.scope",
|
|
802
|
-
phase: "action",
|
|
803
|
-
result: "BLOCK",
|
|
804
|
-
enforcement: "LOCAL_ENFORCED",
|
|
805
|
-
reason: `out-of-scope changes: ${violations.slice(0, 5).join(", ")}`,
|
|
806
|
-
},
|
|
807
|
-
});
|
|
808
|
-
context.waitHuman(`resume-${step}`, [
|
|
809
|
-
`worker changed paths outside the allowed scope: ${violations.slice(0, 5).join(", ")}`,
|
|
810
|
-
]);
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
909
|
+
if (stepStatus === "succeeded") {
|
|
910
|
+
const postGate = runPolicyGate(context, "post", step);
|
|
911
|
+
if (postGate.action === "block") {
|
|
912
|
+
ledger.append({
|
|
913
|
+
type: "RUN_TERMINAL",
|
|
914
|
+
actor: KERNEL,
|
|
915
|
+
ts: now(),
|
|
916
|
+
data: { status: "FAILED", reason: `post policy blocked ${step}` },
|
|
917
|
+
});
|
|
918
|
+
writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
|
|
919
|
+
return null;
|
|
813
920
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
context.waitHuman(`resume-${step}`, [
|
|
818
|
-
`step ${step} left a dirty worktree; a candidate must be a committed state`,
|
|
819
|
-
]);
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
const pinned = ledger.state.workspaces[workspace.workspaceId]?.candidate;
|
|
823
|
-
if (tree.head !== (pinned ?? workspace.base)) {
|
|
824
|
-
ledger.append({
|
|
825
|
-
type: "CANDIDATE_PINNED",
|
|
826
|
-
actor: KERNEL,
|
|
827
|
-
ts: now(),
|
|
828
|
-
data: {
|
|
829
|
-
workspaceId: workspace.workspaceId,
|
|
830
|
-
base: workspace.base,
|
|
831
|
-
candidate: tree.head,
|
|
832
|
-
},
|
|
833
|
-
});
|
|
834
|
-
}
|
|
921
|
+
if (postGate.action === "wait") {
|
|
922
|
+
context.waitHuman(`resume-${step}`, policyReasons(postGate.rows));
|
|
923
|
+
return null;
|
|
835
924
|
}
|
|
925
|
+
}
|
|
836
926
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
927
|
+
return { stepStatus, blockingFindings, tree, exec };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Settles the outcome and picks the next step; blocking findings stop once
|
|
931
|
+
// for triage, the review budget or a review that is not converging before
|
|
932
|
+
// any fixer runs. Returns the next step, or null when the run stopped.
|
|
933
|
+
function routeAfterStep(context, step, stepDef, { stepStatus, blockingFindings, tree, exec }) {
|
|
934
|
+
let outcome;
|
|
935
|
+
if (stepStatus !== "succeeded") {
|
|
936
|
+
outcome = "failed";
|
|
937
|
+
} else if (blockingFindings.length > 0) {
|
|
938
|
+
outcome = "findings-blocking";
|
|
939
|
+
} else {
|
|
940
|
+
outcome = "succeeded";
|
|
941
|
+
}
|
|
942
|
+
const routed = settleOutcome(context, step, outcome, tree, exec);
|
|
943
|
+
// Ask before spending fix/verify workers: one approval covers the next
|
|
944
|
+
// round and both review caps, with the grant bound to this request.
|
|
945
|
+
if (routed && outcome === "findings-blocking") {
|
|
946
|
+
const review = isReviewStep(step, stepDef);
|
|
947
|
+
const grants = review ? budgetGrants(context, step) : [];
|
|
948
|
+
const stalled = review ? reviewNotConverging(context, step, blockingFindings) : null;
|
|
949
|
+
const triage = context.reviewTriage === "required";
|
|
950
|
+
if (triage || grants.length || stalled) {
|
|
951
|
+
const { used, limit, failures } = budgetUsage(context, step);
|
|
952
|
+
const workBudget = workReviewBudget(context, step);
|
|
953
|
+
context.waitHuman(
|
|
954
|
+
`enter-${routed}`,
|
|
955
|
+
[
|
|
956
|
+
...(grants.length ? [
|
|
957
|
+
`${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`,
|
|
958
|
+
] : []),
|
|
959
|
+
...(stalled ? [stalled] : []),
|
|
960
|
+
`review found ${blockingFindings.length} blocking finding(s); ${triage ? "triage" : "approve another round"} before ${routed} runs`,
|
|
961
|
+
...blockingFindings.slice(0, 5).map((finding) =>
|
|
962
|
+
`[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`),
|
|
963
|
+
`adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
|
|
964
|
+
],
|
|
965
|
+
triage ? "finding-triage" : grants.length ? "budget" : "review-not-converging",
|
|
966
|
+
grants,
|
|
967
|
+
);
|
|
968
|
+
return null;
|
|
853
969
|
}
|
|
970
|
+
}
|
|
971
|
+
return routed;
|
|
972
|
+
}
|
|
854
973
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
974
|
+
function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
975
|
+
let step = startStep;
|
|
976
|
+
let firstStep = true;
|
|
977
|
+
while (step) {
|
|
978
|
+
const entry = checkBeforeStep(context, step, { skipBoundary: skipBoundaryOnce && firstStep });
|
|
979
|
+
firstStep = false;
|
|
980
|
+
if (!entry) {
|
|
981
|
+
return;
|
|
862
982
|
}
|
|
863
|
-
const
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
const triage = context.reviewTriage === "required";
|
|
870
|
-
if (triage || grants.length) {
|
|
871
|
-
const { used, limit, failures } = budgetUsage(context, step);
|
|
872
|
-
const workBudget = workReviewBudget(context, step);
|
|
873
|
-
context.waitHuman(
|
|
874
|
-
`enter-${routed}`,
|
|
875
|
-
[
|
|
876
|
-
...(grants.length ? [
|
|
877
|
-
`${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`,
|
|
878
|
-
] : []),
|
|
879
|
-
`review found ${blockingFindings.length} blocking finding(s); ${triage ? "triage" : "approve another round"} before ${routed} runs`,
|
|
880
|
-
...blockingFindings.slice(0, 5).map((finding) =>
|
|
881
|
-
`[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`),
|
|
882
|
-
`adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
|
|
883
|
-
],
|
|
884
|
-
triage ? "finding-triage" : "budget",
|
|
885
|
-
grants,
|
|
886
|
-
);
|
|
887
|
-
return;
|
|
888
|
-
}
|
|
983
|
+
const { stepDef, adapter, attempt } = entry;
|
|
984
|
+
const started = beginStep(context, step, stepDef, attempt);
|
|
985
|
+
const ran = executeOrReuse(context, step, stepDef, adapter, attempt, started);
|
|
986
|
+
const result = recordStepResult(context, step, stepDef, attempt, started, ran);
|
|
987
|
+
if (!result) {
|
|
988
|
+
return;
|
|
889
989
|
}
|
|
890
|
-
step =
|
|
990
|
+
step = routeAfterStep(context, step, stepDef, result);
|
|
891
991
|
}
|
|
892
992
|
}
|
|
893
993
|
|
|
@@ -1232,7 +1332,7 @@ export function resumeRun(options) {
|
|
|
1232
1332
|
amount: 1,
|
|
1233
1333
|
...(grant.scope === "work" ? { scope: "work" } : {}),
|
|
1234
1334
|
maxAttempts: grant.scope === "work"
|
|
1235
|
-
?
|
|
1335
|
+
? context.reviewRoundsPerWork + (ledger.state.workReviewGrants ?? 0) + 1
|
|
1236
1336
|
: context.maxAttemptsFor(grant.step) + 1,
|
|
1237
1337
|
approvalRef: approval.decisionRef,
|
|
1238
1338
|
grants: plan,
|