@haiyangbg/buildbeat 3.1.0 → 3.2.1
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 +31 -10
- package/README.en.md +3 -3
- package/SKILL.md +19 -299
- package/docs/CAPABILITY-MATRIX.md +1 -1
- package/docs/README.md +6 -5
- package/docs/RELEASING.md +5 -5
- package/docs/v2/RFC-0001-product-definition.md +5 -5
- package/docs/v2/RFC-0002-domain-model.md +2 -2
- package/docs/v2/RFC-0003-workflow-policy.md +3 -3
- 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/02-workflow-guide.en.md +125 -0
- package/docs/v2/guide/02-workflow-guide.md +7 -1
- 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/09-security-boundaries.en.md +41 -0
- package/docs/v2/guide/09-security-boundaries.md +4 -2
- package/docs/v2/guide/10-recovery.en.md +1 -1
- package/docs/v2/guide/10-recovery.md +1 -1
- package/docs/v2/guide/README.en.md +36 -0
- package/docs/v2/guide/README.md +8 -6
- package/docs/v2/skill/01-principles.md +26 -0
- package/docs/v2/skill/02-project-layout.md +31 -0
- package/docs/v2/skill/03-collaboration-rules.md +45 -0
- package/docs/v2/skill/04-rhythm-and-rituals.md +102 -0
- package/docs/v2/skill/05-red-lines.md +13 -0
- package/docs/v2/skill/06-bootstrap-and-takeover.md +84 -0
- package/docs/v2/skill/07-templates-and-lessons.md +24 -0
- package/package.json +7 -16
- package/src/v2/cli/run-config-check.js +10 -4
- package/src/v2/cli/run.js +35 -11
- package/src/v2/engine/yaml-subset.js +17 -11
- package/src/v2/presets/policies/ui-render-gate.yaml +1 -1
- package/src/v2/runtime/decisions.js +1 -1
- package/src/v2/runtime/gc.js +41 -25
- package/src/v2/runtime/metrics.js +3 -2
- package/src/v2/runtime/orchestrator.js +542 -412
- package/src/v2/workspace/workspace-manager.js +62 -3
- package/templates/v2/CLAUDE.md +1 -1
- package/templates/v2/run-config.example.yaml +2 -0
|
@@ -21,9 +21,12 @@ import { EventLedger, canonicalJson } from "../storage/event-ledger.js";
|
|
|
21
21
|
import {
|
|
22
22
|
acquireLock,
|
|
23
23
|
createWorkspace,
|
|
24
|
+
describeLockOwner,
|
|
24
25
|
listChangedPaths,
|
|
26
|
+
liveParallelMarkers,
|
|
25
27
|
readback,
|
|
26
28
|
releaseLock,
|
|
29
|
+
withRepoGitLock,
|
|
27
30
|
} from "../workspace/workspace-manager.js";
|
|
28
31
|
import { writeRunRecord } from "./run-record.js";
|
|
29
32
|
import { computeWorkCost } from "./work-cost.js";
|
|
@@ -52,8 +55,14 @@ function sha256(text) {
|
|
|
52
55
|
return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
//
|
|
56
|
-
// repository-wide lock
|
|
58
|
+
// By default one run drives a repository at a time: it holds the
|
|
59
|
+
// repository-wide active-run lock for its whole drive. A run whose config
|
|
60
|
+
// sets `parallel: true` instead holds a per-work lock and a marker, passing
|
|
61
|
+
// the active-run lock only briefly as a gate, so runs of different works can
|
|
62
|
+
// drive together while runs of the same work stay exclusive. Real incident:
|
|
63
|
+
// a session waited 3h23m behind another work's run although worktrees were
|
|
64
|
+
// already isolated; parallelism is opt-in because verifiers that bind fixed
|
|
65
|
+
// ports or share a database would collide.
|
|
57
66
|
const ACTIVE_LOCK = "active-run";
|
|
58
67
|
|
|
59
68
|
function lockActive(repoRoot) {
|
|
@@ -70,17 +79,81 @@ function lockActive(repoRoot) {
|
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
|
|
73
|
-
function
|
|
82
|
+
function lockActiveWaiting(repoRoot, waitMs) {
|
|
83
|
+
const deadline = Date.now() + waitMs;
|
|
84
|
+
for (;;) {
|
|
85
|
+
try {
|
|
86
|
+
acquireLock(repoRoot, ACTIVE_LOCK);
|
|
87
|
+
return;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!error.lock || Date.now() >= deadline) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
74
95
|
lockActive(repoRoot);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function holdRunLock(repoRoot, runId, fn) {
|
|
99
|
+
acquireLock(repoRoot, runId);
|
|
75
100
|
try {
|
|
76
|
-
|
|
101
|
+
return fn();
|
|
102
|
+
} finally {
|
|
103
|
+
releaseLock(repoRoot, runId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function withRunLocks(repoRoot, runId, fn, { workId = null, parallel = false } = {}) {
|
|
108
|
+
if (!parallel) {
|
|
109
|
+
lockActive(repoRoot);
|
|
77
110
|
try {
|
|
78
|
-
|
|
111
|
+
const running = liveParallelMarkers(repoRoot);
|
|
112
|
+
if (running.length > 0) {
|
|
113
|
+
throw new OrchestratorError(
|
|
114
|
+
`another run is active in this repository (parallel run(s) ${running
|
|
115
|
+
.map((marker) => (marker.owner ? `${marker.run}, ${describeLockOwner(marker.owner)}` : marker.run))
|
|
116
|
+
.join("; ")}); this run is exclusive (set parallel: true in its run config to drive alongside other works)`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return holdRunLock(repoRoot, runId, fn);
|
|
79
120
|
} finally {
|
|
80
|
-
releaseLock(repoRoot,
|
|
121
|
+
releaseLock(repoRoot, ACTIVE_LOCK);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!workId) {
|
|
125
|
+
throw new OrchestratorError("a parallel run needs its work id");
|
|
126
|
+
}
|
|
127
|
+
const workLock = `@work.${workId}`;
|
|
128
|
+
const marker = `@parallel.${runId}`;
|
|
129
|
+
try {
|
|
130
|
+
acquireLock(repoRoot, workLock);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const detail = error.lock?.detail;
|
|
133
|
+
throw new OrchestratorError(
|
|
134
|
+
`another run of ${workId} is active (runs of the same work never drive together)${detail ? `; ${detail}` : ""}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
// Gate: an exclusive run holds active-run for its whole drive, so a
|
|
139
|
+
// parallel run cannot slip in while it runs; the marker, created under
|
|
140
|
+
// the gate, is what an exclusive run checks before it starts. Another
|
|
141
|
+
// parallel run holds the gate for milliseconds, so a busy gate is waited
|
|
142
|
+
// for briefly before it is reported (two parallel starts at the same
|
|
143
|
+
// instant must both get through).
|
|
144
|
+
lockActiveWaiting(repoRoot, 2000);
|
|
145
|
+
try {
|
|
146
|
+
acquireLock(repoRoot, marker);
|
|
147
|
+
} finally {
|
|
148
|
+
releaseLock(repoRoot, ACTIVE_LOCK);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return holdRunLock(repoRoot, runId, fn);
|
|
152
|
+
} finally {
|
|
153
|
+
releaseLock(repoRoot, marker);
|
|
81
154
|
}
|
|
82
155
|
} finally {
|
|
83
|
-
releaseLock(repoRoot,
|
|
156
|
+
releaseLock(repoRoot, workLock);
|
|
84
157
|
}
|
|
85
158
|
}
|
|
86
159
|
|
|
@@ -225,7 +298,7 @@ function budgetReasons(context, step, safeguard = false) {
|
|
|
225
298
|
function workReviewBudget(context, step) {
|
|
226
299
|
const stepDef = context.workflow.steps.find((item) => item.id === step);
|
|
227
300
|
const cap = context.runBudgets.reviewRoundsPerWork;
|
|
228
|
-
if (cap === undefined || !(step
|
|
301
|
+
if (cap === undefined || !isReviewStep(step, stepDef)) return null;
|
|
229
302
|
const prior = computeWorkCost(context.repoRoot, context.ledger.state.run.work, {
|
|
230
303
|
excludeRun: context.ledger.state.run.id,
|
|
231
304
|
});
|
|
@@ -358,463 +431,518 @@ function settleOutcome(context, step, outcome, tree, exec) {
|
|
|
358
431
|
return to;
|
|
359
432
|
}
|
|
360
433
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (workflow.terminal.has(step)) {
|
|
367
|
-
context.waitHuman(
|
|
368
|
-
`enter-${step}`,
|
|
369
|
-
["terminal step requires a human decision"],
|
|
370
|
-
"final-decision",
|
|
371
|
-
);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
if (context.stopAt.includes(step) && !(skipBoundaryOnce && firstStep)) {
|
|
375
|
-
context.waitHuman(`enter-${step}`, [`automation boundary: stopAt includes ${step}`]);
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
firstStep = false;
|
|
379
|
-
const stepDef = workflow.steps.find((candidate) => candidate.id === step);
|
|
380
|
-
const adapter = stepDef.worker ? adapters[stepDef.worker] : null;
|
|
381
|
-
if (!adapter) {
|
|
382
|
-
context.waitHuman(`enter-${step}`, [
|
|
383
|
-
`no adapter configured for worker ${stepDef.worker ?? "(none)"}; attended handoff`,
|
|
384
|
-
]);
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
434
|
+
// A step's review rounds are budgeted and capped per work: the step named
|
|
435
|
+
// "review" or any step run by the reviewer worker.
|
|
436
|
+
function isReviewStep(step, stepDef) {
|
|
437
|
+
return step === "review" || stepDef?.worker === "reviewer";
|
|
438
|
+
}
|
|
387
439
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
440
|
+
// Everything that may stop a run before a step starts: terminal step, stop
|
|
441
|
+
// boundary, missing adapter, the work-level review cap, pre policies and the
|
|
442
|
+
// step budgets. Returns what the step needs, or null when the run stopped.
|
|
443
|
+
function checkBeforeStep(context, step, { skipBoundary }) {
|
|
444
|
+
const { ledger, workflow, adapters, now } = context;
|
|
445
|
+
if (workflow.terminal.has(step)) {
|
|
446
|
+
context.waitHuman(
|
|
447
|
+
`enter-${step}`,
|
|
448
|
+
["terminal step requires a human decision"],
|
|
449
|
+
"final-decision",
|
|
450
|
+
);
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
if (context.stopAt.includes(step) && !skipBoundary) {
|
|
454
|
+
context.waitHuman(`enter-${step}`, [`automation boundary: stopAt includes ${step}`]);
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
const stepDef = workflow.steps.find((candidate) => candidate.id === step);
|
|
458
|
+
const adapter = stepDef.worker ? adapters[stepDef.worker] : null;
|
|
459
|
+
if (!adapter) {
|
|
460
|
+
context.waitHuman(`enter-${step}`, [
|
|
461
|
+
`no adapter configured for worker ${stepDef.worker ?? "(none)"}; attended handoff`,
|
|
462
|
+
]);
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
406
465
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
466
|
+
// Work-level review cap (iteration 09): review rounds are counted across
|
|
467
|
+
// every run of the work, superseded ones included, so "one run per
|
|
468
|
+
// round" cannot slip past the per-run budget. Reaching the cap is a
|
|
469
|
+
// human decision (review once more, or merge/close as-is), not a stop.
|
|
470
|
+
const workBudget = workReviewBudget(context, step);
|
|
471
|
+
if (workBudget?.exhausted) {
|
|
472
|
+
const { failures } = budgetUsage(context, step);
|
|
473
|
+
context.waitHuman(
|
|
474
|
+
`enter-${step}`,
|
|
475
|
+
[
|
|
476
|
+
`${step} work review budget exhausted: ${workBudget.rounds}/${workBudget.allowed} review round(s) across the work, ${failures} real failure(s) in this run`,
|
|
477
|
+
`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`,
|
|
478
|
+
],
|
|
479
|
+
"work-review-cap",
|
|
480
|
+
budgetGrants(context, step),
|
|
481
|
+
);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const preGate = runPolicyGate(context, "pre", step);
|
|
486
|
+
if (preGate.action === "block") {
|
|
487
|
+
ledger.append({
|
|
488
|
+
type: "RUN_TERMINAL",
|
|
489
|
+
actor: KERNEL,
|
|
490
|
+
ts: now(),
|
|
491
|
+
data: { status: "FAILED", reason: `pre policy blocked ${step}` },
|
|
492
|
+
});
|
|
493
|
+
writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
if (preGate.action === "wait") {
|
|
497
|
+
context.waitHuman(`resume-${step}`, policyReasons(preGate.rows));
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const attempt = (ledger.state.steps[step]?.attempts ?? 0) + 1;
|
|
502
|
+
const maxAttempts = context.maxAttemptsFor(step);
|
|
503
|
+
if (attempt > maxAttempts) {
|
|
504
|
+
context.waitHuman(`resume-${step}`, budgetReasons(context, step), "budget", budgetGrants(context, step));
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (attempt > context.totalAttemptsFor(step)) {
|
|
509
|
+
context.waitHuman(`resume-${step}`, budgetReasons(context, step, true), "budget", budgetGrants(context, step));
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return { stepDef, adapter, attempt };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Records STEP_STARTED and prepares the worker's input: the read-only
|
|
517
|
+
// snapshot, the output path, anchored findings, the envelope prompt and the
|
|
518
|
+
// incremental-review base.
|
|
519
|
+
function beginStep(context, step, stepDef, attempt) {
|
|
520
|
+
const { ledger, workspace, now } = context;
|
|
521
|
+
const adapter = context.adapters[stepDef.worker];
|
|
522
|
+
ledger.append({
|
|
523
|
+
type: "STEP_STARTED",
|
|
524
|
+
actor: KERNEL,
|
|
525
|
+
ts: now(),
|
|
526
|
+
data: {
|
|
527
|
+
step,
|
|
528
|
+
attempt,
|
|
529
|
+
worker: stepDef.worker,
|
|
530
|
+
adapter: adapter.name,
|
|
531
|
+
workspaceId: workspace.workspaceId,
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
const before = stepDef.readonly ? readback(workspace.worktreePath) : null;
|
|
535
|
+
const outputsDir = join(context.runtimeDir, "runs", ledger.state.run.id, "outputs");
|
|
536
|
+
mkdirSync(outputsDir, { recursive: true });
|
|
537
|
+
const outputPath = join(outputsDir, `${step}-${attempt}.json`);
|
|
538
|
+
// Anchored review: readonly (reviewer) steps receive the adjudicated
|
|
539
|
+
// findings history so a fresh reviewer inherits settled verdicts instead
|
|
540
|
+
// of re-litigating them; writing steps get the latest review findings
|
|
541
|
+
// with their adjudication status (the fixer's worklist).
|
|
542
|
+
const input = { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt };
|
|
543
|
+
const anchor = buildAnchor(context.repoRoot, ledger.state.run.work);
|
|
544
|
+
if (anchor && stepDef.readonly) {
|
|
545
|
+
input.anchor = anchor;
|
|
546
|
+
} else if (anchor) {
|
|
547
|
+
const lastReview = [...ledger.state.evidence]
|
|
548
|
+
.reverse()
|
|
549
|
+
.find((item) => item.kind === "review");
|
|
550
|
+
if (lastReview?.findings?.length) {
|
|
551
|
+
const adjudicated = latestAdjudications(
|
|
552
|
+
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
553
|
+
);
|
|
554
|
+
input.findings = lastReview.findings.map((finding) => ({
|
|
555
|
+
severity: finding.severity,
|
|
556
|
+
summary: finding.summary,
|
|
557
|
+
fingerprint: fingerprintFinding(finding),
|
|
558
|
+
adjudication: adjudicated.get(fingerprintFinding(finding))?.action ?? "open",
|
|
559
|
+
}));
|
|
417
560
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
561
|
+
}
|
|
562
|
+
// Envelope (C6): the worker's prompt, materialised into the run
|
|
563
|
+
// directory and handed over as BUILDBEAT_PROMPT / input.envelope.
|
|
564
|
+
const prompt = materialisePrompt({
|
|
565
|
+
envelope: context.envelope,
|
|
566
|
+
worker: stepDef.worker,
|
|
567
|
+
runtimeDir: context.runtimeDir,
|
|
568
|
+
runId: ledger.state.run.id,
|
|
569
|
+
step,
|
|
570
|
+
attempt,
|
|
571
|
+
repoRoot: context.repoRoot,
|
|
572
|
+
});
|
|
573
|
+
if (prompt) {
|
|
574
|
+
input.envelope = { promptRef: prompt.ref, file: prompt.file, digest: context.envelope.digest, vars: context.envelope.vars };
|
|
575
|
+
}
|
|
576
|
+
// Incremental review (C7): tell a reviewer which candidate the last
|
|
577
|
+
// review saw when it is an ancestor of this one.
|
|
578
|
+
if (stepDef.readonly) {
|
|
579
|
+
const head = before.head;
|
|
580
|
+
const lastReviewed = lastReviewedCandidate(context.repoRoot, ledger.state.run.work, workspace.worktreePath, head);
|
|
581
|
+
if (lastReviewed) {
|
|
582
|
+
input.lastReviewed = lastReviewed;
|
|
421
583
|
}
|
|
584
|
+
}
|
|
585
|
+
return { before, outputPath, input, prompt };
|
|
586
|
+
}
|
|
422
587
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
588
|
+
// Runs the worker, or references identical passed evidence (verification
|
|
589
|
+
// reuse), then records the command evidence for the tree git reads back.
|
|
590
|
+
function executeOrReuse(context, step, stepDef, adapter, attempt, { before, outputPath, input, prompt }) {
|
|
591
|
+
const { ledger, workspace, now } = context;
|
|
592
|
+
// Verification reuse (C7): same tree + same worker + same envelope that
|
|
593
|
+
// already passed is referenced, not re-run. Failures always re-run.
|
|
594
|
+
let stepCacheKey = null;
|
|
595
|
+
let reused = null;
|
|
596
|
+
if (context.cache[step] === "tree") {
|
|
597
|
+
const current = readback(workspace.worktreePath);
|
|
598
|
+
if (!current.dirty) {
|
|
599
|
+
stepCacheKey = cacheKey({
|
|
600
|
+
tree: treeHash(workspace.worktreePath),
|
|
601
|
+
worker: stepDef.worker,
|
|
602
|
+
adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
|
|
603
|
+
adapterName: adapter.name,
|
|
604
|
+
envelopeDigest: context.envelope?.digest ?? null,
|
|
605
|
+
});
|
|
606
|
+
reused = findReusableEvidence(context.repoRoot, stepCacheKey);
|
|
428
607
|
}
|
|
608
|
+
}
|
|
609
|
+
let exec;
|
|
610
|
+
if (reused) {
|
|
611
|
+
const at = now();
|
|
612
|
+
exec = {
|
|
613
|
+
adapter: "cache",
|
|
614
|
+
command: `reuse ${reused.run} ${reused.evidenceRef}`,
|
|
615
|
+
exitCode: 0,
|
|
616
|
+
signal: null,
|
|
617
|
+
stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
|
|
618
|
+
stderr: "",
|
|
619
|
+
timedOut: false,
|
|
620
|
+
spawnError: null,
|
|
621
|
+
startedAt: at,
|
|
622
|
+
finishedAt: at,
|
|
623
|
+
};
|
|
624
|
+
} else {
|
|
625
|
+
exec = adapter.execute({
|
|
626
|
+
step,
|
|
627
|
+
worker: stepDef.worker,
|
|
628
|
+
workspacePath: workspace.worktreePath,
|
|
629
|
+
input,
|
|
630
|
+
timeoutMs: context.stepTimeoutMs,
|
|
631
|
+
outputPath,
|
|
632
|
+
// Live output streams + marker land in the run directory so `status`
|
|
633
|
+
// can answer "is it still doing something" while the step runs.
|
|
634
|
+
liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
|
|
635
|
+
promptPath: prompt?.path ?? null,
|
|
636
|
+
vars: context.envelope?.vars ?? null,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
const tree = readback(workspace.worktreePath);
|
|
640
|
+
const evidence = collectCommandEvidence({
|
|
641
|
+
runtimeDir: context.runtimeDir,
|
|
642
|
+
runId: ledger.state.run.id,
|
|
643
|
+
step,
|
|
644
|
+
attempt,
|
|
645
|
+
execResult: exec,
|
|
646
|
+
subject: tree.head,
|
|
647
|
+
grade: reused ? reused.grade : stepDef.grade ?? "L2",
|
|
648
|
+
redact: context.redact,
|
|
649
|
+
});
|
|
650
|
+
ledger.append({
|
|
651
|
+
type: "EVIDENCE_RECORDED",
|
|
652
|
+
actor: KERNEL,
|
|
653
|
+
ts: now(),
|
|
654
|
+
data: {
|
|
655
|
+
evidenceRef: toRepoRef(context.repoRoot, evidence.location),
|
|
656
|
+
kind: evidence.kind,
|
|
657
|
+
subject: evidence.subject,
|
|
658
|
+
digest: evidence.digest,
|
|
659
|
+
status: evidence.status,
|
|
660
|
+
grade: evidence.grade,
|
|
661
|
+
...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
|
|
662
|
+
...(reused ? { reused: { run: reused.run, evidenceRef: reused.evidenceRef, digest: reused.digest } } : {}),
|
|
663
|
+
},
|
|
664
|
+
});
|
|
429
665
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
666
|
+
return { exec, tree };
|
|
667
|
+
}
|
|
434
668
|
|
|
669
|
+
// Classifies what the step did and records it: read-only enforcement, the
|
|
670
|
+
// worker envelope, status and infrastructure failures, review findings,
|
|
671
|
+
// scope, the pinned candidate and post policies. Returns the result to
|
|
672
|
+
// route, or null when the run stopped.
|
|
673
|
+
function recordStepResult(context, step, stepDef, attempt, { before, outputPath }, { exec, tree }) {
|
|
674
|
+
const { ledger, workspace, now } = context;
|
|
675
|
+
// Read-only enforcement: a reviewer that changed the workspace is a
|
|
676
|
+
// policy violation, not a candidate (invariants 9/17).
|
|
677
|
+
if (stepDef.readonly && (tree.head !== before.head || tree.dirty !== before.dirty)) {
|
|
435
678
|
ledger.append({
|
|
436
|
-
type: "
|
|
679
|
+
type: "POLICY_EVALUATED",
|
|
437
680
|
actor: KERNEL,
|
|
438
681
|
ts: now(),
|
|
439
682
|
data: {
|
|
440
|
-
step,
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
683
|
+
policy: "step.readonly",
|
|
684
|
+
phase: "action",
|
|
685
|
+
result: "BLOCK",
|
|
686
|
+
enforcement: "LOCAL_ENFORCED",
|
|
687
|
+
reason: `read-only step ${step} modified the workspace`,
|
|
445
688
|
},
|
|
446
689
|
});
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
690
|
+
ledger.append({
|
|
691
|
+
type: "STEP_FINISHED",
|
|
692
|
+
actor: KERNEL,
|
|
693
|
+
ts: now(),
|
|
694
|
+
data: { step, attempt, status: "blocked" },
|
|
695
|
+
});
|
|
696
|
+
context.waitHuman(`resume-${step}`, [
|
|
697
|
+
`read-only step ${step} modified the workspace; human triage required`,
|
|
698
|
+
]);
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let envelopeRaw = exec.envelope;
|
|
703
|
+
if (exec.envelope !== undefined && exec.envelope !== null) {
|
|
704
|
+
writeFileSync(outputPath, `${JSON.stringify(exec.envelope, null, 2)}\n`, "utf8");
|
|
705
|
+
} else if (existsSync(outputPath)) {
|
|
706
|
+
envelopeRaw = readFileSync(outputPath, "utf8");
|
|
707
|
+
}
|
|
708
|
+
const { envelope, error: envelopeError } = parseEnvelope(envelopeRaw);
|
|
709
|
+
|
|
710
|
+
let stepStatus;
|
|
711
|
+
if (exec.spawnError) {
|
|
712
|
+
stepStatus = "crashed";
|
|
713
|
+
} else if (exec.timedOut) {
|
|
714
|
+
stepStatus = "timeout";
|
|
715
|
+
} else if (exec.signal) {
|
|
716
|
+
stepStatus = "crashed";
|
|
717
|
+
} else if (exec.exitCode !== 0) {
|
|
718
|
+
stepStatus = "failed";
|
|
719
|
+
} else if (envelopeError) {
|
|
720
|
+
stepStatus = "invalid-output";
|
|
721
|
+
} else {
|
|
722
|
+
stepStatus = "succeeded";
|
|
723
|
+
}
|
|
724
|
+
// Infrastructure failure vs candidate failure. A timeout, a crash,
|
|
725
|
+
// garbage output or the worker's own "environment unavailable" signal
|
|
726
|
+
// (exit 75, EX_TEMPFAIL) says nothing about the candidate: no failure
|
|
727
|
+
// fingerprint, no fixer, the attempt is refunded, and a human decides
|
|
728
|
+
// when the backend is back. Real incidents: a worker backend outage
|
|
729
|
+
// (review exit 97) and non-JSON reviewer output killed five runs in two
|
|
730
|
+
// days as "no transition for (review, failed)"; PATH, port and host-load
|
|
731
|
+
// verify failures dispatched fixers five times.
|
|
732
|
+
const infra =
|
|
733
|
+
stepStatus === "timeout" ||
|
|
734
|
+
stepStatus === "crashed" ||
|
|
735
|
+
stepStatus === "invalid-output" ||
|
|
736
|
+
(stepStatus === "failed" && exec.exitCode === 75);
|
|
737
|
+
const free = stepStatus === "succeeded" && stepDef.readonly !== true;
|
|
738
|
+
ledger.append({
|
|
739
|
+
type: "STEP_FINISHED",
|
|
740
|
+
actor: KERNEL,
|
|
741
|
+
ts: now(),
|
|
742
|
+
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode,
|
|
743
|
+
...(infra ? { infra: true } : {}), ...(free ? { free: true } : {}) },
|
|
744
|
+
});
|
|
745
|
+
ledger.append({
|
|
746
|
+
type: "BUDGET_CONSUMED",
|
|
747
|
+
actor: KERNEL,
|
|
748
|
+
ts: now(),
|
|
749
|
+
data: {
|
|
750
|
+
kind: "attempts",
|
|
751
|
+
amount: infra || free ? 0 : 1,
|
|
752
|
+
remaining: context.maxAttemptsFor(step) - attempt,
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
if (infra) {
|
|
756
|
+
const cause =
|
|
757
|
+
stepStatus === "failed"
|
|
758
|
+
? "exit 75 (worker reports its environment unavailable)"
|
|
759
|
+
: stepStatus === "invalid-output"
|
|
760
|
+
? "output is not a worker envelope"
|
|
761
|
+
: stepStatus;
|
|
762
|
+
context.waitHuman(
|
|
763
|
+
`resume-${step}`,
|
|
764
|
+
[
|
|
765
|
+
`worker infrastructure failure at ${step}: ${cause}; not a candidate defect, attempt not charged`,
|
|
766
|
+
...(tree.dirty ? [`the failed worker left the worktree dirty; inspect before rerunning`] : []),
|
|
767
|
+
`approve resume-${step} to rerun once the backend/environment is back; reject to end the run`,
|
|
768
|
+
],
|
|
769
|
+
"infra",
|
|
770
|
+
);
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let blockingFindings = [];
|
|
775
|
+
if (envelope?.findings) {
|
|
776
|
+
recordReviewFindings(context.repoRoot, ledger.state.run.work, {
|
|
777
|
+
run: ledger.state.run.id,
|
|
482
778
|
step,
|
|
483
779
|
attempt,
|
|
484
|
-
|
|
780
|
+
findings: envelope.findings,
|
|
781
|
+
ts: now(),
|
|
485
782
|
});
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
// Verification reuse (C7): same tree + same worker + same envelope that
|
|
499
|
-
// already passed is referenced, not re-run. Failures always re-run.
|
|
500
|
-
let stepCacheKey = null;
|
|
501
|
-
let reused = null;
|
|
502
|
-
if (context.cache[step] === "tree") {
|
|
503
|
-
const current = readback(workspace.worktreePath);
|
|
504
|
-
if (!current.dirty) {
|
|
505
|
-
stepCacheKey = cacheKey({
|
|
506
|
-
tree: treeHash(workspace.worktreePath),
|
|
507
|
-
worker: stepDef.worker,
|
|
508
|
-
adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
|
|
509
|
-
adapterName: adapter.name,
|
|
510
|
-
envelopeDigest: context.envelope?.digest ?? null,
|
|
511
|
-
});
|
|
512
|
-
reused = findReusableEvidence(context.repoRoot, stepCacheKey);
|
|
783
|
+
// A fingerprint a human dismissed stays visible in the evidence but no
|
|
784
|
+
// longer blocks: settled verdicts do not reopen without a human.
|
|
785
|
+
const adjudicated = latestAdjudications(
|
|
786
|
+
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
787
|
+
);
|
|
788
|
+
const suppressed = [];
|
|
789
|
+
blockingFindings = envelope.findings.filter((finding) => {
|
|
790
|
+
if (adjudicated.get(fingerprintFinding(finding))?.action === "dismiss") {
|
|
791
|
+
suppressed.push(fingerprintFinding(finding));
|
|
792
|
+
return false;
|
|
513
793
|
}
|
|
514
|
-
|
|
515
|
-
let exec;
|
|
516
|
-
if (reused) {
|
|
517
|
-
const at = now();
|
|
518
|
-
exec = {
|
|
519
|
-
adapter: "cache",
|
|
520
|
-
command: `reuse ${reused.run} ${reused.evidenceRef}`,
|
|
521
|
-
exitCode: 0,
|
|
522
|
-
signal: null,
|
|
523
|
-
stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
|
|
524
|
-
stderr: "",
|
|
525
|
-
timedOut: false,
|
|
526
|
-
spawnError: null,
|
|
527
|
-
startedAt: at,
|
|
528
|
-
finishedAt: at,
|
|
529
|
-
};
|
|
530
|
-
} else {
|
|
531
|
-
exec = adapter.execute({
|
|
532
|
-
step,
|
|
533
|
-
worker: stepDef.worker,
|
|
534
|
-
workspacePath: workspace.worktreePath,
|
|
535
|
-
input,
|
|
536
|
-
timeoutMs: context.stepTimeoutMs,
|
|
537
|
-
outputPath,
|
|
538
|
-
// Live output streams + marker land in the run directory so `status`
|
|
539
|
-
// can answer "is it still doing something" while the step runs.
|
|
540
|
-
liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
|
|
541
|
-
promptPath: prompt?.path ?? null,
|
|
542
|
-
vars: context.envelope?.vars ?? null,
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
const tree = readback(workspace.worktreePath);
|
|
546
|
-
const evidence = collectCommandEvidence({
|
|
547
|
-
runtimeDir: context.runtimeDir,
|
|
548
|
-
runId: ledger.state.run.id,
|
|
549
|
-
step,
|
|
550
|
-
attempt,
|
|
551
|
-
execResult: exec,
|
|
552
|
-
subject: tree.head,
|
|
553
|
-
grade: reused ? reused.grade : stepDef.grade ?? "L2",
|
|
554
|
-
redact: context.redact,
|
|
794
|
+
return finding.severity === "P0" || finding.severity === "P1";
|
|
555
795
|
});
|
|
556
796
|
ledger.append({
|
|
557
797
|
type: "EVIDENCE_RECORDED",
|
|
558
798
|
actor: KERNEL,
|
|
559
799
|
ts: now(),
|
|
560
800
|
data: {
|
|
561
|
-
evidenceRef: toRepoRef(context.repoRoot,
|
|
562
|
-
kind:
|
|
563
|
-
subject:
|
|
564
|
-
digest:
|
|
565
|
-
status:
|
|
566
|
-
grade:
|
|
567
|
-
|
|
568
|
-
...(
|
|
801
|
+
evidenceRef: toRepoRef(context.repoRoot, outputPath),
|
|
802
|
+
kind: "review",
|
|
803
|
+
subject: tree.head,
|
|
804
|
+
digest: sha256(canonicalJson(envelope)),
|
|
805
|
+
status: blockingFindings.length > 0 ? "failed" : "passed",
|
|
806
|
+
grade: "L2",
|
|
807
|
+
findings: envelope.findings,
|
|
808
|
+
...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
|
|
569
809
|
},
|
|
570
810
|
});
|
|
811
|
+
}
|
|
571
812
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
813
|
+
// Scope enforcement (B §10: out-of-scope changes stop the loop): any
|
|
814
|
+
// path changed outside the allowed set means this candidate cannot
|
|
815
|
+
// proceed, whatever the exit code said.
|
|
816
|
+
if (!stepDef.readonly && context.allowedPaths) {
|
|
817
|
+
const changed = listChangedPaths(workspace.worktreePath, workspace.base);
|
|
818
|
+
const violations = changed.filter(
|
|
819
|
+
(path) =>
|
|
820
|
+
!context.allowedPaths.some(
|
|
821
|
+
(prefix) =>
|
|
822
|
+
path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
|
823
|
+
),
|
|
824
|
+
);
|
|
825
|
+
if (violations.length > 0) {
|
|
575
826
|
ledger.append({
|
|
576
827
|
type: "POLICY_EVALUATED",
|
|
577
828
|
actor: KERNEL,
|
|
578
829
|
ts: now(),
|
|
579
830
|
data: {
|
|
580
|
-
policy: "
|
|
831
|
+
policy: "workspace.scope",
|
|
581
832
|
phase: "action",
|
|
582
833
|
result: "BLOCK",
|
|
583
834
|
enforcement: "LOCAL_ENFORCED",
|
|
584
|
-
reason: `
|
|
835
|
+
reason: `out-of-scope changes: ${violations.slice(0, 5).join(", ")}`,
|
|
585
836
|
},
|
|
586
837
|
});
|
|
587
|
-
ledger.append({
|
|
588
|
-
type: "STEP_FINISHED",
|
|
589
|
-
actor: KERNEL,
|
|
590
|
-
ts: now(),
|
|
591
|
-
data: { step, attempt, status: "blocked" },
|
|
592
|
-
});
|
|
593
838
|
context.waitHuman(`resume-${step}`, [
|
|
594
|
-
`
|
|
839
|
+
`worker changed paths outside the allowed scope: ${violations.slice(0, 5).join(", ")}`,
|
|
595
840
|
]);
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
let envelopeRaw = exec.envelope;
|
|
600
|
-
if (exec.envelope !== undefined && exec.envelope !== null) {
|
|
601
|
-
writeFileSync(outputPath, `${JSON.stringify(exec.envelope, null, 2)}\n`, "utf8");
|
|
602
|
-
} else if (existsSync(outputPath)) {
|
|
603
|
-
envelopeRaw = readFileSync(outputPath, "utf8");
|
|
841
|
+
return null;
|
|
604
842
|
}
|
|
605
|
-
|
|
843
|
+
}
|
|
606
844
|
|
|
607
|
-
|
|
608
|
-
if (
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
stepStatus = "crashed";
|
|
614
|
-
} else if (exec.exitCode !== 0) {
|
|
615
|
-
stepStatus = "failed";
|
|
616
|
-
} else if (envelopeError) {
|
|
617
|
-
stepStatus = "invalid-output";
|
|
618
|
-
} else {
|
|
619
|
-
stepStatus = "succeeded";
|
|
620
|
-
}
|
|
621
|
-
// Infrastructure failure vs candidate failure. A timeout, a crash,
|
|
622
|
-
// garbage output or the worker's own "environment unavailable" signal
|
|
623
|
-
// (exit 75, EX_TEMPFAIL) says nothing about the candidate: no failure
|
|
624
|
-
// fingerprint, no fixer, the attempt is refunded, and a human decides
|
|
625
|
-
// when the backend is back. Real incidents: a worker backend outage
|
|
626
|
-
// (review exit 97) and non-JSON reviewer output killed five runs in two
|
|
627
|
-
// days as "no transition for (review, failed)"; PATH, port and host-load
|
|
628
|
-
// verify failures dispatched fixers five times.
|
|
629
|
-
const infra =
|
|
630
|
-
stepStatus === "timeout" ||
|
|
631
|
-
stepStatus === "crashed" ||
|
|
632
|
-
stepStatus === "invalid-output" ||
|
|
633
|
-
(stepStatus === "failed" && exec.exitCode === 75);
|
|
634
|
-
const free = stepStatus === "succeeded" && stepDef.readonly !== true;
|
|
635
|
-
ledger.append({
|
|
636
|
-
type: "STEP_FINISHED",
|
|
637
|
-
actor: KERNEL,
|
|
638
|
-
ts: now(),
|
|
639
|
-
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode,
|
|
640
|
-
...(infra ? { infra: true } : {}), ...(free ? { free: true } : {}) },
|
|
641
|
-
});
|
|
642
|
-
ledger.append({
|
|
643
|
-
type: "BUDGET_CONSUMED",
|
|
644
|
-
actor: KERNEL,
|
|
645
|
-
ts: now(),
|
|
646
|
-
data: {
|
|
647
|
-
kind: "attempts",
|
|
648
|
-
amount: infra || free ? 0 : 1,
|
|
649
|
-
remaining: context.maxAttemptsFor(step) - attempt,
|
|
650
|
-
},
|
|
651
|
-
});
|
|
652
|
-
if (infra) {
|
|
653
|
-
const cause =
|
|
654
|
-
stepStatus === "failed"
|
|
655
|
-
? "exit 75 (worker reports its environment unavailable)"
|
|
656
|
-
: stepStatus === "invalid-output"
|
|
657
|
-
? "output is not a worker envelope"
|
|
658
|
-
: stepStatus;
|
|
659
|
-
context.waitHuman(
|
|
660
|
-
`resume-${step}`,
|
|
661
|
-
[
|
|
662
|
-
`worker infrastructure failure at ${step}: ${cause}; not a candidate defect, attempt not charged`,
|
|
663
|
-
...(tree.dirty ? [`the failed worker left the worktree dirty; inspect before rerunning`] : []),
|
|
664
|
-
`approve resume-${step} to rerun once the backend/environment is back; reject to end the run`,
|
|
665
|
-
],
|
|
666
|
-
"infra",
|
|
667
|
-
);
|
|
668
|
-
return;
|
|
845
|
+
if (stepStatus === "succeeded" && !stepDef.readonly) {
|
|
846
|
+
if (tree.dirty) {
|
|
847
|
+
context.waitHuman(`resume-${step}`, [
|
|
848
|
+
`step ${step} left a dirty worktree; a candidate must be a committed state`,
|
|
849
|
+
]);
|
|
850
|
+
return null;
|
|
669
851
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (envelope?.findings) {
|
|
673
|
-
recordReviewFindings(context.repoRoot, ledger.state.run.work, {
|
|
674
|
-
run: ledger.state.run.id,
|
|
675
|
-
step,
|
|
676
|
-
attempt,
|
|
677
|
-
findings: envelope.findings,
|
|
678
|
-
ts: now(),
|
|
679
|
-
});
|
|
680
|
-
// A fingerprint a human dismissed stays visible in the evidence but no
|
|
681
|
-
// longer blocks: settled verdicts do not reopen without a human.
|
|
682
|
-
const adjudicated = latestAdjudications(
|
|
683
|
-
readFindingsAccount(context.repoRoot, ledger.state.run.work),
|
|
684
|
-
);
|
|
685
|
-
const suppressed = [];
|
|
686
|
-
blockingFindings = envelope.findings.filter((finding) => {
|
|
687
|
-
if (adjudicated.get(fingerprintFinding(finding))?.action === "dismiss") {
|
|
688
|
-
suppressed.push(fingerprintFinding(finding));
|
|
689
|
-
return false;
|
|
690
|
-
}
|
|
691
|
-
return finding.severity === "P0" || finding.severity === "P1";
|
|
692
|
-
});
|
|
852
|
+
const pinned = ledger.state.workspaces[workspace.workspaceId]?.candidate;
|
|
853
|
+
if (tree.head !== (pinned ?? workspace.base)) {
|
|
693
854
|
ledger.append({
|
|
694
|
-
type: "
|
|
855
|
+
type: "CANDIDATE_PINNED",
|
|
695
856
|
actor: KERNEL,
|
|
696
857
|
ts: now(),
|
|
697
858
|
data: {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
digest: sha256(canonicalJson(envelope)),
|
|
702
|
-
status: blockingFindings.length > 0 ? "failed" : "passed",
|
|
703
|
-
grade: "L2",
|
|
704
|
-
findings: envelope.findings,
|
|
705
|
-
...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
|
|
859
|
+
workspaceId: workspace.workspaceId,
|
|
860
|
+
base: workspace.base,
|
|
861
|
+
candidate: tree.head,
|
|
706
862
|
},
|
|
707
863
|
});
|
|
708
864
|
}
|
|
865
|
+
}
|
|
709
866
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
(
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
);
|
|
722
|
-
if (violations.length > 0) {
|
|
723
|
-
ledger.append({
|
|
724
|
-
type: "POLICY_EVALUATED",
|
|
725
|
-
actor: KERNEL,
|
|
726
|
-
ts: now(),
|
|
727
|
-
data: {
|
|
728
|
-
policy: "workspace.scope",
|
|
729
|
-
phase: "action",
|
|
730
|
-
result: "BLOCK",
|
|
731
|
-
enforcement: "LOCAL_ENFORCED",
|
|
732
|
-
reason: `out-of-scope changes: ${violations.slice(0, 5).join(", ")}`,
|
|
733
|
-
},
|
|
734
|
-
});
|
|
735
|
-
context.waitHuman(`resume-${step}`, [
|
|
736
|
-
`worker changed paths outside the allowed scope: ${violations.slice(0, 5).join(", ")}`,
|
|
737
|
-
]);
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
867
|
+
if (stepStatus === "succeeded") {
|
|
868
|
+
const postGate = runPolicyGate(context, "post", step);
|
|
869
|
+
if (postGate.action === "block") {
|
|
870
|
+
ledger.append({
|
|
871
|
+
type: "RUN_TERMINAL",
|
|
872
|
+
actor: KERNEL,
|
|
873
|
+
ts: now(),
|
|
874
|
+
data: { status: "FAILED", reason: `post policy blocked ${step}` },
|
|
875
|
+
});
|
|
876
|
+
writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
|
|
877
|
+
return null;
|
|
740
878
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
context.waitHuman(`resume-${step}`, [
|
|
745
|
-
`step ${step} left a dirty worktree; a candidate must be a committed state`,
|
|
746
|
-
]);
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
const pinned = ledger.state.workspaces[workspace.workspaceId]?.candidate;
|
|
750
|
-
if (tree.head !== (pinned ?? workspace.base)) {
|
|
751
|
-
ledger.append({
|
|
752
|
-
type: "CANDIDATE_PINNED",
|
|
753
|
-
actor: KERNEL,
|
|
754
|
-
ts: now(),
|
|
755
|
-
data: {
|
|
756
|
-
workspaceId: workspace.workspaceId,
|
|
757
|
-
base: workspace.base,
|
|
758
|
-
candidate: tree.head,
|
|
759
|
-
},
|
|
760
|
-
});
|
|
761
|
-
}
|
|
879
|
+
if (postGate.action === "wait") {
|
|
880
|
+
context.waitHuman(`resume-${step}`, policyReasons(postGate.rows));
|
|
881
|
+
return null;
|
|
762
882
|
}
|
|
883
|
+
}
|
|
763
884
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
885
|
+
return { stepStatus, blockingFindings, tree, exec };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Settles the outcome and picks the next step; blocking findings stop once
|
|
889
|
+
// for triage or for the review budget before any fixer runs. Returns the
|
|
890
|
+
// next step, or null when the run stopped.
|
|
891
|
+
function routeAfterStep(context, step, stepDef, { stepStatus, blockingFindings, tree, exec }) {
|
|
892
|
+
let outcome;
|
|
893
|
+
if (stepStatus !== "succeeded") {
|
|
894
|
+
outcome = "failed";
|
|
895
|
+
} else if (blockingFindings.length > 0) {
|
|
896
|
+
outcome = "findings-blocking";
|
|
897
|
+
} else {
|
|
898
|
+
outcome = "succeeded";
|
|
899
|
+
}
|
|
900
|
+
const routed = settleOutcome(context, step, outcome, tree, exec);
|
|
901
|
+
// Ask before spending fix/verify workers: one approval covers the next
|
|
902
|
+
// round and both review caps, with the grant bound to this request.
|
|
903
|
+
if (routed && outcome === "findings-blocking") {
|
|
904
|
+
const grants = isReviewStep(step, stepDef) ? budgetGrants(context, step) : [];
|
|
905
|
+
const triage = context.reviewTriage === "required";
|
|
906
|
+
if (triage || grants.length) {
|
|
907
|
+
const { used, limit, failures } = budgetUsage(context, step);
|
|
908
|
+
const workBudget = workReviewBudget(context, step);
|
|
909
|
+
context.waitHuman(
|
|
910
|
+
`enter-${routed}`,
|
|
911
|
+
[
|
|
912
|
+
...(grants.length ? [
|
|
913
|
+
`${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`,
|
|
914
|
+
] : []),
|
|
915
|
+
`review found ${blockingFindings.length} blocking finding(s); ${triage ? "triage" : "approve another round"} before ${routed} runs`,
|
|
916
|
+
...blockingFindings.slice(0, 5).map((finding) =>
|
|
917
|
+
`[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`),
|
|
918
|
+
`adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
|
|
919
|
+
],
|
|
920
|
+
triage ? "finding-triage" : "budget",
|
|
921
|
+
grants,
|
|
922
|
+
);
|
|
923
|
+
return null;
|
|
780
924
|
}
|
|
925
|
+
}
|
|
926
|
+
return routed;
|
|
927
|
+
}
|
|
781
928
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
929
|
+
function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
930
|
+
let step = startStep;
|
|
931
|
+
let firstStep = true;
|
|
932
|
+
while (step) {
|
|
933
|
+
const entry = checkBeforeStep(context, step, { skipBoundary: skipBoundaryOnce && firstStep });
|
|
934
|
+
firstStep = false;
|
|
935
|
+
if (!entry) {
|
|
936
|
+
return;
|
|
789
937
|
}
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
}
|
|
938
|
+
const { stepDef, adapter, attempt } = entry;
|
|
939
|
+
const started = beginStep(context, step, stepDef, attempt);
|
|
940
|
+
const ran = executeOrReuse(context, step, stepDef, adapter, attempt, started);
|
|
941
|
+
const result = recordStepResult(context, step, stepDef, attempt, started, ran);
|
|
942
|
+
if (!result) {
|
|
943
|
+
return;
|
|
816
944
|
}
|
|
817
|
-
step =
|
|
945
|
+
step = routeAfterStep(context, step, stepDef, result);
|
|
818
946
|
}
|
|
819
947
|
}
|
|
820
948
|
|
|
@@ -926,7 +1054,7 @@ export function startRun(options) {
|
|
|
926
1054
|
}
|
|
927
1055
|
|
|
928
1056
|
return withRunLocks(repoRoot, runId, () => {
|
|
929
|
-
const workspace = createWorkspace({ repoRoot, runId, base });
|
|
1057
|
+
const workspace = withRepoGitLock(repoRoot, () => createWorkspace({ repoRoot, runId, base }));
|
|
930
1058
|
const context = makeContext(options, ledger, workspace);
|
|
931
1059
|
const now = context.now;
|
|
932
1060
|
const supersession =
|
|
@@ -974,7 +1102,7 @@ export function startRun(options) {
|
|
|
974
1102
|
superseded: supersession.superseded,
|
|
975
1103
|
supersedeSkipped: supersession.skipped,
|
|
976
1104
|
};
|
|
977
|
-
});
|
|
1105
|
+
}, { workId, parallel: options.parallel === true });
|
|
978
1106
|
}
|
|
979
1107
|
|
|
980
1108
|
function resumeStepFromTransition(transition) {
|
|
@@ -1038,7 +1166,9 @@ export function resumeRun(options) {
|
|
|
1038
1166
|
// decided again on a ledger read under the locks: another session may
|
|
1039
1167
|
// have approved, resumed or stopped the run in between, and writing
|
|
1040
1168
|
// through the earlier read would fork the hash chain.
|
|
1041
|
-
const
|
|
1169
|
+
const outer = openLedgerFor(repoRoot, runId);
|
|
1170
|
+
const outerLedger = outer.ledger;
|
|
1171
|
+
const outside = resumeTarget(options, outer);
|
|
1042
1172
|
if (outside.early) {
|
|
1043
1173
|
return outside.early;
|
|
1044
1174
|
}
|
|
@@ -1216,5 +1346,5 @@ export function resumeRun(options) {
|
|
|
1216
1346
|
drive(context, startStep);
|
|
1217
1347
|
}
|
|
1218
1348
|
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
1219
|
-
});
|
|
1349
|
+
}, { workId: outerLedger.state.run.work, parallel: options.parallel === true });
|
|
1220
1350
|
}
|