tickmarkr 2.5.8 → 2.6.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/dist/adapters/claude-code.js +19 -6
- package/dist/adapters/prompt.js +1 -1
- package/dist/adapters/qwen.js +30 -3
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.js +15 -3
- package/dist/cli/commands/beat.d.ts +2 -0
- package/dist/cli/commands/beat.js +28 -29
- package/dist/cli/commands/compile.js +11 -0
- package/dist/cli/commands/fleet.js +61 -53
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/report.js +11 -1
- package/dist/cli/commands/verify.d.ts +4 -1
- package/dist/cli/commands/verify.js +18 -5
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +6 -4
- package/dist/compile/native.js +39 -4
- package/dist/config/config.d.ts +41 -3
- package/dist/config/config.js +48 -17
- package/dist/config/fleet-overlay.js +47 -62
- package/dist/drivers/orca.d.ts +1 -1
- package/dist/drivers/orca.js +21 -4
- package/dist/gates/baseline.d.ts +67 -0
- package/dist/gates/baseline.js +172 -10
- package/dist/gates/cache.d.ts +8 -0
- package/dist/gates/cache.js +26 -13
- package/dist/gates/review.d.ts +8 -7
- package/dist/gates/review.js +83 -45
- package/dist/gates/run-gates.d.ts +4 -1
- package/dist/gates/run-gates.js +32 -12
- package/dist/gates/test-manifest.d.ts +16 -1
- package/dist/gates/test-manifest.js +127 -27
- package/dist/gates/test-reporter.js +4 -0
- package/dist/graph/graph.d.ts +1 -1
- package/dist/graph/graph.js +6 -1
- package/dist/graph/schema.d.ts +2 -0
- package/dist/graph/schema.js +2 -0
- package/dist/route/preference.d.ts +1 -1
- package/dist/route/preference.js +10 -39
- package/dist/run/daemon.d.ts +7 -1
- package/dist/run/daemon.js +237 -53
- package/dist/run/git.d.ts +10 -0
- package/dist/run/git.js +49 -1
- package/dist/run/journal.d.ts +18 -3
- package/dist/run/journal.js +141 -17
- package/dist/run/merge.d.ts +13 -2
- package/dist/run/merge.js +98 -12
- package/dist/run/protocol.d.ts +82 -0
- package/dist/run/protocol.js +35 -0
- package/dist/run/receipt-resolver.d.ts +18 -0
- package/dist/run/receipt-resolver.js +132 -0
- package/dist/run/supervision.d.ts +14 -1
- package/dist/run/supervision.js +122 -24
- package/dist/tui/cockpit/evidence-view.d.ts +10 -1
- package/dist/tui/cockpit/evidence-view.js +37 -5
- package/dist/tui/cockpit/live-runtime.d.ts +4 -0
- package/dist/tui/cockpit/live-runtime.js +37 -3
- package/dist/tui/cockpit/live-store.d.ts +2 -0
- package/dist/tui/ink/fleet-app.d.ts +12 -22
- package/dist/tui/ink/fleet-app.js +520 -131
- package/package.json +1 -1
- package/schema/rungraph.schema.json +7 -0
- package/skills/tickmarkr-overseer/SKILL.md +91 -41
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
package/dist/run/daemon.js
CHANGED
|
@@ -33,9 +33,9 @@ import { cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModu
|
|
|
33
33
|
import { runInteractiveSeed } from "./interactive-seed.js";
|
|
34
34
|
import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
|
|
35
35
|
import { applyScopeAmendments, activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingApprovalActions, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
36
|
-
import { isDiffCapPark, pickReviewer } from "../gates/review.js";
|
|
36
|
+
import { gateReviewerFloor, isDiffCapPark, pickReviewer } from "../gates/review.js";
|
|
37
37
|
import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
|
|
38
|
-
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
38
|
+
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, reusedTipEvidence, verifyIntegrationTip } from "./merge.js";
|
|
39
39
|
import { climbChannel, marginalCostRank, nextChannel, route } from "../route/router.js";
|
|
40
40
|
import { desiredPanes } from "./reconcile.js";
|
|
41
41
|
import { readTierLiveness, readWatchBoard, supervisionBeatPath } from "./supervision.js";
|
|
@@ -164,6 +164,27 @@ export function resolveRunMode(repoRoot, opts = {}) {
|
|
|
164
164
|
: undefined;
|
|
165
165
|
return { cfg: resolved.cfg, mode: resolved.mode, source, ...(conflict ? { conflict } : {}) };
|
|
166
166
|
}
|
|
167
|
+
// Bind approval context before dispatch consumes the release. Restore reconstructs the same
|
|
168
|
+
// binding from journal order; a newer approval, including one without a reason, supersedes it.
|
|
169
|
+
function approvalReviewContext(events, taskId, restore = false) {
|
|
170
|
+
let pending;
|
|
171
|
+
let bound;
|
|
172
|
+
let approved = false;
|
|
173
|
+
for (const row of events) {
|
|
174
|
+
if (row.taskId !== taskId)
|
|
175
|
+
continue;
|
|
176
|
+
if (row.event === "task-approved") {
|
|
177
|
+
pending = typeof row.data.reason === "string" ? row.data.reason.trim() || undefined : undefined;
|
|
178
|
+
approved = true;
|
|
179
|
+
}
|
|
180
|
+
else if (row.event === "task-dispatch" || (row.event === "recheck-battery" && approved)) {
|
|
181
|
+
bound = pending;
|
|
182
|
+
pending = undefined;
|
|
183
|
+
approved = false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return restore && !approved ? bound : pending;
|
|
187
|
+
}
|
|
167
188
|
// T14: the events that prove an approval was ENACTED — narrowly causal, never merely subsequent.
|
|
168
189
|
// Ordinary, attempt-cap and review-upheld approvals buy a WORKER, so their proof is a dispatch;
|
|
169
190
|
// recheck has its own battery enactment below. Generic terminal events are deliberately NOT proof: an approved human-gate
|
|
@@ -664,7 +685,7 @@ function lastVerifyCycle(events) {
|
|
|
664
685
|
if (e.event === "tip-verify-start") {
|
|
665
686
|
const { tip, cmdHash } = e.data;
|
|
666
687
|
cur = typeof tip === "string" && typeof cmdHash === "string"
|
|
667
|
-
? { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [e.data.capacity] }
|
|
688
|
+
? { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [e.data.capacity], evidenceByGate: new Map() }
|
|
668
689
|
: undefined;
|
|
669
690
|
afterRunEnd = false;
|
|
670
691
|
continue;
|
|
@@ -687,7 +708,7 @@ function lastVerifyCycle(events) {
|
|
|
687
708
|
continue;
|
|
688
709
|
}
|
|
689
710
|
if (!cur || afterRunEnd || cur.tip !== tip || cur.cmdHash !== cmdHash) {
|
|
690
|
-
cur = { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [] };
|
|
711
|
+
cur = { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [], evidenceByGate: new Map() };
|
|
691
712
|
}
|
|
692
713
|
afterRunEnd = false;
|
|
693
714
|
// T7: EVERY verdict row's own capacity, not just the start row's. The start row is a statement of
|
|
@@ -699,6 +720,7 @@ function lastVerifyCycle(events) {
|
|
|
699
720
|
cur.failed = true;
|
|
700
721
|
else {
|
|
701
722
|
cur.gates.add(gate);
|
|
723
|
+
cur.evidenceByGate.set(gate, e.data);
|
|
702
724
|
// Q121s review M1: a forgiven green depends on the baseline that forgave it. Baselines are not
|
|
703
725
|
// part of the cache key, so a forgiven cycle is NEVER cache-eligible — an unchanged tip whose
|
|
704
726
|
// baseline later vanishes or changes must re-run the full verify, not inherit the green.
|
|
@@ -712,7 +734,8 @@ function lastVerifyCycle(events) {
|
|
|
712
734
|
* OBS-1077 close rider: what the engagement's LATEST verification cycle proved — its
|
|
713
735
|
* `tip-verify-start` row and what followed, never a commit comparison. Exactly one kind per close:
|
|
714
736
|
* fresh (every tip command ran AND passed), reused (an eligible cached cycle carried forward),
|
|
715
|
-
* failed, or incomplete (cancelled, cut short
|
|
737
|
+
* failed, or incomplete (cancelled, cut short or undelimited). Completed mixed cycles retain each
|
|
738
|
+
* gate's kind beside the whole-cycle reused kind. Nothing before the start row
|
|
716
739
|
* is read, so an unfinished cycle inherits nothing from an earlier green one.
|
|
717
740
|
*/
|
|
718
741
|
export function runEndTipProof(events) {
|
|
@@ -737,14 +760,25 @@ export function runEndTipProof(events) {
|
|
|
737
760
|
if ((events[start].data.cached === true) !== cachedRow || (cachedRow && carried !== rows.length))
|
|
738
761
|
return proof("incomplete");
|
|
739
762
|
// D-131: fresh only when EVERY command executed; one per-gate persisted verdict makes the cycle reused.
|
|
740
|
-
return
|
|
763
|
+
return {
|
|
764
|
+
...proof(carried > 0 ? "reused" : "fresh"),
|
|
765
|
+
gates: gates.map((gate) => ({
|
|
766
|
+
gate: String(gate),
|
|
767
|
+
kind: rows.some((r) => r.data.gate === gate && r.data.cached === true) ? "reused" : "fresh",
|
|
768
|
+
})),
|
|
769
|
+
};
|
|
741
770
|
}
|
|
742
771
|
/** The close notification's statement of the proof — one clause per kind. */
|
|
743
772
|
export function formatTipProof(p) {
|
|
744
773
|
const at = p.tip ? ` ${p.tip.slice(0, 12)}` : "";
|
|
774
|
+
const gateReading = p.gates?.map(({ gate, kind }) => `${gate}: ${kind === "fresh" ? "verified fresh" : "cached (reused) — carried, not re-run"}`).join("; ");
|
|
775
|
+
const suffix = gateReading ? `; ${gateReading}` : "";
|
|
776
|
+
if (p.kind === "reused" && p.gates?.some(({ kind }) => kind === "fresh")) {
|
|
777
|
+
return `tip proof: ${p.kind} — commit${at}; ${gateReading}`;
|
|
778
|
+
}
|
|
745
779
|
switch (p.kind) {
|
|
746
|
-
case "fresh": return `tip proof: fresh — every tip command ran and passed on${at || " the integration tip"}`;
|
|
747
|
-
case "reused": return `tip proof: reused — carried from verified commit${at}, commands not re-run`;
|
|
780
|
+
case "fresh": return `tip proof: fresh — every tip command ran and passed on${at || " the integration tip"}${suffix}`;
|
|
781
|
+
case "reused": return `tip proof: reused — carried from verified commit${at}, commands not re-run${suffix}`;
|
|
748
782
|
case "failed": return `tip proof: failed —${at ? ` commit${at}` : ""} latest verification cycle is red`;
|
|
749
783
|
case "incomplete": return `tip proof: incomplete —${at ? ` commit${at}` : ""} latest verification cycle did not finish`;
|
|
750
784
|
}
|
|
@@ -909,7 +943,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
|
|
|
909
943
|
// pass — `cached: true` keeps it honest about not having re-run the command.
|
|
910
944
|
for (const gate of gates) {
|
|
911
945
|
const cmd = gate === "test" && commands.tipTest ? commands.tipTest : commands[gate];
|
|
912
|
-
journal.append("tip-verify", undefined, { gate, cmd, pass: true, exitCode: 0, cached: true, tip, cmdHash, capacity });
|
|
946
|
+
journal.append("tip-verify", undefined, { ...reusedTipEvidence(last.evidenceByGate.get(gate) ?? {}), gate, cmd, pass: true, exitCode: 0, cached: true, tip, cmdHash, capacity });
|
|
913
947
|
}
|
|
914
948
|
return false;
|
|
915
949
|
}
|
|
@@ -918,12 +952,22 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
|
|
|
918
952
|
? cancellableTipBattery(intWt, commands, journal.dir, opts.baseline, opts.signal)
|
|
919
953
|
: verifyIntegrationTip(intWt, commands, journal.dir, opts.baseline))) {
|
|
920
954
|
const measuredCapacity = r.capacity ?? capacity;
|
|
955
|
+
const evidence = r.reused ? reusedTipEvidence(r) : {
|
|
956
|
+
...(r.originRunRoot ? { originRunRoot: r.originRunRoot } : {}),
|
|
957
|
+
...(r.cause ? { cause: r.cause } : {}),
|
|
958
|
+
...(r.evidenceReceipt ? { evidenceReceipt: r.evidenceReceipt } : {}),
|
|
959
|
+
...(r.evidenceReceipts ? { evidenceReceipts: r.evidenceReceipts } : {}),
|
|
960
|
+
...(r.evidenceAbsence ? { evidenceAbsence: r.evidenceAbsence } : {}),
|
|
961
|
+
...Object.fromEntries(["nonce", "stdoutPath", "stderrPath"]
|
|
962
|
+
.filter(key => r[key] !== undefined).map(key => [key, r[key]])),
|
|
963
|
+
};
|
|
921
964
|
if (r.pass) {
|
|
922
965
|
// Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
|
|
923
|
-
journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.reused ? { cached: true } : {}), ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash, capacity: measuredCapacity });
|
|
966
|
+
journal.append("tip-verify", undefined, { ...evidence, gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.reused ? { cached: true } : {}), ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash, capacity: measuredCapacity });
|
|
924
967
|
}
|
|
925
968
|
else {
|
|
926
969
|
journal.append("tip-verify-failed", undefined, {
|
|
970
|
+
...evidence,
|
|
927
971
|
gate: r.gate,
|
|
928
972
|
cmd: r.cmd,
|
|
929
973
|
exitCode: r.exitCode,
|
|
@@ -1444,6 +1488,77 @@ async function cherryPickCommits(wt, commits) {
|
|
|
1444
1488
|
}
|
|
1445
1489
|
return carried;
|
|
1446
1490
|
}
|
|
1491
|
+
/** Fold lifetime dispatch/carry evidence, independent of attempt budgets and routing exclusions.
|
|
1492
|
+
* Recreation rows name SOURCE hashes, so ownership is joined by stable patch identity. Each
|
|
1493
|
+
* attempt owns only what the next carry (or current subject) adds beyond its own incoming set.
|
|
1494
|
+
* Gate-only recreations do not start an attempt or transfer authorship to the restored seat.
|
|
1495
|
+
*/
|
|
1496
|
+
async function subjectAuthors(events, taskId, wt, base) {
|
|
1497
|
+
const cache = new Map();
|
|
1498
|
+
const patches = async (commits) => {
|
|
1499
|
+
const ids = new Set();
|
|
1500
|
+
for (const commit of commits) {
|
|
1501
|
+
if (!cache.has(commit)) {
|
|
1502
|
+
const diff = await shGit(`git show --format= --binary ${shq(commit)}`, wt);
|
|
1503
|
+
if (diff.code !== 0)
|
|
1504
|
+
throw new Error(`cannot read author patch ${commit}`);
|
|
1505
|
+
const id = execFileSync("git", ["patch-id", "--stable"], {
|
|
1506
|
+
cwd: wt, input: diff.stdout, encoding: "utf8", maxBuffer: 32 * 1024 * 1024,
|
|
1507
|
+
}).trim().split(/\s+/)[0];
|
|
1508
|
+
cache.set(commit, id || undefined); // an empty commit authored no patch
|
|
1509
|
+
}
|
|
1510
|
+
const id = cache.get(commit);
|
|
1511
|
+
if (id)
|
|
1512
|
+
ids.add(id);
|
|
1513
|
+
}
|
|
1514
|
+
return ids;
|
|
1515
|
+
};
|
|
1516
|
+
let current;
|
|
1517
|
+
let previous;
|
|
1518
|
+
let awaitingCarry = false;
|
|
1519
|
+
const owners = new Map();
|
|
1520
|
+
const attribute = (ids, attempt) => {
|
|
1521
|
+
for (const id of ids) {
|
|
1522
|
+
if (attempt?.incoming.has(id))
|
|
1523
|
+
continue;
|
|
1524
|
+
const authors = owners.get(id) ?? new Set();
|
|
1525
|
+
authors.add(attempt?.author ?? "unknown author (missing task-dispatch assignment)");
|
|
1526
|
+
owners.set(id, authors);
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
try {
|
|
1530
|
+
for (const row of events) {
|
|
1531
|
+
if (row.taskId !== taskId)
|
|
1532
|
+
continue;
|
|
1533
|
+
if (row.event === "task-dispatch") {
|
|
1534
|
+
previous = current;
|
|
1535
|
+
const a = row.data.assignment;
|
|
1536
|
+
current = { author: typeof a?.adapter === "string" && typeof a?.model === "string"
|
|
1537
|
+
? `${a.adapter}:${a.model}` : "unknown author (missing task-dispatch assignment)", incoming: new Set() };
|
|
1538
|
+
awaitingCarry = true;
|
|
1539
|
+
}
|
|
1540
|
+
else if (row.event === "worktree-recreation") {
|
|
1541
|
+
const carried = await patches(Array.isArray(row.data.carried) ? row.data.carried : []);
|
|
1542
|
+
attribute(carried, awaitingCarry ? previous : current);
|
|
1543
|
+
if (awaitingCarry && current)
|
|
1544
|
+
current.incoming = carried;
|
|
1545
|
+
awaitingCarry = false;
|
|
1546
|
+
}
|
|
1547
|
+
else if (["worker-launch", "worker-result", "gate-result", "task-human"].includes(row.event)) {
|
|
1548
|
+
// The initial checkout has no recreation row. Once work/gates start, a later
|
|
1549
|
+
// recreation is a restore of this attempt, not the input of a new dispatch.
|
|
1550
|
+
awaitingCarry = false;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
const subject = await patches(await commitsAheadOf(base, wt));
|
|
1554
|
+
attribute(subject, current);
|
|
1555
|
+
return [...new Set([...subject].flatMap((id) => [...(owners.get(id) ?? [])]))];
|
|
1556
|
+
}
|
|
1557
|
+
catch (error) {
|
|
1558
|
+
// An unreadable history cannot silently remove an author from the exclusion set.
|
|
1559
|
+
return [`unknown author (${String(error)})`];
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1447
1562
|
// T7 (v1.86): a first run-end append that fails AFTER partial bytes landed leaves a torn tail at
|
|
1448
1563
|
// EOF with no newline; a blind retry would glue the run-end line onto those bytes and readJsonl's
|
|
1449
1564
|
// torn-line tolerance would drop the retry too — no terminal record despite a successful write.
|
|
@@ -2550,6 +2665,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2550
2665
|
// run; a park-instead policy can come later if it ever bites.
|
|
2551
2666
|
}
|
|
2552
2667
|
const taskHistory = journal.read().filter((e) => e.taskId === t.id);
|
|
2668
|
+
// Lifetime identity counts every dispatch, including legacy and unchargeable rows. Releases
|
|
2669
|
+
// reset the budget below, never this counter; observational annotations do not consume it.
|
|
2670
|
+
let nextWorkerDispatchOrdinal = taskHistory.filter((e) => e.event === "task-dispatch").length;
|
|
2553
2671
|
const lastApproval = taskHistory.map((e) => e.event).lastIndexOf("task-approved");
|
|
2554
2672
|
const priorClimb = taskHistory.slice(lastApproval + 1).reverse().find((e) => e.event === "tier-escalated");
|
|
2555
2673
|
if (!hintsChanged && priorClimb && taskHistory.indexOf(priorClimb) > taskHistory.map((e) => e.event).lastIndexOf("task-dispatch")) {
|
|
@@ -2566,7 +2684,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2566
2684
|
// exclusion-list-equality oracle. Daemon-side append only — no journal.ts write-path change (Phase 48
|
|
2567
2685
|
// stays unblocked); inert to replayStatuses (unknown events ignored, pinned at journal.test.ts:70-80).
|
|
2568
2686
|
if (rs)
|
|
2569
|
-
journal.append("resume-restore", t.id, {
|
|
2687
|
+
journal.append("resume-restore", t.id, {
|
|
2688
|
+
attempts: rs.attempts, tried: [...tried], assignment,
|
|
2689
|
+
workerDispatchOrdinal: previousDispatch?.data.workerDispatchOrdinal ?? null,
|
|
2690
|
+
});
|
|
2570
2691
|
// Keep one live list: recovery retries must see exclusions added by onGate during the round.
|
|
2571
2692
|
const badReviewers = [...replayedReviewerExclusions];
|
|
2572
2693
|
const noteReviewEvent = (e) => {
|
|
@@ -2597,7 +2718,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2597
2718
|
const round = await runGates(task, ctx);
|
|
2598
2719
|
let review = round.results.find((g) => g.gate === "review");
|
|
2599
2720
|
while (review?.meta?.noVerdict === true) {
|
|
2600
|
-
|
|
2721
|
+
// Recovery must honor the gate's floor, including the seats that just failed to
|
|
2722
|
+
// return a verdict; a below-floor alternative cannot replace the infra result.
|
|
2723
|
+
const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers]);
|
|
2724
|
+
const next = pickReviewer(ctx.author, ctx.channels, badReviewers, cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers);
|
|
2601
2725
|
if (!next)
|
|
2602
2726
|
break;
|
|
2603
2727
|
journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
|
|
@@ -2706,6 +2830,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2706
2830
|
...(Array.isArray(g.meta?.selectedTests) ? { selectedTests: g.meta.selectedTests } : {}),
|
|
2707
2831
|
...(g.meta?.fullSuite === true ? { fullSuite: true } : {}),
|
|
2708
2832
|
...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
|
|
2833
|
+
...(g.gate === "review" && g.meta?.noEligibleReviewer === true ? {
|
|
2834
|
+
noEligibleReviewer: true, authorVendors: g.meta.authorVendors, unresolvedAuthors: g.meta.unresolvedAuthors,
|
|
2835
|
+
} : {}),
|
|
2709
2836
|
...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
|
|
2710
2837
|
reviewer: g.meta.reviewer,
|
|
2711
2838
|
...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised", "reviewerFloor", "reviewerFloorCause", "reviewerTier"]
|
|
@@ -2733,6 +2860,14 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2733
2860
|
// every recalibration this telemetry funds, a gap is honest and a zero is a lie. The
|
|
2734
2861
|
// seven-gate closed set is asserted end-to-end in tests/run/gate-telemetry.test.ts.
|
|
2735
2862
|
...gateMeasurement(g.meta),
|
|
2863
|
+
// Carried receipts retain their original invocation and artifact root.
|
|
2864
|
+
...(g.evidenceReceipt ? { evidenceReceipt: g.evidenceReceipt } : {}),
|
|
2865
|
+
...(g.evidenceReceipts ? { evidenceReceipts: g.evidenceReceipts } : {}),
|
|
2866
|
+
...(g.meta?.reused ? {
|
|
2867
|
+
reused: true,
|
|
2868
|
+
...(g.originRunRoot ? { originRunRoot: g.originRunRoot } : {}),
|
|
2869
|
+
} : Object.fromEntries(["nonce", "stdoutPath", "stderrPath", "classification"]
|
|
2870
|
+
.filter(key => g.meta?.[key] !== undefined).map(key => [key, g.meta[key]]))),
|
|
2736
2871
|
// T7: the capacity the gate's own command child ran under, lifted verbatim from the result
|
|
2737
2872
|
// the battery produced — read where the shell built that child's environment, never
|
|
2738
2873
|
// re-derived from the run's own budget, which would answer a different number than the
|
|
@@ -2960,7 +3095,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2960
3095
|
// OBS-130/T15: both gate-resume paths consume the persisted task branch with no worker dispatch.
|
|
2961
3096
|
// An operator approval skips its exact failed gate by authority; observed results skip only the
|
|
2962
3097
|
// contiguous green prefix whose recorded commit is still the task branch tip.
|
|
2963
|
-
|
|
3098
|
+
let satisfiedGate = satisfiedGates.get(t.id);
|
|
2964
3099
|
const replayedGates = fundedRerun ? undefined : replayedGateResults.get(t.id);
|
|
2965
3100
|
const recheck = approvalAction?.authority === "battery";
|
|
2966
3101
|
resumeGateReplay: if (satisfiedGate || replayedGates || recheck) {
|
|
@@ -2997,6 +3132,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2997
3132
|
// than against the stale worktree whose task-only history cannot see newly merged dependencies.
|
|
2998
3133
|
const currentTaskTip = await gitHead(wt);
|
|
2999
3134
|
const currentTaskSubject = await gateCommitSubject(taskBase, currentTaskTip, wt);
|
|
3135
|
+
// Recheck carries only review authority for the exact subject being gated, never tool greens.
|
|
3136
|
+
if (recheck) {
|
|
3137
|
+
satisfiedGate = journal.replaySatisfiedGates(new Map([[t.id, currentTaskSubject]])).get(t.id);
|
|
3138
|
+
}
|
|
3000
3139
|
journal.append("worktree-recreation", t.id, { attempted: commitsToCarry, carried: carriedCommits });
|
|
3001
3140
|
// OBS-212: same fail-closed rule as the dispatch path — but this path is worse, because it runs
|
|
3002
3141
|
// ONLY the gates after the approved one and then MERGES. T3 took it on run-20260728-110135:
|
|
@@ -3066,9 +3205,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3066
3205
|
continue;
|
|
3067
3206
|
priorResults.set(e.data.gate, e);
|
|
3068
3207
|
}
|
|
3208
|
+
const declaredGates = GATE_NAMES.filter((gate) => t.gates.includes(gate));
|
|
3209
|
+
const reused = [];
|
|
3069
3210
|
let remainingGates;
|
|
3070
3211
|
if (recheck) {
|
|
3071
|
-
remainingGates =
|
|
3212
|
+
remainingGates = declaredGates.filter((gate) => gate !== satisfiedGate);
|
|
3072
3213
|
}
|
|
3073
3214
|
else if (satisfiedGate) {
|
|
3074
3215
|
remainingGates = t.gates.filter((gate) => {
|
|
@@ -3119,48 +3260,40 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3119
3260
|
});
|
|
3120
3261
|
}
|
|
3121
3262
|
let reusable = sameWorld && sameProtocol && (exactCurrentCommit || canonicalCurrentCommit || recreatedLegacyCommit);
|
|
3122
|
-
const reused = [];
|
|
3123
|
-
const declaredGates = GATE_NAMES.filter((gate) => t.gates.includes(gate));
|
|
3124
3263
|
for (const gate of declaredGates) {
|
|
3125
3264
|
if (!reusable || replayedGates.results.get(gate) !== true)
|
|
3126
3265
|
reusable = false;
|
|
3127
3266
|
else
|
|
3128
3267
|
reused.push(gate);
|
|
3129
3268
|
}
|
|
3130
|
-
// OBS-1049: build outputs are not commits. A replayed build verdict says the tree WAS green;
|
|
3131
|
-
// it says nothing about whether the RECREATED checkout holds what that build produced (dist),
|
|
3132
|
-
// and the host's npm lifecycle (ignore-scripts) may never rebuild it before the suite runs.
|
|
3133
|
-
// So a replayed build re-runs its own command here as provisioning, journaled by name; a red
|
|
3134
|
-
// provisioning is not reused — build (and everything behind it) re-enters the battery as a gate.
|
|
3135
|
-
// Every resume on this path recreates the checkout (recreateTaskWorktree above), so "reused
|
|
3136
|
-
// build" is exactly "build replayed onto a recreated tree".
|
|
3137
|
-
// Journal order is the criterion's order: a GREEN provisioning is recorded AFTER the reuse
|
|
3138
|
-
// rows it belongs to (gate-reused build, then gate-provisioned build); a RED provisioning is
|
|
3139
|
-
// recorded alone, and no reuse row follows it.
|
|
3140
|
-
let provisionedRow;
|
|
3141
|
-
if (reused.includes("build") && commands.build !== undefined) {
|
|
3142
|
-
// Provisioning is a verification command like any gate: it waits for the run's baseline and
|
|
3143
|
-
// takes the command lease (suite admission), never a bare shell beside sibling gates.
|
|
3144
|
-
await waitForBaseline(t.id);
|
|
3145
|
-
const startedAt = Date.now();
|
|
3146
|
-
const provisioned = await withCommandContext(t.id, () => sh(commands.build, wt));
|
|
3147
|
-
provisionedRow = {
|
|
3148
|
-
gate: "build", commit: replayedGates.commit, exitCode: provisioned.code, durationMs: Date.now() - startedAt,
|
|
3149
|
-
};
|
|
3150
|
-
if (provisioned.code !== 0) {
|
|
3151
|
-
reused.splice(reused.indexOf("build"));
|
|
3152
|
-
journal.append("gate-provisioned", t.id, provisionedRow);
|
|
3153
|
-
provisionedRow = undefined;
|
|
3154
|
-
}
|
|
3155
|
-
}
|
|
3156
|
-
for (const gate of reused) {
|
|
3157
|
-
journal.append("gate-reused", t.id, { gate, commit: replayedGates.commit });
|
|
3158
|
-
}
|
|
3159
|
-
if (provisionedRow !== undefined)
|
|
3160
|
-
journal.append("gate-provisioned", t.id, provisionedRow);
|
|
3161
3269
|
remainingGates = declaredGates.slice(reused.length);
|
|
3162
3270
|
}
|
|
3271
|
+
// OBS-1094/1049: every replay recreates the checkout, but build outputs are not commits.
|
|
3272
|
+
// Provision whenever build will not run as a gate, including an operator waiver restore.
|
|
3273
|
+
// Defer reuse rows until provisioning succeeds: green keeps reuse-then-provision order;
|
|
3274
|
+
// red records only provisioning and puts build and every declared successor back in gates,
|
|
3275
|
+
// unless build itself was waived: provisioning must not undo the operator's release.
|
|
3276
|
+
let provisionedRow;
|
|
3277
|
+
if (!remainingGates.includes("build") && commands.build !== undefined) {
|
|
3278
|
+
await waitForBaseline(t.id);
|
|
3279
|
+
const startedAt = Date.now();
|
|
3280
|
+
const provisioned = await withCommandContext(t.id, () => sh(commands.build, wt));
|
|
3281
|
+
provisionedRow = {
|
|
3282
|
+
gate: "build", commit: !satisfiedGate && !recheck ? replayedGates.commit : currentTaskSubject,
|
|
3283
|
+
exitCode: provisioned.code, durationMs: Date.now() - startedAt,
|
|
3284
|
+
};
|
|
3285
|
+
if (provisioned.code !== 0 && satisfiedGate !== "build") {
|
|
3286
|
+
reused.length = 0;
|
|
3287
|
+
remainingGates = ["build", ...declaredGates.filter((gate) => gate !== "build")];
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
for (const gate of reused) {
|
|
3291
|
+
journal.append("gate-reused", t.id, { gate, commit: replayedGates.commit });
|
|
3292
|
+
}
|
|
3293
|
+
if (provisionedRow !== undefined)
|
|
3294
|
+
journal.append("gate-provisioned", t.id, provisionedRow);
|
|
3163
3295
|
const resumedTask = { ...t, gates: remainingGates };
|
|
3296
|
+
const operatorContext = approvalReviewContext(journal.read(), t.id, true);
|
|
3164
3297
|
gateLoop: while (true) {
|
|
3165
3298
|
fatalStop.signal.throwIfAborted();
|
|
3166
3299
|
executionSignal()?.throwIfAborted();
|
|
@@ -3173,11 +3306,20 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3173
3306
|
// deterministic-fingerprint occurrence/review round for budget accounting.
|
|
3174
3307
|
...(!satisfiedGate && !recheck ? { replayMeasurement: true } : {}),
|
|
3175
3308
|
};
|
|
3309
|
+
if (recheck && satisfiedGate === "review" && gateSubject.commit !== currentTaskSubject) {
|
|
3310
|
+
satisfiedGate = undefined;
|
|
3311
|
+
resumedTask.gates = declaredGates;
|
|
3312
|
+
}
|
|
3313
|
+
if (recheck && satisfiedGate === "review" && !resumedTask.gates.includes("review")) {
|
|
3314
|
+
journal.append("gate-waiver-carried", t.id, { gate: "review", commit: gateSubject.commit, carried: true, release: RECHECK_RELEASE });
|
|
3315
|
+
}
|
|
3176
3316
|
await trackedDriver.project?.(t.id, "in-review");
|
|
3177
3317
|
await waitForBaseline(t.id);
|
|
3178
3318
|
journal.phaseStart(t.id, "gates");
|
|
3179
|
-
const { results } = await withCommandContext(t.id, () => runReviewRecovery(resumedTask, {
|
|
3319
|
+
const { results } = await withCommandContext(t.id, async () => runReviewRecovery(resumedTask, {
|
|
3320
|
+
carriedAuthors: await subjectAuthors(journal.read(), t.id, wt, taskBase),
|
|
3180
3321
|
carriedFindings: outstandingReviewFindings(journal.read(), t.id),
|
|
3322
|
+
operatorContext,
|
|
3181
3323
|
worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
|
|
3182
3324
|
commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
|
|
3183
3325
|
collateral: collateral.get(t.id) ?? [],
|
|
@@ -3194,7 +3336,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3194
3336
|
excludeReviewers: badReviewers,
|
|
3195
3337
|
reviewHistory, demotedReviewers, priorReviewers: taskReviewers(t.id),
|
|
3196
3338
|
// Leg-2 (OBS-1052): the run-scoped two-strike tally — without it retirement is inert and a
|
|
3197
|
-
// flaking seat is re-asked on every task.
|
|
3339
|
+
// flaking seat is re-asked on every task.
|
|
3198
3340
|
reviewNoVerdicts,
|
|
3199
3341
|
recheck, // OBS-1055: a recheck discards cached reds — the battery re-measures what the operator questioned
|
|
3200
3342
|
onGate: async (e) => {
|
|
@@ -3231,6 +3373,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3231
3373
|
graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
|
|
3232
3374
|
saveGraph(repoRoot, graph);
|
|
3233
3375
|
if (!results.every(gateSatisfied)) {
|
|
3376
|
+
const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
|
|
3377
|
+
if (unavailableReview) {
|
|
3378
|
+
await park(t, gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
3379
|
+
return;
|
|
3380
|
+
}
|
|
3234
3381
|
const infra = results.find((g) => g.meta?.infra === true);
|
|
3235
3382
|
if (infra) {
|
|
3236
3383
|
await park(t, `${infra.gate}: ${infra.details}`, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
@@ -3259,7 +3406,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3259
3406
|
// Observed green gates are only measurements. If the resumed suffix is red, preserve that
|
|
3260
3407
|
// result in the journal and return to the ordinary attempt/consult ladder, which rebuilds
|
|
3261
3408
|
// feedback from those rows. Only an operator-authorized gate release parks on a new red.
|
|
3262
|
-
if (!satisfiedGate) {
|
|
3409
|
+
if (!satisfiedGate || recheck) {
|
|
3263
3410
|
// OBS-1055: a recheck red funds a repair of the pin's own work. A pinned task's repair sits
|
|
3264
3411
|
// on the pin (OBS-1034's exemption keeps the tried list from excluding it); a pin the fleet
|
|
3265
3412
|
// cannot seat parks naming the pin — never the ladder.
|
|
@@ -3425,6 +3572,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3425
3572
|
// fresh re-dispatches. Read from the JOURNAL, before this dispatch's own event lands: a run that
|
|
3426
3573
|
// stopped between funding the repair and sending it resumes still carrying the findings.
|
|
3427
3574
|
const journaledSoFar = journal.read(); // read BEFORE this dispatch's own event lands
|
|
3575
|
+
const operatorContext = approvalReviewContext(journaledSoFar, t.id);
|
|
3428
3576
|
let repairFindings = pendingRepairFindings(journaledSoFar, t.id);
|
|
3429
3577
|
// OBS-254, one layer below the upheld brief: the ordinary gate-fail brief was loop-local, so any
|
|
3430
3578
|
// path that rebuilt this task's state (a resume, `--retry-failed`, a fresh daemon) dispatched a
|
|
@@ -3515,9 +3663,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3515
3663
|
// ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
|
|
3516
3664
|
// spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
|
|
3517
3665
|
fatalStop.signal.throwIfAborted();
|
|
3666
|
+
const workerDispatchOrdinal = nextWorkerDispatchOrdinal++;
|
|
3518
3667
|
journal.append("task-dispatch", t.id, {
|
|
3519
3668
|
...(scopeApproval?.data.release === "scope-request" ? { files: t.files, graphDefinitionHash: graphDefinitionHash(graph) } : {}),
|
|
3520
|
-
assignment, attempt, provenance: dispatchProvenance([
|
|
3669
|
+
assignment, attempt, workerDispatchOrdinal, provenance: dispatchProvenance([
|
|
3521
3670
|
channelKey(assignment) === channelKey(r.assignment) ? r.provenance
|
|
3522
3671
|
: t.routingHints?.pin ? `pin ${t.routingHints.pin.via}:${t.routingHints.pin.model} not re-tried` : "ladder assignment",
|
|
3523
3672
|
`dispatch ${channelKey(assignment)}`, climbProvenance,
|
|
@@ -3624,7 +3773,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3624
3773
|
// anything the live brief holds beyond the journaled findings (a consult's guidance) is kept:
|
|
3625
3774
|
// a repair adds the diff and the fix-only contract, it never subtracts what was already known.
|
|
3626
3775
|
feedback = feedback && !brief.includes(feedback) ? `${brief}\n\n${feedback}` : brief;
|
|
3627
|
-
journal.append("repair-dispatch", t.id, { diffBytes: Buffer.byteLength(diff, "utf8"), capped, ...(capped ? { droppedFiles } : {}) });
|
|
3776
|
+
journal.append("repair-dispatch", t.id, { workerDispatchOrdinal, diffBytes: Buffer.byteLength(diff, "utf8"), capped, ...(capped ? { droppedFiles } : {}) });
|
|
3628
3777
|
}
|
|
3629
3778
|
if (feedback || priorNamed.length > 0) {
|
|
3630
3779
|
feedback = augmentRetryBrief(feedback, { attempted: commitsToCarry, carried: carriedCommits, present: presentCommits });
|
|
@@ -3664,6 +3813,20 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3664
3813
|
Object.defineProperty(workerSlotOpts, "agent", { value: assignment.adapter });
|
|
3665
3814
|
const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, workerSlotOpts);
|
|
3666
3815
|
const sessionId = retryMode === "resume" ? priorSession.id : slot.name;
|
|
3816
|
+
const readResumeTranscript = () => {
|
|
3817
|
+
try {
|
|
3818
|
+
const sample = adapter.readSessionTranscript?.({ cwd: wt, id: sessionId });
|
|
3819
|
+
return sample && Number.isSafeInteger(sample.bytes) && sample.bytes >= 0 ? sample : null;
|
|
3820
|
+
}
|
|
3821
|
+
catch {
|
|
3822
|
+
return null;
|
|
3823
|
+
}
|
|
3824
|
+
};
|
|
3825
|
+
const resumeBaseline = retryMode === "resume" ? readResumeTranscript() : null;
|
|
3826
|
+
if (retryMode === "resume")
|
|
3827
|
+
journal.append("worker-resume-requested", t.id, {
|
|
3828
|
+
sessionId, attempt, workerDispatchOrdinal, baselineBytes: resumeBaseline?.bytes ?? null,
|
|
3829
|
+
});
|
|
3667
3830
|
const icmd = retryMode === "resume"
|
|
3668
3831
|
? adapter.resumeCommand(sessionId, promptFile, assignment.model)
|
|
3669
3832
|
: cfg.visibility.worker === "interactive" && driver.interactive
|
|
@@ -3691,6 +3854,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3691
3854
|
const groupFile = `${dispatchScript}.${nonce}.pgid`;
|
|
3692
3855
|
workerOwners.set(slot, { taskId: t.id, attempt, groupFile });
|
|
3693
3856
|
writeFileSync(dispatchScript, [
|
|
3857
|
+
// Shell startup can swallow the driver's leading cd; the payload owns its checkout too.
|
|
3858
|
+
`cd ${shq(wt)} || exit 1`,
|
|
3694
3859
|
// A driver may launch inside the daemon's group. That group is never worker-owned.
|
|
3695
3860
|
`worker_pgid=$(ps -o pgid= -p $$ 2>/dev/null); daemon_pgid=$(ps -o pgid= -p ${process.pid} 2>/dev/null)`,
|
|
3696
3861
|
`if [ -n "$worker_pgid" ] && [ -n "$daemon_pgid" ] && [ "$worker_pgid" != "$daemon_pgid" ]; then printf '%s\\n' "$worker_pgid" > ${shq(groupFile)}; fi`,
|
|
@@ -3723,6 +3888,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3723
3888
|
journal.append("worker-launch", t.id, {
|
|
3724
3889
|
attempt,
|
|
3725
3890
|
retryMode,
|
|
3891
|
+
...(retryMode === "resume" ? { sessionId, workerDispatchOrdinal } : {}),
|
|
3726
3892
|
driver: trackedDriver.id,
|
|
3727
3893
|
slot: { ...slot },
|
|
3728
3894
|
workspace: driver.id === "herdr" ? process.env.HERDR_WORKSPACE_ID : undefined,
|
|
@@ -4750,6 +4916,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4750
4916
|
else {
|
|
4751
4917
|
await closeSlot(slot);
|
|
4752
4918
|
}
|
|
4919
|
+
if (retryMode === "resume") {
|
|
4920
|
+
const transcript = readResumeTranscript();
|
|
4921
|
+
const identity = resumeBaseline === null || transcript === null
|
|
4922
|
+
? "unknown" : transcript.bytes > resumeBaseline.bytes ? "confirmed" : "unconfirmed";
|
|
4923
|
+
journal.append("worker-resume-identity", t.id, {
|
|
4924
|
+
sessionId, attempt, workerDispatchOrdinal, identity,
|
|
4925
|
+
baselineBytes: resumeBaseline?.bytes ?? null, observedBytes: transcript?.bytes ?? null,
|
|
4926
|
+
// This is evidence of file growth, not an independent runtime identity handshake.
|
|
4927
|
+
assumption: "external runtime appends to the requested session's own transcript",
|
|
4928
|
+
});
|
|
4929
|
+
}
|
|
4753
4930
|
// SPEND-01: usage from the harness's own cwd-keyed structured store, read POST-HOC from disk —
|
|
4754
4931
|
// `wt` is this task's private worktree, so the path is unique; the read is sliced to records
|
|
4755
4932
|
// stamped at/after this attempt's dispatch instant. Never the harvested pane text, never the
|
|
@@ -5091,8 +5268,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5091
5268
|
}
|
|
5092
5269
|
}
|
|
5093
5270
|
else {
|
|
5094
|
-
({ results, commits } = await withCommandContext(t.id, () => runReviewRecovery(t, {
|
|
5271
|
+
({ results, commits } = await withCommandContext(t.id, async () => runReviewRecovery(t, {
|
|
5272
|
+
carriedAuthors: await subjectAuthors(journal.read(), t.id, wt, taskBase),
|
|
5095
5273
|
carriedFindings: outstandingFindings,
|
|
5274
|
+
operatorContext,
|
|
5096
5275
|
worktree: wt, baseRef: taskBase, result, author: assignment,
|
|
5097
5276
|
commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
|
|
5098
5277
|
collateral: collateral.get(t.id) ?? [],
|
|
@@ -5185,6 +5364,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
5185
5364
|
// already journaled by onGate, before gateFails and before any ladder selection can fund an
|
|
5186
5365
|
// identical retry in the same environment. Parsed judge refusals never carry infra and keep
|
|
5187
5366
|
// flowing through the chargeable quality path below.
|
|
5367
|
+
const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
|
|
5368
|
+
if (unavailableReview) {
|
|
5369
|
+
await park(t, gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
5370
|
+
return;
|
|
5371
|
+
}
|
|
5188
5372
|
const infraFailure = results.find((g) => gateFailed(g) && g.meta?.infra === true);
|
|
5189
5373
|
if (infraFailure) {
|
|
5190
5374
|
await park(t, `${infraFailure.gate}: ${infraFailure.details}${infraFailure.meta?.recoveryBlocked ? ` — ${infraFailure.meta.recoveryBlocked}` : ""}`, "infra", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
|
package/dist/run/git.d.ts
CHANGED
|
@@ -190,6 +190,16 @@ export declare function resolveIntegrationBranch(_repo: string, branch: string):
|
|
|
190
190
|
*/
|
|
191
191
|
export declare function preserveWorktree(cwd: string): Promise<string | undefined>;
|
|
192
192
|
export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
|
|
193
|
+
export interface DependencyLink {
|
|
194
|
+
/** Package link, relative to node_modules (including @scope/name). */
|
|
195
|
+
link: string;
|
|
196
|
+
target: string;
|
|
197
|
+
classification: "worktree" | "node_modules" | "outside";
|
|
198
|
+
}
|
|
199
|
+
/** Inventory only package-level links; never descend into installed packages. */
|
|
200
|
+
export declare function inventoryDependencyLinks(worktree: string): DependencyLink[];
|
|
201
|
+
/** Shared pre-command refusal; unreadable inventories cannot establish isolation either. */
|
|
202
|
+
export declare function dependencyLinkRefusal(worktree: string): string | undefined;
|
|
193
203
|
export declare function linkNodeModules(repo: string, dir: string, { force }?: {
|
|
194
204
|
force?: boolean | undefined;
|
|
195
205
|
}): boolean;
|
package/dist/run/git.js
CHANGED
|
@@ -4,7 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
4
4
|
import { execFileSync, spawn } from "node:child_process";
|
|
5
5
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { availableParallelism, homedir, tmpdir } from "node:os";
|
|
7
|
-
import { join, resolve } from "node:path";
|
|
7
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { StringDecoder } from "node:string_decoder";
|
|
9
9
|
import { shq } from "../adapters/types.js";
|
|
10
10
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
@@ -624,6 +624,54 @@ function excludeNodeModules(dir) {
|
|
|
624
624
|
}
|
|
625
625
|
catch { /* not a git checkout (bare tmpdir in tests) — never fail provisioning over the exclude */ }
|
|
626
626
|
}
|
|
627
|
+
/** Inventory only package-level links; never descend into installed packages. */
|
|
628
|
+
export function inventoryDependencyLinks(worktree) {
|
|
629
|
+
const dependencyPath = join(worktree, "node_modules");
|
|
630
|
+
try {
|
|
631
|
+
lstatSync(dependencyPath);
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
if (error.code === "ENOENT")
|
|
635
|
+
return [];
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
const root = realpathSync(worktree);
|
|
639
|
+
const dependencies = realpathSync(dependencyPath);
|
|
640
|
+
const inside = (parent, target) => {
|
|
641
|
+
const rel = relative(parent, target);
|
|
642
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
643
|
+
};
|
|
644
|
+
const links = [];
|
|
645
|
+
const inspect = (link) => {
|
|
646
|
+
const path = join(dependencies, link);
|
|
647
|
+
if (!lstatSync(path).isSymbolicLink())
|
|
648
|
+
return;
|
|
649
|
+
const target = realpathSync(path);
|
|
650
|
+
links.push({ link, target, classification: inside(root, target) ? "worktree"
|
|
651
|
+
: inside(dependencies, target) ? "node_modules" : "outside" });
|
|
652
|
+
};
|
|
653
|
+
for (const entry of readdirSync(dependencies).sort()) {
|
|
654
|
+
inspect(entry);
|
|
655
|
+
if (entry.startsWith("@") && statSync(join(dependencies, entry)).isDirectory()) {
|
|
656
|
+
for (const child of readdirSync(join(dependencies, entry)).sort())
|
|
657
|
+
inspect(join(entry, child));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return links;
|
|
661
|
+
}
|
|
662
|
+
/** Shared pre-command refusal; unreadable inventories cannot establish isolation either. */
|
|
663
|
+
export function dependencyLinkRefusal(worktree) {
|
|
664
|
+
try {
|
|
665
|
+
const outside = inventoryDependencyLinks(worktree).filter(link => link.classification === "outside");
|
|
666
|
+
if (!outside.length)
|
|
667
|
+
return undefined;
|
|
668
|
+
return "infra: OBS-1118 refusing verification: dependency links resolve outside the worktree and real node_modules:\n"
|
|
669
|
+
+ outside.map(({ link, target }) => `node_modules/${link} -> ${target}`).join("\n");
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
return `infra: OBS-1118 refusing verification: cannot inventory dependency links in ${worktree}: ${String(error)}`;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
627
675
|
export function linkNodeModules(repo, dir, { force = false } = {}) {
|
|
628
676
|
const src = join(repo, "node_modules");
|
|
629
677
|
const dest = join(dir, "node_modules");
|