tickmarkr 2.6.6 → 2.6.8
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 +8 -2
- package/dist/adapters/codex-commit-check.d.ts +3 -2
- package/dist/adapters/codex-commit-check.js +37 -10
- package/dist/adapters/codex.js +10 -4
- package/dist/cli/commands/beat.d.ts +7 -2
- package/dist/cli/commands/beat.js +144 -77
- package/dist/cli/commands/fleet.js +66 -21
- package/dist/cli/commands/init.js +18 -7
- package/dist/cli/commands/plan.js +16 -18
- package/dist/cli/commands/verify.js +4 -2
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +14 -6
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/drivers/herdr.js +2 -2
- package/dist/drivers/orca.d.ts +12 -4
- package/dist/drivers/orca.js +200 -39
- package/dist/gates/baseline.d.ts +9 -0
- package/dist/gates/baseline.js +14 -3
- package/dist/gates/cache.js +1 -1
- package/dist/gates/evidence.js +3 -3
- package/dist/gates/review.d.ts +1 -1
- package/dist/gates/review.js +77 -13
- package/dist/gates/run-gates.d.ts +21 -6
- package/dist/gates/run-gates.js +439 -224
- package/dist/gates/test-manifest.d.ts +42 -0
- package/dist/gates/test-manifest.js +203 -118
- package/dist/run/beat-lifecycle.d.ts +239 -0
- package/dist/run/beat-lifecycle.js +1782 -0
- package/dist/run/daemon.d.ts +4 -0
- package/dist/run/daemon.js +129 -40
- package/dist/run/git-trust.d.ts +102 -0
- package/dist/run/git-trust.js +334 -0
- package/dist/run/git.d.ts +24 -3
- package/dist/run/git.js +169 -30
- package/dist/run/journal.js +15 -3
- package/dist/run/lease.d.ts +23 -0
- package/dist/run/lease.js +232 -38
- package/dist/run/merge.js +4 -4
- package/dist/run/stall.js +20 -1
- package/dist/run/supervision.d.ts +8 -0
- package/dist/run/supervision.js +24 -4
- package/dist/run/verification-job.d.ts +53 -0
- package/dist/run/verification-job.js +159 -0
- package/dist/tui/ink/fleet-app.d.ts +16 -2
- package/dist/tui/ink/fleet-app.js +265 -141
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +80 -27
- package/skills/tickmarkr-loop/SKILL.md +87 -27
- package/skills/tickmarkr-overseer/SKILL.md +125 -47
- package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
package/dist/gates/run-gates.js
CHANGED
|
@@ -7,9 +7,10 @@ import { TIER_RANK } from "../config/config.js";
|
|
|
7
7
|
import { getAdapter } from "../adapters/registry.js";
|
|
8
8
|
import { GATE_NAMES } from "../graph/schema.js";
|
|
9
9
|
import { acceptanceGate } from "./acceptance.js";
|
|
10
|
-
import { compareToBaseline, effectiveCeilingMs, waitForCalmWindow, calmWindowReady } from "./baseline.js";
|
|
10
|
+
import { classifiedFileDurations, compareToBaseline, effectiveCeilingMs, waitForCalmWindow, calmWindowReady } from "./baseline.js";
|
|
11
11
|
import { evidenceGate } from "./evidence.js";
|
|
12
|
-
import { captureLlmOutput } from "./llm.js";
|
|
12
|
+
import { captureLlmOutput, SeatLaunchError } from "./llm.js";
|
|
13
|
+
import { withLaunchGuard } from "../drivers/orca.js";
|
|
13
14
|
import { disallowedBy, observedSeat } from "../route/preference.js";
|
|
14
15
|
import { marginalCostRank } from "../route/router.js";
|
|
15
16
|
import { carriedAuthorVendors, gateReviewerFloor, pickReviewer, reviewGate } from "./review.js";
|
|
@@ -18,7 +19,7 @@ import { discoverTestManifest, evaluateManifestedTest, isVitestTestCommand, VITE
|
|
|
18
19
|
import { RUNNER_INFRA_DIAGNOSTIC_RE, timeoutShaped } from "./timeout-shaped.js";
|
|
19
20
|
import { executionSignal } from "../run/execution-budget.js";
|
|
20
21
|
import { failureDisposition } from "../run/recovery.js";
|
|
21
|
-
import { dependencyLinkRefusal, FORK_CAP_ENV, preserveWorktree, producerFields, ROUTING_ENV_SEAMS, shGit, resolvedCapacity, SUITE_PARENT_ENV, verificationProtocol } from "../run/git.js";
|
|
22
|
+
import { dependencyLinkRefusal, FORK_CAP_ENV, preserveWorktree, producerFields, readCapacity, ROUTING_ENV_SEAMS, shGit, resolvedCapacity, sameCapacity, SUITE_PARENT_ENV, verificationProtocol } from "../run/git.js";
|
|
22
23
|
import { withJudgeInvocationEvidence } from "../run/journal.js";
|
|
23
24
|
import { computeVerificationIdentity, verificationIdentityKey, formatReusedRow, getVerdictStore, isInfraResult, resolveStateDir, reusedIdentity, } from "./cache.js";
|
|
24
25
|
const productionLoadProvider = () => loadavg()[0] ?? 0;
|
|
@@ -122,6 +123,9 @@ const TEST_FILE_RE = /(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
|
122
123
|
// relative specifiers only — `from "./x.js"`, `import("./x.js")`, `require("./x.js")`
|
|
123
124
|
const IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)["'](\.[^"']*)["']/g;
|
|
124
125
|
const SELECTION_FILE_CAP = 3000;
|
|
126
|
+
// v2.6.7 T1: the paths the import scan can analyze — the cap bounds THESE, never every tracked path, so
|
|
127
|
+
// planning files, fixtures and docs cannot push an attributable source/test change over it.
|
|
128
|
+
const ANALYZABLE_RE = /\.[cm]?[jt]sx?$/;
|
|
125
129
|
/**
|
|
126
130
|
* T4: the tests covering this round's diff, or undefined when the diff cannot be attributed with
|
|
127
131
|
* certainty — a rename or delete (the old path's coverage is gone), or a changed file no test
|
|
@@ -135,32 +139,40 @@ const SELECTION_FILE_CAP = 3000;
|
|
|
135
139
|
* (tsconfig paths, package exports) if selection ever misses often enough to be worth a round.
|
|
136
140
|
*/
|
|
137
141
|
async function coveringTests(worktree, baseRef) {
|
|
138
|
-
const
|
|
142
|
+
const unsupported = { reason: "unsupported-attribution" };
|
|
143
|
+
// -z: NUL-delimited and never C-quoted, so a non-ASCII path keeps its real extension (v2.6.7 T1 review)
|
|
144
|
+
const diff = await shGit(`git diff -z --name-status ${shq(baseRef)} HEAD`, worktree);
|
|
139
145
|
if (diff.code !== 0)
|
|
140
|
-
return
|
|
146
|
+
return unsupported;
|
|
141
147
|
const changed = [];
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
const fields = diff.stdout.split("\0");
|
|
149
|
+
for (let i = 0; i < fields.length; i++) {
|
|
150
|
+
const status = fields[i];
|
|
151
|
+
if (!status)
|
|
144
152
|
continue;
|
|
145
|
-
const parts = line.split("\t");
|
|
146
|
-
const status = parts[0] ?? "";
|
|
147
153
|
// R (rename) and D (delete): whatever used to cover the old path is unattributable now — full suite.
|
|
148
|
-
if (
|
|
149
|
-
return
|
|
150
|
-
|
|
154
|
+
if (status[0] === "R" || status[0] === "D")
|
|
155
|
+
return unsupported;
|
|
156
|
+
if (status[0] === "C")
|
|
157
|
+
i++; // a copy carries its source path first; attribute the destination
|
|
158
|
+
const path = fields[++i];
|
|
159
|
+
if (!path)
|
|
160
|
+
return unsupported;
|
|
161
|
+
changed.push(path);
|
|
151
162
|
}
|
|
152
163
|
if (!changed.length)
|
|
153
|
-
return
|
|
154
|
-
const listed = await shGit("git ls-files", worktree);
|
|
164
|
+
return unsupported;
|
|
165
|
+
const listed = await shGit("git ls-files -z", worktree);
|
|
155
166
|
if (listed.code !== 0)
|
|
156
|
-
return
|
|
157
|
-
const tracked = listed.stdout.split("\
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
return unsupported;
|
|
168
|
+
const tracked = listed.stdout.split("\0").filter(Boolean);
|
|
169
|
+
// ponytail: a huge repo pays the full suite rather than a long scan — counted over analyzable paths only
|
|
170
|
+
if (tracked.filter((p) => ANALYZABLE_RE.test(p)).length > SELECTION_FILE_CAP)
|
|
171
|
+
return { reason: "analyzable-path-cap" };
|
|
160
172
|
const trackedSet = new Set(tracked);
|
|
161
173
|
const tests = tracked.filter((p) => TEST_FILE_RE.test(p));
|
|
162
174
|
if (!tests.length)
|
|
163
|
-
return
|
|
175
|
+
return { reason: "no-covering-tests" };
|
|
164
176
|
const resolveSpec = (from, spec) => {
|
|
165
177
|
const base = posix.join(posix.dirname(from), spec);
|
|
166
178
|
// ESM-TS writes ".js" for a ".ts" source; a directory specifier means its index.
|
|
@@ -199,12 +211,14 @@ async function coveringTests(worktree, baseRef) {
|
|
|
199
211
|
continue;
|
|
200
212
|
}
|
|
201
213
|
const covering = tests.filter((t) => reachOf(t).has(file));
|
|
202
|
-
|
|
203
|
-
|
|
214
|
+
// A changed source no test reaches is unattributable: a diagnostic over the rest would screen around
|
|
215
|
+
// it, so the whole selection is unsupported. A non-source path (docs, planning) carries no imports.
|
|
216
|
+
if (!covering.length && ANALYZABLE_RE.test(file))
|
|
217
|
+
return unsupported;
|
|
204
218
|
for (const t of covering)
|
|
205
219
|
selected.add(t);
|
|
206
220
|
}
|
|
207
|
-
return selected.size ? [...selected].sort() :
|
|
221
|
+
return selected.size ? { files: [...selected].sort() } : { reason: "no-covering-tests" };
|
|
208
222
|
}
|
|
209
223
|
/**
|
|
210
224
|
* The configured test command narrowed to these files. Mirrors testFiltered's `--` rule (acceptance.ts:104):
|
|
@@ -216,23 +230,56 @@ export function testCommandForFiles(testCmd, files) {
|
|
|
216
230
|
const fwd = wrapped && !/\s--\s/.test(testCmd) ? " --" : "";
|
|
217
231
|
return `${testCmd}${fwd} ${files.map(shq).join(" ")}`;
|
|
218
232
|
}
|
|
219
|
-
/**
|
|
220
|
-
export const
|
|
233
|
+
/** v2.6.7 T1 (closed order table): an attributed test-red diagnostic is admitted only when BOTH hold. */
|
|
234
|
+
export const DIAGNOSTIC_MAX_RATIO = 0.15;
|
|
235
|
+
export const DIAGNOSTIC_MAX_ESTIMATE_MS = 60_000;
|
|
221
236
|
/**
|
|
222
|
-
* The
|
|
223
|
-
*
|
|
224
|
-
*
|
|
237
|
+
* The diagnostic's admission evidence, from the per-file durations the harness measured at baseline
|
|
238
|
+
* capture — never a worker's timing. Unknown (any selected file unmeasured, any measurement that is not a
|
|
239
|
+
* finite number > 0 ms, an infra baseline, or no positive total) skips: an expensive or unknown screen must
|
|
240
|
+
* never become a full suite before semantics.
|
|
241
|
+
* So does timing recorded under a malformed or different capacity (git.ts sameCapacity): it divided the
|
|
242
|
+
* machine by another number, so it is not comparable evidence about this one.
|
|
243
|
+
* `estimatedMs` is the serial sum of those files: admission evidence, not a kill ceiling and not a
|
|
244
|
+
* claim about the parallel-plus-serial wall time the runner will actually take.
|
|
225
245
|
*/
|
|
226
|
-
export function
|
|
246
|
+
export function diagnosticAdmission(baseline, selected) {
|
|
227
247
|
const entry = baseline.commands.test;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
248
|
+
// The CURRENT capacity is validated on its own first: an unstamped (absent) baseline capacity is comparable
|
|
249
|
+
// to anything, so it must never wave through a zero, negative, NaN, infinite or fractional current one.
|
|
250
|
+
const current = resolvedCapacity();
|
|
251
|
+
if (readCapacity(current).state !== "present" || !sameCapacity(entry?.capacity, current))
|
|
252
|
+
return { admitted: false, reason: "diagnostic-capacity-mismatch" };
|
|
253
|
+
const raw = entry?.infra ? [] : classifiedFileDurations(entry) ?? [];
|
|
254
|
+
const unknown = { admitted: false, reason: "diagnostic-unknown-cost" };
|
|
255
|
+
// Every input is checked BEFORE any arithmetic touches it: an entry that is not an object whose duration
|
|
256
|
+
// is a finite number > 0 (0 ms, negative, NaN, non-numeric, an object with no primitive value), selected
|
|
257
|
+
// or not, is unknown cost — never a free screen and never a throw.
|
|
258
|
+
const measured = (f) => {
|
|
259
|
+
const d = typeof f === "object" && f !== null ? f.durationMs : undefined;
|
|
260
|
+
return typeof d === "number" && Number.isFinite(d) && d > 0;
|
|
261
|
+
};
|
|
262
|
+
if (!Array.isArray(raw) || !raw.every(measured) || !selected.length)
|
|
263
|
+
return unknown;
|
|
264
|
+
const files = raw;
|
|
265
|
+
// Baseline capture keeps one entry per (project, file): a file's cost is the sum of its measurements.
|
|
266
|
+
const cost = new Map();
|
|
267
|
+
for (const f of files)
|
|
268
|
+
cost.set(f.file, (cost.get(f.file) ?? 0) + f.durationMs);
|
|
232
269
|
const total = files.reduce((sum, f) => sum + f.durationMs, 0);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
270
|
+
// Finite inputs can still overflow: a non-finite total or estimate is no comparable evidence either.
|
|
271
|
+
if (!(total > 0) || !Number.isFinite(total) || selected.some((file) => !((cost.get(file) ?? 0) > 0)))
|
|
272
|
+
return unknown;
|
|
273
|
+
// A selected file that was RED in the baseline ended early there: its cost is unknown, never cheap.
|
|
274
|
+
if (files.some((f) => f.failed === true && selected.includes(f.file)))
|
|
275
|
+
return unknown;
|
|
276
|
+
const estimatedMs = selected.reduce((sum, file) => sum + cost.get(file), 0);
|
|
277
|
+
if (!Number.isFinite(estimatedMs))
|
|
278
|
+
return unknown;
|
|
279
|
+
const costRatio = estimatedMs / total;
|
|
280
|
+
const reason = costRatio > DIAGNOSTIC_MAX_RATIO ? "diagnostic-cost-ratio"
|
|
281
|
+
: estimatedMs > DIAGNOSTIC_MAX_ESTIMATE_MS ? "diagnostic-cost-estimate" : "diagnostic-admitted";
|
|
282
|
+
return { admitted: reason === "diagnostic-admitted", reason, costRatio, estimatedMs };
|
|
236
283
|
}
|
|
237
284
|
/** OBS-635: the full manifest the runner lists NOW, under the environment evaluateManifestedTest's own
|
|
238
285
|
* discovery receives (test-manifest.ts manifestEnvironment), so it compares with the one a verdict
|
|
@@ -264,7 +311,7 @@ async function listFullManifest(cmd, worktree) {
|
|
|
264
311
|
async function runVitestManifestGate(worktree, cmd, baseline, selected, artifactDir, retry = {}, retried = false) {
|
|
265
312
|
const entry = baseline.commands.test;
|
|
266
313
|
const outcome = await evaluateManifestedTest(cmd, worktree, {
|
|
267
|
-
baselineDurations: entry
|
|
314
|
+
baselineDurations: classifiedFileDurations(entry),
|
|
268
315
|
longestFile: entry?.longestFile,
|
|
269
316
|
overallCeilingMs: effectiveCeilingMs(entry),
|
|
270
317
|
artifactDir,
|
|
@@ -325,14 +372,26 @@ const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+co
|
|
|
325
372
|
const FAILURE_IDENTITY_RE = /\b(?:AssertionError|FAIL\s+\S|Tests?\s+\d+\s+failed|expected\s+.+\s+to\s+)\b/i;
|
|
326
373
|
/** D1: apply the daemon's signal-only rider before either battery cache read or write. Its onGate
|
|
327
374
|
* classification happens after persistence, too late to keep a scripted runner's non-verdict out.
|
|
328
|
-
* Keep named failures as work verdicts and preserve details for failure-policy fingerprinting.
|
|
329
|
-
|
|
330
|
-
|
|
375
|
+
* Keep named failures as work verdicts and preserve details for failure-policy fingerprinting.
|
|
376
|
+
* A baseline-forgiven "green" whose runner was signal-killed is the same non-verdict: a killed job
|
|
377
|
+
* completed no suite, so forgiveness never publishes or caches it green. The authoritative termination
|
|
378
|
+
* receipt decides, never the verdict text: a signal-terminated job is killed whatever its details say, and
|
|
379
|
+
* a green stands only on a receipt showing a normal exit — a receiptless CACHED one (an older or planted
|
|
380
|
+
* entry) is unavailable proof, so a fresh job measures instead. A fresh row is this invocation's own: every
|
|
381
|
+
* production runner attaches its receipt, and the manifest path's verdict already requires a clean exit
|
|
382
|
+
* (test-manifest.ts verifyManifestReport). A no-command skip ran nothing and claims none. */
|
|
383
|
+
function classifySignalOnlyTest(g, cached = false) {
|
|
384
|
+
const termination = g.evidenceReceipt?.termination.kind;
|
|
385
|
+
const exitCode = g.evidenceReceipt?.termination.exitCode;
|
|
386
|
+
const killed = termination === "signal" || exitCode === 137 || exitCode === 143 || (!g.pass && SIGNAL_EXIT_RE.test(g.details));
|
|
387
|
+
const unproven = g.pass && g.meta?.skipped !== true && termination !== "exit" && (cached || termination !== undefined);
|
|
388
|
+
if (g.gate !== "test" || g.meta?.infra === true || !(killed || unproven))
|
|
331
389
|
return;
|
|
332
390
|
const named = Array.isArray(g.meta?.failingTests) && g.meta.failingTests.length > 0;
|
|
333
|
-
if (named || FAILURE_IDENTITY_RE.test(g.details))
|
|
391
|
+
if (!g.pass && (named || FAILURE_IDENTITY_RE.test(g.details)))
|
|
334
392
|
return;
|
|
335
|
-
g.
|
|
393
|
+
g.pass = false;
|
|
394
|
+
g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false, kind: killed || termination ? "signal-exit" : "no-exit-receipt" };
|
|
336
395
|
}
|
|
337
396
|
/** OBS-1151: a criterion's comparable subject — its canonical text, the task's declared bounds and the
|
|
338
397
|
* operator context. The cited files' blobs are compared separately, over the union of both citations. */
|
|
@@ -421,9 +480,8 @@ export async function runGates(task, ctx) {
|
|
|
421
480
|
await receiptNotes;
|
|
422
481
|
};
|
|
423
482
|
let selectionDecision;
|
|
424
|
-
// OBS-635:
|
|
425
|
-
let
|
|
426
|
-
let batteryFullIdentity;
|
|
483
|
+
// OBS-635: a stale in-battery full green buys one fresh merge-candidate job (see answersNow).
|
|
484
|
+
let rerunFull = false;
|
|
427
485
|
let commits = [];
|
|
428
486
|
// Check before cache identity, npm policy probes, or any gate command.
|
|
429
487
|
const dependencyRefusal = dependencyLinkRefusal(ctx.worktree);
|
|
@@ -482,28 +540,20 @@ export async function runGates(task, ctx) {
|
|
|
482
540
|
};
|
|
483
541
|
const shapeGates = ctx.cfg.gates.byShape?.[task.shape];
|
|
484
542
|
const enabled = (g) => task.gates.includes(g) && (g !== "acceptance" && g !== "review" || shapeGates?.[g] !== false);
|
|
485
|
-
// T4 (OBS-265): a GREEN selected-test run is a screen, not the round's verdict — the merge-candidate
|
|
486
|
-
// round re-runs the full suite on the same commit and THAT is what the round reports. With no
|
|
487
|
-
// semantic gate to act on it, the screen is held so its full suite speaks for it in one row.
|
|
488
|
-
// (A RED screen IS the verdict: the round ends there, so it is recorded immediately.)
|
|
489
|
-
let heldTest;
|
|
490
543
|
const semantic = enabled("acceptance") || enabled("review");
|
|
491
|
-
// v2.6.
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
// it carries only its own receipts and interval, so it neither erases nor re-counts the screen.
|
|
505
|
-
// Semantic-first, nothing acts on the screen but its full suite: it is held like a gate-less round's.
|
|
506
|
-
const publishScreen = semantic && !semanticFirst;
|
|
544
|
+
// v2.6.7 T1 (closed order table): after the cheap build/lint/evidence/scope checks, acceptance and
|
|
545
|
+
// review run BEFORE any test-gate payload for every candidate — fresh, semantic repair, or a test-red
|
|
546
|
+
// repair without an admitted diagnostic — so a decisive semantic red costs zero test starts. Only a
|
|
547
|
+
// concrete attributed behavioral test-red repair may buy one cheap diagnostic first (see below).
|
|
548
|
+
// A review that returned NO verdict is not a red that ends the round before its test proof: the
|
|
549
|
+
// daemon re-asks only the review (runReviewRecovery) and merges on the rows beside it, so the round
|
|
550
|
+
// still buys its full job. The row stays red; the round is unsatisfied until that recovery answers.
|
|
551
|
+
// A gate that DECLINED (meta.skipped, R3) is no red either: a declined review no longer ends the round
|
|
552
|
+
// just because it now precedes the test gate.
|
|
553
|
+
const failed = () => results.some((r) => !r.pass && r.meta?.skipped !== true && !(r.gate === "review" && r.meta?.noVerdict === true));
|
|
554
|
+
// D-974: set once the cheap checks pass. From then on an enabled gate the round leaves unrun is owed
|
|
555
|
+
// proof (done()) unless a decisive red already decided the round.
|
|
556
|
+
let proofOwed = false;
|
|
507
557
|
// v2.0 T2 (OBS-554): this round's per-gate measurement. Every interval a gate actually spends
|
|
508
558
|
// executing is added HERE, at the call site that runs it, so a gate that runs twice (the test
|
|
509
559
|
// gate's screen and its full suite) sums to its own cost and never to the span between them.
|
|
@@ -581,14 +631,7 @@ export async function runGates(task, ctx) {
|
|
|
581
631
|
await ctx.onGate?.({ phase: "start", gate, index: indexOf(gate), total, ...(parentAt === undefined ? {} : { parentAt }) });
|
|
582
632
|
};
|
|
583
633
|
// The returned array stays in GATE_NAMES order however few gates a short-circuiting round reached.
|
|
584
|
-
// A round that ends before its merge-candidate stage flushes the held screen on the way out, so a
|
|
585
|
-
// green subset run is still journaled exactly once — as a selected run, which is what it was.
|
|
586
634
|
const done = async () => {
|
|
587
|
-
if (heldTest) {
|
|
588
|
-
const held = heldTest;
|
|
589
|
-
heldTest = undefined;
|
|
590
|
-
await ctx.onGate?.({ phase: "end", gate: "test", result: held }); // stamped when it was held
|
|
591
|
-
}
|
|
592
635
|
const sorted = [...results].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate));
|
|
593
636
|
// v1.87 T5: no round returns a MERGEABLE GREEN on a dirty tree. The battery is not the only gate
|
|
594
637
|
// that executes shell in this worktree — the acceptance gate runs command and named-test oracles
|
|
@@ -607,6 +650,29 @@ export async function runGates(task, ctx) {
|
|
|
607
650
|
await ctx.onGate?.({ phase: "end", gate: refusal.gate, result: refusal });
|
|
608
651
|
}
|
|
609
652
|
}
|
|
653
|
+
// D-974 (closed missing-proof table): owed rows land ONLY when the round holds no decisive red —
|
|
654
|
+
// every unsatisfied row is infra or no-verdict and could be recovered — so a review-only recovery
|
|
655
|
+
// can never merge over a gate the round never ran. After a decisive rejection the gates it left
|
|
656
|
+
// unrun owe nothing. A selected diagnostic green is not full proof, so it is owed too. Gates the
|
|
657
|
+
// task omits or its shape disables are outside `sequence` and create no debt.
|
|
658
|
+
const unsatisfied = results.filter((r) => !(r.pass || r.meta?.skipped === true) || r.meta?.infra === true);
|
|
659
|
+
if (proofOwed && unsatisfied.length && unsatisfied.every((r) => r.meta?.infra === true || r.meta?.noVerdict === true)) {
|
|
660
|
+
for (const gate of sequence) {
|
|
661
|
+
if (gate === "test" && ctx.commands.test === undefined)
|
|
662
|
+
continue;
|
|
663
|
+
const at = results.findIndex((r) => r.gate === gate);
|
|
664
|
+
const diagnosticOnly = gate === "test" && at >= 0 && results[at].pass
|
|
665
|
+
&& Array.isArray(results[at].meta?.selectedTests) && results[at].meta?.fullSuite !== true;
|
|
666
|
+
if (at >= 0 && !diagnosticOnly)
|
|
667
|
+
continue;
|
|
668
|
+
if (at >= 0)
|
|
669
|
+
results.splice(at, 1);
|
|
670
|
+
await record({ gate, pass: false, details: `${gate} not run — the round ended on a recoverable non-verdict before `
|
|
671
|
+
+ `${gate === "test" ? "its full verification job" : "this gate published a verdict"}; that proof is still owed on this subject`,
|
|
672
|
+
meta: { skipped: true, infra: true, classification: "infra", retryable: false, gateOwed: gate, ...(gate === "test" ? { testOwed: true } : {}) } });
|
|
673
|
+
}
|
|
674
|
+
return { results: [...results].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate)), commits };
|
|
675
|
+
}
|
|
610
676
|
return { results: sorted, commits };
|
|
611
677
|
};
|
|
612
678
|
const toolGates = ["build", "lint"].filter(enabled);
|
|
@@ -803,18 +869,48 @@ export async function runGates(task, ctx) {
|
|
|
803
869
|
capacity: resolvedCapacity(),
|
|
804
870
|
});
|
|
805
871
|
// OBS-635: a full green answers only for the manifest it certified. The tree identity cannot see an
|
|
806
|
-
// ignored generated test the runner would collect, so every full-green
|
|
807
|
-
// runner's listing and requires the verdict's to equal it; a runner without a listing is bound by
|
|
808
|
-
// its identity alone.
|
|
809
|
-
|
|
810
|
-
|
|
872
|
+
// ignored generated test the runner would collect, so every full-green check rediscovers the
|
|
873
|
+
// runner's listing NOW and requires the verdict's to equal it; a runner without a listing is bound by
|
|
874
|
+
// its identity alone. So is a FRESH green that certified no manifest beside a runner that lists none
|
|
875
|
+
// now: neither side holds a manifest that could have moved. A cached one certifies nothing (no reuse).
|
|
876
|
+
// OOB M1: the listing is itself a command — one that creates an ignored collectable test AFTER taking
|
|
877
|
+
// its own snapshot returns the manifest it just made stale, and neither cleanliness nor the identity
|
|
878
|
+
// sees an ignored file. So an agreeing listing certifies only when STABLE: one more listing, taken
|
|
879
|
+
// after the first one's side effects, must return the same set (one extra listing per proof, and only
|
|
880
|
+
// on the agreeing path). An unstable listing is stale proof like any other moved manifest.
|
|
881
|
+
const certifiesFullManifest = async (verdict, fresh = false) => {
|
|
811
882
|
if (!verdict.pass || !isVitestTestCommand(ctx.commands.test, ctx.worktree))
|
|
812
883
|
return true;
|
|
813
884
|
const certified = verdict.meta?.manifest;
|
|
814
|
-
const current = await
|
|
815
|
-
|
|
816
|
-
|
|
885
|
+
const current = await listFullManifest(ctx.commands.test, ctx.worktree);
|
|
886
|
+
if (fresh && certified === undefined && current === undefined)
|
|
887
|
+
return true;
|
|
888
|
+
if (!Array.isArray(certified) || current === undefined
|
|
889
|
+
|| certified.length !== current.length || ![...certified].sort().every((file, i) => file === current[i]))
|
|
890
|
+
return false;
|
|
891
|
+
const settled = await listFullManifest(ctx.commands.test, ctx.worktree);
|
|
892
|
+
return settled !== undefined && settled.length === current.length && settled.every((file, i) => file === current[i]);
|
|
817
893
|
};
|
|
894
|
+
// OBS-635: a full green — fresh or cached — answers only while the identity it measured and the complete
|
|
895
|
+
// manifest the runner lists NOW still hold — the job's own command may have written an ignored lockfile or
|
|
896
|
+
// a test the runner now collects. Checked before that green is cached, reused or published. `strict` (the in-battery job,
|
|
897
|
+
// which may still buy a fresh one) adds D-598: an unmeasurable lifecycle on either side is not comparable
|
|
898
|
+
// to anything (VerdictStore R41 refuses it), so two `unknown`s hashing equal is no unchanged identity.
|
|
899
|
+
const answersNow = async (before, verdict, strict, fresh = strict) => {
|
|
900
|
+
// The listing is a command too (it may write an ignored lockfile or a tracked byte), so it runs FIRST
|
|
901
|
+
// and cleanliness and identity are sampled after it — never before.
|
|
902
|
+
if (!(await certifiesFullManifest(verdict, fresh)) || await dirtyWorktree())
|
|
903
|
+
return false;
|
|
904
|
+
const now = await fullTestIdentity();
|
|
905
|
+
const measurable = (id) => !strict || id.envParts?.verification?.lifecycle !== "unknown";
|
|
906
|
+
return !!now && !!before && measurable(now) && measurable(before)
|
|
907
|
+
&& verificationIdentityKey(now) === verificationIdentityKey(before);
|
|
908
|
+
};
|
|
909
|
+
// A stale green is never green: an infra row naming why, unverdicted (skipped) while a fresh job supersedes it.
|
|
910
|
+
const staleProof = (r, superseded) => ({ ...r, pass: false,
|
|
911
|
+
details: `stale full proof: this job's own command moved the verification identity or the complete manifest the runner `
|
|
912
|
+
+ `lists now, so its green answers an earlier subject — ${superseded ? "one fresh full job measures this one" : "failing closed"}\n${r.details}`,
|
|
913
|
+
meta: { ...r.meta, infra: true, classification: "infra", retryable: false, staleProof: true, ...(superseded ? { skipped: true } : {}) } });
|
|
818
914
|
// shell tools vs the shared baseline
|
|
819
915
|
const retryOptions = (identity) => ctx.authorizeInfraRetry
|
|
820
916
|
? { authorizeRetry: (cause) => ctx.authorizeInfraRetry(identity ? verificationIdentityKey(identity) : "", cause === "infra" ? "infrastructure" : "host-starved") }
|
|
@@ -842,14 +938,21 @@ export async function runGates(task, ctx) {
|
|
|
842
938
|
});
|
|
843
939
|
const hit = verdictStore.get(identity);
|
|
844
940
|
if (hit)
|
|
845
|
-
classifySignalOnlyTest(hit); // Older entries predate classification at the write seam.
|
|
941
|
+
classifySignalOnlyTest(hit, true); // Older entries predate classification at the write seam.
|
|
846
942
|
if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")
|
|
847
|
-
&& !(await discardCachedRed(g, hit))
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
943
|
+
&& !(await discardCachedRed(g, hit))) {
|
|
944
|
+
// RE-PROOF: a cached FULL green is reused only after the current manifest listing, then cleanliness
|
|
945
|
+
// and the identity sampled after it, agree with it. Stale, it is never relabelled: the fresh job
|
|
946
|
+
// measures under the identity sampled now.
|
|
947
|
+
if (g !== "test" || selected !== undefined || !hit.pass || await answersNow(identity, hit, false)) {
|
|
948
|
+
r = formatReusedRow(hit, identity);
|
|
949
|
+
cached = true;
|
|
950
|
+
if (g === "build")
|
|
951
|
+
await noBuild("reused-result", "verdict reused; no fresh build ran in this invocation");
|
|
952
|
+
await noteReuse(g, r, identity);
|
|
953
|
+
}
|
|
954
|
+
else
|
|
955
|
+
identity = await fullTestIdentity();
|
|
853
956
|
}
|
|
854
957
|
}
|
|
855
958
|
if (!r) {
|
|
@@ -871,10 +974,6 @@ export async function runGates(task, ctx) {
|
|
|
871
974
|
// the screen's interval IS the test gate's first interval, so the split needs no second clock
|
|
872
975
|
if (g === "test" && selected)
|
|
873
976
|
selectedDurationMs = spans.get("test")?.durationMs ?? 0;
|
|
874
|
-
if (g === "test" && !selected) {
|
|
875
|
-
fullInBattery = true;
|
|
876
|
-
batteryFullIdentity = identity;
|
|
877
|
-
}
|
|
878
977
|
// The pre-battery check proves the tree clean ONCE; a command that exits 0 having rewritten a
|
|
879
978
|
// tracked file makes it dirty again, and every gate after it — including the next shell gate,
|
|
880
979
|
// which would then run against bytes HEAD does not hold — inherits that. So re-check after each
|
|
@@ -889,24 +988,31 @@ export async function runGates(task, ctx) {
|
|
|
889
988
|
}
|
|
890
989
|
if (r)
|
|
891
990
|
classifySignalOnlyTest(r);
|
|
991
|
+
// Every round's in-battery full job is its first — test-only verification included: stale, it is
|
|
992
|
+
// published unverdicted and buys one fresh merge-candidate job below, whose own stale green fails closed.
|
|
993
|
+
if (g === "test" && !selected && cmd !== undefined && !cached && r.pass && !(await answersNow(identity, r, true))) {
|
|
994
|
+
r = staleProof(r, true);
|
|
995
|
+
rerunFull = true;
|
|
996
|
+
}
|
|
892
997
|
if (!cached && identity && r && !isInfraResult(r)) {
|
|
893
998
|
verdictStore.set(identity, { ...r, meta: { ...r.meta, source: "gate", runDir: ctx.artifactDir } });
|
|
894
999
|
}
|
|
895
1000
|
if (g === "test" && selected) {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1001
|
+
// v2.6.7 T1: the attributed diagnostic. A behavioral red IS the round's verdict and ends it
|
|
1002
|
+
// before semantics. An infrastructure red is no verdict either way: it is published UNVERDICTED
|
|
1003
|
+
// (skipped — the journal row keeps its infra evidence and receipts but no `pass`, so the review
|
|
1004
|
+
// round counter never reads it as a decisive test red) and never enters the round's results, so
|
|
1005
|
+
// it can neither end the round nor become green — semantics and the full job decide. A green is
|
|
1006
|
+
// published as its own selected row; the full job afterwards replaces it in the results.
|
|
1007
|
+
const inconclusive = !r.pass && isInfraResult(r);
|
|
1008
|
+
const screened = withTelemetry({ ...r, meta: { ...r.meta, selectedTests: selected, ...(inconclusive ? { skipped: true } : {}) } });
|
|
1009
|
+
if (!inconclusive)
|
|
1010
|
+
results.push(screened);
|
|
1011
|
+
await ctx.onGate?.({ phase: "end", gate: "test", result: screened });
|
|
1012
|
+
// The diagnostic's interval now lives on its own row; the full job measures from zero.
|
|
1013
|
+
spans.delete("test");
|
|
1014
|
+
loadSamples.delete("test");
|
|
1015
|
+
selectedDurationMs = undefined;
|
|
910
1016
|
}
|
|
911
1017
|
else {
|
|
912
1018
|
await record(r);
|
|
@@ -1138,27 +1244,66 @@ export async function runGates(task, ctx) {
|
|
|
1138
1244
|
// seat spends two invocations, and one blended span cannot tell a slow reviewer from two.
|
|
1139
1245
|
// A pick that found NO eligible seat dispatched nothing, so it contributes no invocation.
|
|
1140
1246
|
const invocations = [];
|
|
1247
|
+
const retiredNow = () => [...(ctx.reviewNoVerdicts ?? [])].filter(([, causes]) => causes.length >= 2).map(([seat]) => seat);
|
|
1248
|
+
// v2.6.8 T1: retirement is live through the LAUNCH, not only the pick. reviewGate awaits its diff read
|
|
1249
|
+
// after it picks, and a pane seat awaits its slot, so another task can retire the picked seat inside
|
|
1250
|
+
// either await. The seat is named when its command is built and checked against the live tally there and
|
|
1251
|
+
// again as its pane is bound (onSlot runs inside llm.ts's launch guard, which closes that pane unrun), and
|
|
1252
|
+
// a driver that still awaits lookups before it submits the terminal (Orca's runtime probe and worktree
|
|
1253
|
+
// lookup) re-reads it at that submission (withLaunchGuard): a retired seat is never launched, and dispatch
|
|
1254
|
+
// picks again under the live exclusions (atPick) — the next eligible seat, or none. Bounded: each
|
|
1255
|
+
// withdrawal is a seat newly retired, and the pool is finite.
|
|
1256
|
+
let launching = {};
|
|
1257
|
+
const refuseRetired = () => {
|
|
1258
|
+
if (launching.seat === undefined || !retiredNow().includes(launching.seat))
|
|
1259
|
+
return;
|
|
1260
|
+
launching.withdrawn = true;
|
|
1261
|
+
throw new SeatLaunchError(launching.seat, new Error("retired for the run before it launched"));
|
|
1262
|
+
};
|
|
1263
|
+
const reviewVia = via && { ...via, onSlot: (slot) => { via.onSlot?.(slot); refuseRetired(); } };
|
|
1264
|
+
const guarded = (adapter) => new Proxy(adapter, {
|
|
1265
|
+
get(target, property) {
|
|
1266
|
+
if (property === "headlessCommand") {
|
|
1267
|
+
return (promptFile, model, effort) => {
|
|
1268
|
+
launching.seat = channelKey({ adapter: target.id, model });
|
|
1269
|
+
refuseRetired();
|
|
1270
|
+
return target.headlessCommand(promptFile, model, effort);
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
const value = Reflect.get(target, property, target);
|
|
1274
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
1275
|
+
},
|
|
1276
|
+
});
|
|
1141
1277
|
// Dispatch is PROVEN, never inferred: captureLlmOutput records one output per runLlm return, so an
|
|
1142
1278
|
// empty capture means reviewGate returned before asking anyone — a policy skip, a pre-dispatch diff
|
|
1143
1279
|
// cap, or no eligible seat. Reading `noEligibleReviewer` alone missed the first two and invented
|
|
1144
1280
|
// an "unknown" span for each.
|
|
1145
1281
|
const dispatch = async (run) => {
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1282
|
+
const withdrawn = [];
|
|
1283
|
+
let picked;
|
|
1284
|
+
for (;;) {
|
|
1285
|
+
launching = {};
|
|
1286
|
+
const captured = await withLaunchGuard(refuseRetired, () => captureLlmDispatches(ctx.adapters.map(guarded), run));
|
|
1287
|
+
invocations.push(...captured.invocations);
|
|
1288
|
+
picked = captured.value;
|
|
1289
|
+
if (cancelled || !launching.withdrawn)
|
|
1290
|
+
break;
|
|
1291
|
+
withdrawn.push(launching.seat);
|
|
1292
|
+
}
|
|
1293
|
+
const rv = withdrawn.length === 0 ? picked : { ...picked, details: `${withdrawn.map((seat) => `review dispatch withdrawn: ${seat} was retired for the run (two real no-verdicts) after it was picked, before it launched; picked again`).join("\n")}\n${picked.details}` };
|
|
1149
1294
|
if (cancelled)
|
|
1150
1295
|
return rv; // a cancelled seat's non-answer says nothing about the seat
|
|
1151
1296
|
if (rv.meta?.noVerdict === true || rv.meta?.unparseable === true) {
|
|
1152
1297
|
await ctx.onGate?.({ phase: "note", gate: "review", name: "review-no-verdict", payload: { ...rv.meta }, result: rv });
|
|
1153
|
-
|
|
1298
|
+
// v2.6.8 T1: a seat that never LAUNCHED (any launchCause, checkout-proof-timeout or none) said
|
|
1299
|
+
// nothing about the work or about itself: no run-scoped strike and no soft demotion. It gets one
|
|
1300
|
+
// bounded same-seat launch retry below and is excluded for the rest of THIS round only.
|
|
1301
|
+
if (typeof rv.meta.reviewer === "string" && rv.meta.cause !== "seat-launch-failed") {
|
|
1154
1302
|
const reviewer = rv.meta.reviewer;
|
|
1155
1303
|
const cause = String(rv.meta.cause);
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
|
|
1159
|
-
const proofTimeout = cause === "seat-launch-failed" && rv.meta.launchCause === "checkout-proof-timeout";
|
|
1160
|
-
// OBS-1025 add.2: the run-scoped tally; two no-verdicts retire the seat for the rest of the run.
|
|
1161
|
-
const causes = rv.meta.noVerdict === true && ctx.reviewNoVerdicts && !proofTimeout
|
|
1304
|
+
// OBS-1025 add.2: the run-scoped tally of REAL no-verdicts (silent, truncated, closure-mismatch,
|
|
1305
|
+
// no nonce …); two of them retire the seat for the run — a hard exclusion, never a soft ranking.
|
|
1306
|
+
const causes = rv.meta.noVerdict === true && ctx.reviewNoVerdicts
|
|
1162
1307
|
? [...(ctx.reviewNoVerdicts.get(reviewer) ?? []), cause] : undefined;
|
|
1163
1308
|
if (causes)
|
|
1164
1309
|
ctx.reviewNoVerdicts.set(reviewer, causes);
|
|
@@ -1174,15 +1319,34 @@ export async function runGates(task, ctx) {
|
|
|
1174
1319
|
}
|
|
1175
1320
|
return rv;
|
|
1176
1321
|
};
|
|
1177
|
-
// OBS-1025 add.2: seats with two no-verdicts this run
|
|
1178
|
-
|
|
1322
|
+
// OBS-1025 add.2: seats with two REAL no-verdicts this run (launch failures never strike) are RETIRED:
|
|
1323
|
+
// out of every pick below for the rest of the run, and a resumed daemon re-seeds the tally from the
|
|
1324
|
+
// journal. demotedReviewers is only a soft preference, live or replayed on resume — it ranks a seat
|
|
1325
|
+
// last and never excludes or retires it; history or prefer can never resurrect a retired seat.
|
|
1326
|
+
// The tally is shared across the run's tasks, so it is read LIVE at every pick — after every awaited
|
|
1327
|
+
// notification and inside reviewGate after its promotion reads (atPick), since another task can retire a
|
|
1328
|
+
// seat inside any await: a seat retired while this round's dispatch, note or read is pending is out of
|
|
1329
|
+
// the initial pick, the launch retry, the re-emission and the re-route alike — and one retired after its
|
|
1330
|
+
// pick is refused at launch (dispatch, above).
|
|
1331
|
+
const retired = retiredNow();
|
|
1332
|
+
// reviewGate awaits its promotion path reads BEFORE it picks, and another task can retire a seat inside
|
|
1333
|
+
// that await too. So the exclusions every reviewGate call is handed are a live view: each read — the pick's
|
|
1334
|
+
// included — answers from `fixed` plus the tally as it stands at that read, never a copy taken before.
|
|
1335
|
+
// ponytail: a read-through Proxy because review.ts takes a plain list; a live-exclusion parameter there retires it.
|
|
1336
|
+
const atPick = (fixed) => new Proxy([], {
|
|
1337
|
+
get: (_target, prop) => {
|
|
1338
|
+
const now = [...new Set([...fixed, ...retiredNow()])];
|
|
1339
|
+
const value = Reflect.get(now, prop);
|
|
1340
|
+
return typeof value === "function" ? value.bind(now) : value;
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1179
1343
|
// RF-1: THIS task's prior reviewers — earlier rounds' seats plus the seats that produced garbage for
|
|
1180
1344
|
// it (excludeReviewers names only dispatched seats). Kept apart from the eligibility exclusions the
|
|
1181
1345
|
// retry below adds for a flaked seat's whole adapter: those sibling channels never reviewed.
|
|
1182
1346
|
const priorReviewers = [...(ctx.priorReviewers ?? []), ...(ctx.excludeReviewers ?? [])];
|
|
1183
1347
|
const carriedAuthors = ctx.carriedAuthors ?? [];
|
|
1184
1348
|
let exclusions = [...(ctx.excludeReviewers ?? []), ...retired];
|
|
1185
|
-
let rv = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg,
|
|
1349
|
+
let rv = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, reviewVia, atPick(exclusions), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, priorReviewers, carriedAuthors, ctx.operatorContext));
|
|
1186
1350
|
// OBS-193/574: an unparseable review verdict retries the REVIEW, preferring a different adapter. Only
|
|
1187
1351
|
// a single-adapter eligible pool may fall back to another channel on the flaked adapter. The flaked
|
|
1188
1352
|
// verdict never enters results; an exhausted pool preserves its cause.
|
|
@@ -1194,19 +1358,50 @@ export async function runGates(task, ctx) {
|
|
|
1194
1358
|
let hop = 0;
|
|
1195
1359
|
// OBS-1196: seats already re-asked after a malformed verdict — once per seat, so never unbounded.
|
|
1196
1360
|
const reemitted = new Set();
|
|
1361
|
+
// v2.6.8 T1: seats already relaunched after a launch failure — once per seat per candidate (the
|
|
1362
|
+
// daemon replays the spent ones), so a launch retry is bounded and never a second malformed allowance.
|
|
1363
|
+
const launchRetried = new Set(ctx.launchRetried ?? []);
|
|
1197
1364
|
while (!cancelled && (rv.meta?.unparseable === true || rv.meta?.noVerdict === true) && typeof rv.meta.reviewer === "string") {
|
|
1198
1365
|
hop++;
|
|
1199
1366
|
const flaked = rv.meta.reviewer;
|
|
1200
|
-
const retryVia =
|
|
1201
|
-
? { ...
|
|
1367
|
+
const retryVia = reviewVia
|
|
1368
|
+
? { ...reviewVia, nameFor: (role, adapter) => reviewVia.nameFor(role, adapter) + `-r${hop}` }
|
|
1202
1369
|
: undefined;
|
|
1370
|
+
// v2.6.8 T1: bounded same-seat launch retry. A seat that could not launch is launched ONCE more
|
|
1371
|
+
// (fresh launch identity, fresh nonce, same subject) before any other seat is picked, journaled as
|
|
1372
|
+
// the review-infra-retry naming that seat; its verdict decides. A second refusal re-routes below. A
|
|
1373
|
+
// seat two real no-verdicts retired — before the note, while it was awaited, or during the relaunch's
|
|
1374
|
+
// own promotion read (atPick) — is never relaunched: the withdrawn retry is said in the round's
|
|
1375
|
+
// details, and the re-route below excludes the seat.
|
|
1376
|
+
if (rv.meta.cause === "seat-launch-failed" && !launchRetried.has(flaked) && !retiredNow().includes(flaked)) {
|
|
1377
|
+
launchRetried.add(flaked);
|
|
1378
|
+
await ctx.onGate?.({ phase: "note", gate: "review", name: "review-infra-retry", payload: { reviewer: flaked, cause: "seat-launch-failed",
|
|
1379
|
+
sameSeat: true, ...(rv.meta.launchCause ? { launchCause: rv.meta.launchCause } : {}) }, result: rv });
|
|
1380
|
+
const others = ctx.channels.map(channelKey).filter((key) => key !== flaked);
|
|
1381
|
+
const again = retiredNow().includes(flaked) ? undefined : await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(others), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1382
|
+
if (again && again.meta?.noEligibleReviewer !== true) {
|
|
1383
|
+
// The relaunch that completes a malformed verdict's re-emission (the re-emission was what failed to
|
|
1384
|
+
// launch) publishes that delivery, so the seat is a delivery flake again, not a bad reviewer.
|
|
1385
|
+
const reemission = rv.meta.reviewReemission?.reviewer === flaked;
|
|
1386
|
+
if (reemission && again.meta?.unparseable !== true && again.meta?.noVerdict !== true) {
|
|
1387
|
+
await ctx.onGate?.({ phase: "note", gate: "review", name: "review-reemission", payload: { reviewer: flaked, cause: "malformed-verdict",
|
|
1388
|
+
delivered: true, launchRetry: true }, result: again });
|
|
1389
|
+
}
|
|
1390
|
+
routes.push(`review launch retry (same seat, fresh launch): ${flaked} failed to launch; launched once more`);
|
|
1391
|
+
rv = { ...again, details: `${routes.join("\n")}\n${again.details}`, meta: { ...again.meta, reviewLaunchRetry: { reviewer: flaked } } };
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
if (retiredNow().includes(flaked)) {
|
|
1395
|
+
routes.push(`review launch retry withdrawn: ${flaked} was retired for the run (two real no-verdicts) while its relaunch was pending`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1203
1398
|
// OBS-1196: a malformed (unparseable) verdict is a delivery defect of THIS seat, not a reason to drop it. Ask the
|
|
1204
1399
|
// same seat once more on the same subject; reviewGate mints a fresh nonce, so only a new, whole,
|
|
1205
1400
|
// nonce-bound verdict can answer — the malformed bytes are never salvaged into one.
|
|
1206
|
-
if (rv.meta.cause === "malformed-verdict" && rv.meta.closureInvalid !== true && !reemitted.has(flaked)) {
|
|
1401
|
+
if (rv.meta.cause === "malformed-verdict" && rv.meta.closureInvalid !== true && !reemitted.has(flaked) && !retiredNow().includes(flaked)) {
|
|
1207
1402
|
reemitted.add(flaked);
|
|
1208
1403
|
const others = ctx.channels.map(channelKey).filter((key) => key !== flaked);
|
|
1209
|
-
const again = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, others, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1404
|
+
const again = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(others), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1210
1405
|
if (again.meta?.noEligibleReviewer !== true) {
|
|
1211
1406
|
await ctx.onGate?.({ phase: "note", gate: "review", name: "review-reemission", payload: { reviewer: flaked, cause: "malformed-verdict",
|
|
1212
1407
|
delivered: again.meta?.unparseable !== true && again.meta?.noVerdict !== true }, result: again });
|
|
@@ -1214,6 +1409,9 @@ export async function runGates(task, ctx) {
|
|
|
1214
1409
|
rv = { ...again, details: `${routes.join("\n")}\n${again.details}`, meta: { ...again.meta, reviewReemission: { reviewer: flaked } } };
|
|
1215
1410
|
continue;
|
|
1216
1411
|
}
|
|
1412
|
+
if (retiredNow().includes(flaked)) {
|
|
1413
|
+
routes.push(`review re-emission withdrawn: ${flaked} was retired for the run (two real no-verdicts) while its re-emission was pending`);
|
|
1414
|
+
}
|
|
1217
1415
|
}
|
|
1218
1416
|
const emptyOutput = rv.meta.cause === "empty-output";
|
|
1219
1417
|
if (emptyOutput) {
|
|
@@ -1229,16 +1427,18 @@ export async function runGates(task, ctx) {
|
|
|
1229
1427
|
// and the prior reviewers' tiers, the flaked seat's own included, so a retry never drops a tier.
|
|
1230
1428
|
retryPrior = [...retryPrior, flaked];
|
|
1231
1429
|
const retryFloor = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, retryPrior).floor;
|
|
1430
|
+
exclusions = [...new Set([...exclusions, ...retiredNow()])];
|
|
1232
1431
|
const crossAdapter = pickReviewer(ctx.author, ctx.channels, [...exclusions, ...adapterExclusions], ctx.cfg.review.prefer ?? [], retryFloor, undefined, undefined, undefined, carriedAuthorVendors(ctx.channels, carriedAuthors));
|
|
1233
1432
|
const exclusion = crossAdapter ? "adapter" : "channel";
|
|
1234
1433
|
exclusions = [...exclusions, ...(crossAdapter ? adapterExclusions : [flaked])];
|
|
1235
|
-
const second = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, exclusions, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1434
|
+
const second = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(exclusions), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1236
1435
|
if (second.meta?.noEligibleReviewer !== true) {
|
|
1237
1436
|
const retried = typeof second.meta?.reviewer === "string" ? second.meta.reviewer : "none";
|
|
1238
1437
|
const route = exclusion === "adapter"
|
|
1239
1438
|
? `different-adapter retry; excluded flaked adapter ${flakedAdapter}`
|
|
1240
1439
|
: `same-adapter fallback; excluded flaked channel ${flaked}`;
|
|
1241
|
-
const produced = emptyOutput ? "EMPTY output" : rv.meta.cause === "closure-mismatch" ? "a verdict closing no carried fingerprint"
|
|
1440
|
+
const produced = emptyOutput ? "EMPTY output" : rv.meta.cause === "closure-mismatch" ? "a verdict closing no carried fingerprint"
|
|
1441
|
+
: rv.meta.cause === "seat-launch-failed" ? "no verdict (it failed to launch)" : "no parseable verdict";
|
|
1242
1442
|
// `details` is lifted onto the journal's gate-result row; meta.reviewRetry is not. Keep every
|
|
1243
1443
|
// re-route visible in the result text a reader actually opens, including on a red retry.
|
|
1244
1444
|
routes.push(`review re-route (${route}): ${flaked} produced ${produced}; replaced by ${retried}`);
|
|
@@ -1324,9 +1524,11 @@ export async function runGates(task, ctx) {
|
|
|
1324
1524
|
// enough: an acceptance-first await withholds a completed review behind a slow/hung judge and a
|
|
1325
1525
|
// process death can lose that already-earned verdict. The returned result is still sorted into
|
|
1326
1526
|
// GATE_NAMES order by done(); the event stream truthfully records each independent completion.
|
|
1327
|
-
// OBS-1168(c): a sibling that throws, or
|
|
1527
|
+
// OBS-1168(c): a sibling that throws, or a seatless JUDGE (no seat could launch, so the round can
|
|
1328
1528
|
// only park infra), cancels the other — its seats are closed — and the round still AWAITS it, with
|
|
1329
1529
|
// or without an execution policy, so no verdict of a settled round publishes after its engagement.
|
|
1530
|
+
// v2.6.7 T1: a seatless REVIEW is recoverable (runReviewRecovery re-asks it alone), so it cancels
|
|
1531
|
+
// nothing — acceptance finishes independently and publishes its own result.
|
|
1330
1532
|
const seatless = (r) => r.meta?.cause === "seat-launch-failed" && r.meta?.infra === true;
|
|
1331
1533
|
let failure;
|
|
1332
1534
|
const fail = (reason) => {
|
|
@@ -1337,16 +1539,18 @@ export async function runGates(task, ctx) {
|
|
|
1337
1539
|
const judged = judging?.then(async (outcome) => {
|
|
1338
1540
|
if (cancelled)
|
|
1339
1541
|
return;
|
|
1340
|
-
|
|
1341
|
-
|
|
1542
|
+
// D-974: a seatless judge beside an already-settled no-verdict review has no sibling to cancel —
|
|
1543
|
+
// acceptance is MISSING proof: its row is owed and the round still buys its full job.
|
|
1544
|
+
const missing = seatless(outcome.result) && results.some((r) => r.gate === "review" && r.meta?.noVerdict === true);
|
|
1545
|
+
await withJudgeInvocationEvidence(outcome.invocations, () => record(missing
|
|
1546
|
+
? { ...outcome.result, meta: { ...outcome.result.meta, skipped: true, gateOwed: "acceptance" } } : outcome.result));
|
|
1547
|
+
if (seatless(outcome.result) && !missing)
|
|
1342
1548
|
cancelSemantic();
|
|
1343
1549
|
}).catch(fail);
|
|
1344
1550
|
const reviewed = reviewing?.then(async (outcome) => {
|
|
1345
1551
|
if (cancelled)
|
|
1346
1552
|
return;
|
|
1347
1553
|
await record(outcome);
|
|
1348
|
-
if (seatless(outcome))
|
|
1349
|
-
cancelSemantic();
|
|
1350
1554
|
}).catch(fail);
|
|
1351
1555
|
// ponytail: a headless seat has no slot to close; it is awaited to its own timeout, never abandoned.
|
|
1352
1556
|
await Promise.all([judged, reviewed]);
|
|
@@ -1356,108 +1560,105 @@ export async function runGates(task, ctx) {
|
|
|
1356
1560
|
executionSignal()?.throwIfAborted();
|
|
1357
1561
|
return cancelled || failed();
|
|
1358
1562
|
};
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
await emitStart("test");
|
|
1375
|
-
await record(await dirtyRefusal("test", dirt));
|
|
1376
|
-
return done();
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
// A non-final round may run only the tests covering its own diff; the merge-candidate round below
|
|
1380
|
-
// pays the full suite anyway, so a selection that misses costs a round and can never merge.
|
|
1381
|
-
let selected = ctx.selectTests && enabled("test") && ctx.commands.test
|
|
1382
|
-
? await coveringTests(ctx.worktree, ctx.baseRef)
|
|
1383
|
-
: undefined;
|
|
1384
|
-
let selectionReason = ctx.selectionReason ?? (selected ? "affected-tests" : "full-suite-fallback");
|
|
1385
|
-
if (selected && ctx.requiredRepairTests?.length) {
|
|
1386
|
-
const required = ctx.requiredRepairTests;
|
|
1563
|
+
proofOwed = true;
|
|
1564
|
+
const testRuns = enabled("test") && ctx.commands.test !== undefined;
|
|
1565
|
+
// v2.6.7 T1 (closed order table): the ONE diagnostic a round may buy before semantics — a concrete
|
|
1566
|
+
// attributed behavioral test-red repair (the caller's known failing files) whose diagnostic covers
|
|
1567
|
+
// every required failing file plus the conservatively selected tests reaching its diff, admitted only
|
|
1568
|
+
// on comparable harness timing with ratio <= 0.15 AND estimate <= 60000 ms. Selection disabled,
|
|
1569
|
+
// unsupported attribution, the analyzable-path cap or unknown/over-bound timing skip it: semantics
|
|
1570
|
+
// then precede the one full job, never an expensive screen standing in front of them. Test-only
|
|
1571
|
+
// verification (no semantic gate) or an explicit full recheck (no selection) keeps its full scope.
|
|
1572
|
+
let diagnostic;
|
|
1573
|
+
let decision;
|
|
1574
|
+
const required = ctx.requiredRepairTests ?? [];
|
|
1575
|
+
if (testRuns && ctx.selectTests === true && required.length && !semantic)
|
|
1576
|
+
decision = { reason: "test-only-verification" };
|
|
1577
|
+
else if (testRuns && ctx.selectTests === true && required.length) {
|
|
1387
1578
|
const safe = required.every((path) => posix.normalize(path) === path && !path.startsWith("../")
|
|
1388
1579
|
&& !path.startsWith("/") && TEST_FILE_RE.test(path) && existsSync(join(ctx.worktree, path)));
|
|
1389
1580
|
const listed = safe ? await shGit(`git ls-files -z -- ${required.map(shq).join(" ")}`, ctx.worktree) : undefined;
|
|
1390
1581
|
const tracked = new Set(listed?.stdout.split("\0").filter(Boolean));
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1582
|
+
const covering = await coveringTests(ctx.worktree, ctx.baseRef);
|
|
1583
|
+
if (!listed || listed.code !== 0 || required.some((path) => !tracked.has(path)))
|
|
1584
|
+
decision = { reason: "required-repair-test-unavailable" };
|
|
1585
|
+
else if (!covering.files)
|
|
1586
|
+
decision = { reason: covering.reason };
|
|
1587
|
+
else {
|
|
1588
|
+
const files = [...new Set([...covering.files, ...required])].sort();
|
|
1589
|
+
// An exact full green already answers the full job; reusing it is the full job, not a screen.
|
|
1590
|
+
const id = await fullTestIdentity();
|
|
1591
|
+
const hit = verdictStore.get(id);
|
|
1592
|
+
if (hit)
|
|
1593
|
+
classifySignalOnlyTest(hit, true);
|
|
1594
|
+
if (hit?.pass === true && !isInfraResult(hit) && await answersNow(id, hit, false))
|
|
1595
|
+
decision = { reason: "full-green-cache" };
|
|
1596
|
+
else {
|
|
1597
|
+
decision = diagnosticAdmission(ctx.baseline, files);
|
|
1598
|
+
if (decision.admitted)
|
|
1599
|
+
diagnostic = files;
|
|
1600
|
+
}
|
|
1394
1601
|
}
|
|
1395
|
-
else
|
|
1396
|
-
selected = [...new Set([...selected, ...required])].sort();
|
|
1397
|
-
}
|
|
1398
|
-
// OBS-635: a screen buys nothing when the full suite that must follow it already has a qualified
|
|
1399
|
-
// green on this exact identity (read BEFORE any screen), or when the harness-measured screen costs
|
|
1400
|
-
// at least 75 % of it — then the full suite runs in the battery instead. Unknown timing keeps the
|
|
1401
|
-
// screen. A selected-only green never answers here: its identity names its selection.
|
|
1402
|
-
let promotion;
|
|
1403
|
-
if (selected) {
|
|
1404
|
-
const hit = verdictStore.get(await fullTestIdentity());
|
|
1405
|
-
const costRatio = screenCostRatio(ctx.baseline, selected);
|
|
1406
|
-
if (hit?.pass === true && !isInfraResult(hit) && await certifiesFullManifest(hit))
|
|
1407
|
-
promotion = { reason: "full-green-cache" };
|
|
1408
|
-
else if (costRatio !== undefined && costRatio >= SCREEN_PROMOTION_RATIO)
|
|
1409
|
-
promotion = { reason: "screen-cost-promoted", costRatio };
|
|
1410
|
-
if (promotion)
|
|
1411
|
-
selected = undefined;
|
|
1412
1602
|
}
|
|
1413
|
-
|
|
1603
|
+
// The recorded reason is the one that actually decided scope: with no decision above, it is the
|
|
1604
|
+
// eligibility check that failed. Selection off: the caller's reason is what turned it off (an explicit
|
|
1605
|
+
// full recheck, a distrusted history) — unless it names selection itself, which cannot have disabled it.
|
|
1606
|
+
if (ctx.selectionReason)
|
|
1414
1607
|
selectionDecision = {
|
|
1415
|
-
scope:
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1608
|
+
scope: diagnostic ? "selected" : "full",
|
|
1609
|
+
reason: decision?.reason ?? (ctx.selectTests !== true
|
|
1610
|
+
? (ctx.selectionReason === "known-failing-files" ? "selection-disabled" : ctx.selectionReason)
|
|
1611
|
+
: !required.length ? "no-required-repair-tests" : "test-gate-disabled"),
|
|
1612
|
+
requiredFiles: [...required],
|
|
1613
|
+
...(decision?.costRatio !== undefined ? { costRatio: decision.costRatio } : {}),
|
|
1614
|
+
...(decision?.estimatedMs !== undefined ? { estimatedMs: decision.estimatedMs } : {}),
|
|
1419
1615
|
};
|
|
1420
|
-
|
|
1421
|
-
if (
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1616
|
+
let diagnosed = false;
|
|
1617
|
+
if (diagnostic) {
|
|
1618
|
+
await runBattery({ ...ctx.commands, test: testCommandForFiles(ctx.commands.test, diagnostic) }, diagnostic, ["test"]);
|
|
1619
|
+
if (failed())
|
|
1620
|
+
return done();
|
|
1621
|
+
diagnosed = results.some((r) => r.gate === "test");
|
|
1622
|
+
// An inconclusive (infrastructure) diagnostic answered nothing: the full job's row says so.
|
|
1623
|
+
if (!diagnosed && selectionDecision)
|
|
1624
|
+
selectionDecision = { ...selectionDecision, scope: "full", reason: "diagnostic-inconclusive" };
|
|
1625
|
+
}
|
|
1626
|
+
if (semantic) {
|
|
1627
|
+
// A cancelled round starts no test; done() records its proof OWED (D-974), never a verdict.
|
|
1628
|
+
if (await runSemantics())
|
|
1629
|
+
return done();
|
|
1630
|
+
// Subject freshness: the semantic gates ran oracles and vendor CLIs in this worktree. Their dirt is
|
|
1631
|
+
// not the committed subject, so no full job measures it and no cached green row answers for it.
|
|
1632
|
+
const dirt = testRuns ? await dirtyWorktree() : undefined;
|
|
1435
1633
|
if (dirt) {
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1438
|
-
|
|
1634
|
+
const at = results.findIndex((r) => r.gate === "test");
|
|
1635
|
+
if (at >= 0)
|
|
1636
|
+
results.splice(at, 1);
|
|
1637
|
+
await emitStart("test");
|
|
1638
|
+
await record(await dirtyRefusal("test", dirt));
|
|
1439
1639
|
return done();
|
|
1440
1640
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
loadSamples.delete("test");
|
|
1452
|
-
}
|
|
1641
|
+
}
|
|
1642
|
+
if (!diagnosed) {
|
|
1643
|
+
await runBattery(ctx.commands, undefined, enabled("test") ? ["test"] : []);
|
|
1644
|
+
if (failed())
|
|
1645
|
+
return done();
|
|
1646
|
+
}
|
|
1647
|
+
if (rerunFull) {
|
|
1648
|
+
// The superseded in-battery row keeps its own interval; the replacement measures from zero.
|
|
1649
|
+
spans.delete("test");
|
|
1650
|
+
loadSamples.delete("test");
|
|
1453
1651
|
}
|
|
1454
1652
|
// The merge-candidate round: every other gate is green, so THIS round is the one that can merge —
|
|
1455
1653
|
// the full suite runs on the exact gated commit before the pipeline reports green. Nothing merges
|
|
1456
1654
|
// on a subset (spec: "nothing merges without a complete green suite"). Its verdict replaces the
|
|
1457
1655
|
// screen's entry in the returned record (one `test` entry), and `fullSuite` says which suite spoke
|
|
1458
|
-
// while `selectedTests` keeps what the screen ran. In the stream
|
|
1459
|
-
//
|
|
1460
|
-
|
|
1656
|
+
// while `selectedTests` keeps what the screen ran. In the stream the diagnostic keeps its own
|
|
1657
|
+
// earlier event and this is the second.
|
|
1658
|
+
// OBS-635 (answersNow): a diagnosed round's full job is its first, so, like the in-battery job, a stale
|
|
1659
|
+
// green buys one fresh job (published unverdicted); a stale last job fails closed. Neither is cached.
|
|
1660
|
+
let spare = diagnosed ? 1 : 0;
|
|
1661
|
+
while (diagnosed || rerunFull) {
|
|
1461
1662
|
await emitStart("test");
|
|
1462
1663
|
// This is the last shell command a round can run — the judge's named-test oracle (acceptance.ts)
|
|
1463
1664
|
// may have run one before it, and every gate between the battery and here reads commits only, so
|
|
@@ -1471,12 +1672,17 @@ export async function runGates(task, ctx) {
|
|
|
1471
1672
|
identity = await fullTestIdentity();
|
|
1472
1673
|
const hit = verdictStore.get(identity);
|
|
1473
1674
|
if (hit)
|
|
1474
|
-
classifySignalOnlyTest(hit);
|
|
1675
|
+
classifySignalOnlyTest(hit, true);
|
|
1475
1676
|
if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")
|
|
1476
|
-
&& !(await discardCachedRed("test", hit))
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1677
|
+
&& !(await discardCachedRed("test", hit))) {
|
|
1678
|
+
// RE-PROOF, as in the battery: listing, then cleanliness and identity; stale, a fresh job measures now.
|
|
1679
|
+
if (!hit.pass || await answersNow(identity, hit, false)) {
|
|
1680
|
+
full = formatReusedRow(hit, identity);
|
|
1681
|
+
cached = true;
|
|
1682
|
+
await noteReuse("test", full, identity);
|
|
1683
|
+
}
|
|
1684
|
+
else
|
|
1685
|
+
identity = await fullTestIdentity();
|
|
1480
1686
|
}
|
|
1481
1687
|
}
|
|
1482
1688
|
if (!full) {
|
|
@@ -1489,16 +1695,25 @@ export async function runGates(task, ctx) {
|
|
|
1489
1695
|
const dirt = (!cached && full.pass) ? await dirtyWorktree() : undefined;
|
|
1490
1696
|
if (full)
|
|
1491
1697
|
classifySignalOnlyTest(full);
|
|
1492
|
-
|
|
1698
|
+
const stale = !cached && !dirt && full.pass && !(await answersNow(identity, full, false, true));
|
|
1699
|
+
if (!cached && identity && full && !dirt && !stale && !isInfraResult(full)) {
|
|
1493
1700
|
verdictStore.set(identity, { ...full, meta: { ...full.meta, source: "gate", runDir: ctx.artifactDir } });
|
|
1494
1701
|
}
|
|
1702
|
+
const spoke = stale ? staleProof(full, spare > 0) : full;
|
|
1495
1703
|
const merged = withTelemetry(dirt
|
|
1496
1704
|
? { ...await dirtyRefusal("test", dirt, ctx.commands.test), evidenceReceipt: full.evidenceReceipt, evidenceReceipts: full.evidenceReceipts }
|
|
1497
|
-
: { ...
|
|
1498
|
-
merged.evidenceReceipts = [...(
|
|
1705
|
+
: { ...spoke, meta: { ...spoke.meta, fullSuite: true, selectedTests: diagnosed ? diagnostic : undefined } });
|
|
1706
|
+
merged.evidenceReceipts = [...(full?.evidenceReceipts ?? [])];
|
|
1707
|
+
if (stale && spare-- > 0) {
|
|
1708
|
+
await ctx.onGate?.({ phase: "end", gate: "test", result: merged });
|
|
1709
|
+
// The superseded job keeps its own interval; the replacement measures from zero.
|
|
1710
|
+
spans.delete("test");
|
|
1711
|
+
loadSamples.delete("test");
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1499
1714
|
results[results.findIndex((r) => r.gate === "test")] = merged;
|
|
1500
|
-
heldTest = undefined;
|
|
1501
1715
|
await ctx.onGate?.({ phase: "end", gate: "test", result: merged });
|
|
1716
|
+
break;
|
|
1502
1717
|
}
|
|
1503
1718
|
return done();
|
|
1504
1719
|
}
|