tickmarkr 2.6.5 → 2.6.7
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/README.md +7 -3
- package/dist/adapters/claude-code.js +8 -2
- package/dist/adapters/codex-commit-check.d.ts +16 -4
- package/dist/adapters/codex-commit-check.js +150 -26
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +20 -8
- package/dist/adapters/types.d.ts +4 -1
- package/dist/cli/commands/beat.d.ts +6 -1
- package/dist/cli/commands/beat.js +123 -76
- package/dist/cli/commands/doctor.js +3 -1
- package/dist/cli/commands/fleet.js +368 -85
- package/dist/cli/commands/init.js +23 -9
- package/dist/cli/commands/plan.js +12 -1
- package/dist/cli/commands/verify.js +37 -9
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +10 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/config/fleet-overlay.d.ts +14 -0
- package/dist/config/fleet-overlay.js +154 -25
- package/dist/drivers/orca.d.ts +17 -3
- package/dist/drivers/orca.js +64 -20
- 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/review.js +15 -0
- package/dist/gates/run-gates.d.ts +20 -7
- package/dist/gates/run-gates.js +345 -210
- package/dist/gates/test-manifest.d.ts +11 -0
- package/dist/gates/test-manifest.js +151 -111
- package/dist/gates/timeout-shaped.d.ts +3 -0
- package/dist/gates/timeout-shaped.js +10 -0
- package/dist/run/beat-lifecycle.d.ts +154 -0
- package/dist/run/beat-lifecycle.js +825 -0
- package/dist/run/daemon.js +202 -126
- package/dist/run/git-trust.d.ts +27 -0
- package/dist/run/git-trust.js +178 -0
- package/dist/run/git.js +34 -14
- 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 +2 -2
- 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 +51 -0
- package/dist/run/verification-job.js +137 -0
- package/dist/tui/ink/fleet-app.d.ts +35 -5
- package/dist/tui/ink/fleet-app.js +244 -124
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +19 -10
- package/skills/tickmarkr-loop/SKILL.md +19 -10
- package/skills/tickmarkr-overseer/SKILL.md +25 -16
- package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
- package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
package/dist/gates/run-gates.js
CHANGED
|
@@ -7,7 +7,7 @@ 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
12
|
import { captureLlmOutput } from "./llm.js";
|
|
13
13
|
import { disallowedBy, observedSeat } from "../route/preference.js";
|
|
@@ -15,9 +15,10 @@ import { marginalCostRank } from "../route/router.js";
|
|
|
15
15
|
import { carriedAuthorVendors, gateReviewerFloor, pickReviewer, reviewGate } from "./review.js";
|
|
16
16
|
import { scopeGate } from "./scope.js";
|
|
17
17
|
import { discoverTestManifest, evaluateManifestedTest, isVitestTestCommand, VITEST_CACHE_ENV, worktreeVitestCache } from "./test-manifest.js";
|
|
18
|
+
import { RUNNER_INFRA_DIAGNOSTIC_RE, timeoutShaped } from "./timeout-shaped.js";
|
|
18
19
|
import { executionSignal } from "../run/execution-budget.js";
|
|
19
20
|
import { failureDisposition } from "../run/recovery.js";
|
|
20
|
-
import { dependencyLinkRefusal, FORK_CAP_ENV, preserveWorktree, producerFields, ROUTING_ENV_SEAMS, shGit, resolvedCapacity, SUITE_PARENT_ENV, verificationProtocol } from "../run/git.js";
|
|
21
|
+
import { dependencyLinkRefusal, FORK_CAP_ENV, preserveWorktree, producerFields, readCapacity, ROUTING_ENV_SEAMS, shGit, resolvedCapacity, sameCapacity, SUITE_PARENT_ENV, verificationProtocol } from "../run/git.js";
|
|
21
22
|
import { withJudgeInvocationEvidence } from "../run/journal.js";
|
|
22
23
|
import { computeVerificationIdentity, verificationIdentityKey, formatReusedRow, getVerdictStore, isInfraResult, resolveStateDir, reusedIdentity, } from "./cache.js";
|
|
23
24
|
const productionLoadProvider = () => loadavg()[0] ?? 0;
|
|
@@ -121,6 +122,9 @@ const TEST_FILE_RE = /(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
|
121
122
|
// relative specifiers only — `from "./x.js"`, `import("./x.js")`, `require("./x.js")`
|
|
122
123
|
const IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)["'](\.[^"']*)["']/g;
|
|
123
124
|
const SELECTION_FILE_CAP = 3000;
|
|
125
|
+
// v2.6.7 T1: the paths the import scan can analyze — the cap bounds THESE, never every tracked path, so
|
|
126
|
+
// planning files, fixtures and docs cannot push an attributable source/test change over it.
|
|
127
|
+
const ANALYZABLE_RE = /\.[cm]?[jt]sx?$/;
|
|
124
128
|
/**
|
|
125
129
|
* T4: the tests covering this round's diff, or undefined when the diff cannot be attributed with
|
|
126
130
|
* certainty — a rename or delete (the old path's coverage is gone), or a changed file no test
|
|
@@ -134,32 +138,40 @@ const SELECTION_FILE_CAP = 3000;
|
|
|
134
138
|
* (tsconfig paths, package exports) if selection ever misses often enough to be worth a round.
|
|
135
139
|
*/
|
|
136
140
|
async function coveringTests(worktree, baseRef) {
|
|
137
|
-
const
|
|
141
|
+
const unsupported = { reason: "unsupported-attribution" };
|
|
142
|
+
// -z: NUL-delimited and never C-quoted, so a non-ASCII path keeps its real extension (v2.6.7 T1 review)
|
|
143
|
+
const diff = await shGit(`git diff -z --name-status ${shq(baseRef)} HEAD`, worktree);
|
|
138
144
|
if (diff.code !== 0)
|
|
139
|
-
return
|
|
145
|
+
return unsupported;
|
|
140
146
|
const changed = [];
|
|
141
|
-
|
|
142
|
-
|
|
147
|
+
const fields = diff.stdout.split("\0");
|
|
148
|
+
for (let i = 0; i < fields.length; i++) {
|
|
149
|
+
const status = fields[i];
|
|
150
|
+
if (!status)
|
|
143
151
|
continue;
|
|
144
|
-
const parts = line.split("\t");
|
|
145
|
-
const status = parts[0] ?? "";
|
|
146
152
|
// R (rename) and D (delete): whatever used to cover the old path is unattributable now — full suite.
|
|
147
|
-
if (
|
|
148
|
-
return
|
|
149
|
-
|
|
153
|
+
if (status[0] === "R" || status[0] === "D")
|
|
154
|
+
return unsupported;
|
|
155
|
+
if (status[0] === "C")
|
|
156
|
+
i++; // a copy carries its source path first; attribute the destination
|
|
157
|
+
const path = fields[++i];
|
|
158
|
+
if (!path)
|
|
159
|
+
return unsupported;
|
|
160
|
+
changed.push(path);
|
|
150
161
|
}
|
|
151
162
|
if (!changed.length)
|
|
152
|
-
return
|
|
153
|
-
const listed = await shGit("git ls-files", worktree);
|
|
163
|
+
return unsupported;
|
|
164
|
+
const listed = await shGit("git ls-files -z", worktree);
|
|
154
165
|
if (listed.code !== 0)
|
|
155
|
-
return
|
|
156
|
-
const tracked = listed.stdout.split("\
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
return unsupported;
|
|
167
|
+
const tracked = listed.stdout.split("\0").filter(Boolean);
|
|
168
|
+
// ponytail: a huge repo pays the full suite rather than a long scan — counted over analyzable paths only
|
|
169
|
+
if (tracked.filter((p) => ANALYZABLE_RE.test(p)).length > SELECTION_FILE_CAP)
|
|
170
|
+
return { reason: "analyzable-path-cap" };
|
|
159
171
|
const trackedSet = new Set(tracked);
|
|
160
172
|
const tests = tracked.filter((p) => TEST_FILE_RE.test(p));
|
|
161
173
|
if (!tests.length)
|
|
162
|
-
return
|
|
174
|
+
return { reason: "no-covering-tests" };
|
|
163
175
|
const resolveSpec = (from, spec) => {
|
|
164
176
|
const base = posix.join(posix.dirname(from), spec);
|
|
165
177
|
// ESM-TS writes ".js" for a ".ts" source; a directory specifier means its index.
|
|
@@ -198,12 +210,14 @@ async function coveringTests(worktree, baseRef) {
|
|
|
198
210
|
continue;
|
|
199
211
|
}
|
|
200
212
|
const covering = tests.filter((t) => reachOf(t).has(file));
|
|
201
|
-
|
|
202
|
-
|
|
213
|
+
// A changed source no test reaches is unattributable: a diagnostic over the rest would screen around
|
|
214
|
+
// it, so the whole selection is unsupported. A non-source path (docs, planning) carries no imports.
|
|
215
|
+
if (!covering.length && ANALYZABLE_RE.test(file))
|
|
216
|
+
return unsupported;
|
|
203
217
|
for (const t of covering)
|
|
204
218
|
selected.add(t);
|
|
205
219
|
}
|
|
206
|
-
return selected.size ? [...selected].sort() :
|
|
220
|
+
return selected.size ? { files: [...selected].sort() } : { reason: "no-covering-tests" };
|
|
207
221
|
}
|
|
208
222
|
/**
|
|
209
223
|
* The configured test command narrowed to these files. Mirrors testFiltered's `--` rule (acceptance.ts:104):
|
|
@@ -215,23 +229,56 @@ export function testCommandForFiles(testCmd, files) {
|
|
|
215
229
|
const fwd = wrapped && !/\s--\s/.test(testCmd) ? " --" : "";
|
|
216
230
|
return `${testCmd}${fwd} ${files.map(shq).join(" ")}`;
|
|
217
231
|
}
|
|
218
|
-
/**
|
|
219
|
-
export const
|
|
232
|
+
/** v2.6.7 T1 (closed order table): an attributed test-red diagnostic is admitted only when BOTH hold. */
|
|
233
|
+
export const DIAGNOSTIC_MAX_RATIO = 0.15;
|
|
234
|
+
export const DIAGNOSTIC_MAX_ESTIMATE_MS = 60_000;
|
|
220
235
|
/**
|
|
221
|
-
* The
|
|
222
|
-
*
|
|
223
|
-
*
|
|
236
|
+
* The diagnostic's admission evidence, from the per-file durations the harness measured at baseline
|
|
237
|
+
* capture — never a worker's timing. Unknown (any selected file unmeasured, any measurement that is not a
|
|
238
|
+
* finite number > 0 ms, an infra baseline, or no positive total) skips: an expensive or unknown screen must
|
|
239
|
+
* never become a full suite before semantics.
|
|
240
|
+
* So does timing recorded under a malformed or different capacity (git.ts sameCapacity): it divided the
|
|
241
|
+
* machine by another number, so it is not comparable evidence about this one.
|
|
242
|
+
* `estimatedMs` is the serial sum of those files: admission evidence, not a kill ceiling and not a
|
|
243
|
+
* claim about the parallel-plus-serial wall time the runner will actually take.
|
|
224
244
|
*/
|
|
225
|
-
export function
|
|
245
|
+
export function diagnosticAdmission(baseline, selected) {
|
|
226
246
|
const entry = baseline.commands.test;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
247
|
+
// The CURRENT capacity is validated on its own first: an unstamped (absent) baseline capacity is comparable
|
|
248
|
+
// to anything, so it must never wave through a zero, negative, NaN, infinite or fractional current one.
|
|
249
|
+
const current = resolvedCapacity();
|
|
250
|
+
if (readCapacity(current).state !== "present" || !sameCapacity(entry?.capacity, current))
|
|
251
|
+
return { admitted: false, reason: "diagnostic-capacity-mismatch" };
|
|
252
|
+
const raw = entry?.infra ? [] : classifiedFileDurations(entry) ?? [];
|
|
253
|
+
const unknown = { admitted: false, reason: "diagnostic-unknown-cost" };
|
|
254
|
+
// Every input is checked BEFORE any arithmetic touches it: an entry that is not an object whose duration
|
|
255
|
+
// is a finite number > 0 (0 ms, negative, NaN, non-numeric, an object with no primitive value), selected
|
|
256
|
+
// or not, is unknown cost — never a free screen and never a throw.
|
|
257
|
+
const measured = (f) => {
|
|
258
|
+
const d = typeof f === "object" && f !== null ? f.durationMs : undefined;
|
|
259
|
+
return typeof d === "number" && Number.isFinite(d) && d > 0;
|
|
260
|
+
};
|
|
261
|
+
if (!Array.isArray(raw) || !raw.every(measured) || !selected.length)
|
|
262
|
+
return unknown;
|
|
263
|
+
const files = raw;
|
|
264
|
+
// Baseline capture keeps one entry per (project, file): a file's cost is the sum of its measurements.
|
|
265
|
+
const cost = new Map();
|
|
266
|
+
for (const f of files)
|
|
267
|
+
cost.set(f.file, (cost.get(f.file) ?? 0) + f.durationMs);
|
|
231
268
|
const total = files.reduce((sum, f) => sum + f.durationMs, 0);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
269
|
+
// Finite inputs can still overflow: a non-finite total or estimate is no comparable evidence either.
|
|
270
|
+
if (!(total > 0) || !Number.isFinite(total) || selected.some((file) => !((cost.get(file) ?? 0) > 0)))
|
|
271
|
+
return unknown;
|
|
272
|
+
// A selected file that was RED in the baseline ended early there: its cost is unknown, never cheap.
|
|
273
|
+
if (files.some((f) => f.failed === true && selected.includes(f.file)))
|
|
274
|
+
return unknown;
|
|
275
|
+
const estimatedMs = selected.reduce((sum, file) => sum + cost.get(file), 0);
|
|
276
|
+
if (!Number.isFinite(estimatedMs))
|
|
277
|
+
return unknown;
|
|
278
|
+
const costRatio = estimatedMs / total;
|
|
279
|
+
const reason = costRatio > DIAGNOSTIC_MAX_RATIO ? "diagnostic-cost-ratio"
|
|
280
|
+
: estimatedMs > DIAGNOSTIC_MAX_ESTIMATE_MS ? "diagnostic-cost-estimate" : "diagnostic-admitted";
|
|
281
|
+
return { admitted: reason === "diagnostic-admitted", reason, costRatio, estimatedMs };
|
|
235
282
|
}
|
|
236
283
|
/** OBS-635: the full manifest the runner lists NOW, under the environment evaluateManifestedTest's own
|
|
237
284
|
* discovery receives (test-manifest.ts manifestEnvironment), so it compares with the one a verdict
|
|
@@ -253,31 +300,44 @@ async function listFullManifest(cmd, worktree) {
|
|
|
253
300
|
rmSync(dir, { recursive: true, force: true });
|
|
254
301
|
}
|
|
255
302
|
}
|
|
256
|
-
/** The manifest-report path for a detected vitest test command — never the stdout-count/file-count path.
|
|
303
|
+
/** The manifest-report path for a detected vitest test command — never the stdout-count/file-count path.
|
|
304
|
+
* ONE outer latch (`retried`) buys at most one more measurement of the exact same selection: the bounded
|
|
305
|
+
* infrastructure retry, or — v2.6.6 C (D-873), default-on — a behavioral timeout red carrying runner
|
|
306
|
+
* infra diagnostics, read by the daemon's own predicate (timeout-shaped.ts). Either waits for calm and
|
|
307
|
+
* spends the bounded allowance when one is configured; neither follows a receipt-backed recovery
|
|
308
|
+
* (retryable:false) or a diagnostic re-observation, and the second sample never stacks the stranded
|
|
309
|
+
* single-fork recovery. The second sample is authoritative; the first keeps its receipts beside it. */
|
|
257
310
|
async function runVitestManifestGate(worktree, cmd, baseline, selected, artifactDir, retry = {}, retried = false) {
|
|
258
311
|
const entry = baseline.commands.test;
|
|
259
312
|
const outcome = await evaluateManifestedTest(cmd, worktree, {
|
|
260
|
-
baselineDurations: entry
|
|
313
|
+
baselineDurations: classifiedFileDurations(entry),
|
|
261
314
|
longestFile: entry?.longestFile,
|
|
262
315
|
overallCeilingMs: effectiveCeilingMs(entry),
|
|
263
316
|
artifactDir,
|
|
264
317
|
evidence: retry.evidence,
|
|
265
318
|
retryBaseCommand: retry.retryBaseCommand, // OBS-1166: the un-narrowed command for a selected screen's stranded retry
|
|
319
|
+
allowStrandedRecovery: !retried,
|
|
266
320
|
});
|
|
267
321
|
const reportPath = outcome.reportPath;
|
|
268
322
|
const evidence = { evidenceReceipt: outcome.evidenceReceipt, evidenceReceipts: outcome.evidenceReceipts };
|
|
269
|
-
|
|
270
|
-
|
|
323
|
+
const disposition = failureDisposition(outcome);
|
|
324
|
+
const shaped = disposition === "behavioral" && timeoutShaped(outcome.details) && RUNNER_INFRA_DIAGNOSTIC_RE.test(outcome.details);
|
|
325
|
+
if (!retried && outcome.meta?.retryable !== false && retry.retryBaseCommand !== REOBSERVATION_RETRY_BASE
|
|
326
|
+
&& (shaped || (retry.authorizeRetry && disposition === "infrastructure"))) {
|
|
271
327
|
const waitedMs = await waitForCalmWindow(executionSignal());
|
|
272
328
|
if (!calmWindowReady())
|
|
273
329
|
return { ...evidence, gate: "test", pass: false, details: outcome.details,
|
|
274
330
|
meta: { ...outcome.meta, reportPath, recoveryBlocked: "calm window unavailable within the existing wait ceiling" } };
|
|
275
|
-
if (!retry.authorizeRetry("infra")) {
|
|
331
|
+
if (retry.authorizeRetry && !retry.authorizeRetry("infra")) {
|
|
276
332
|
return { ...evidence, gate: "test", pass: false, details: outcome.details,
|
|
277
333
|
meta: { ...outcome.meta, reportPath, recoveryBlocked: "infrastructure retry allowance exhausted or subject unavailable" } };
|
|
278
334
|
}
|
|
279
335
|
const result = await runVitestManifestGate(worktree, cmd, baseline, selected, artifactDir, retry, true);
|
|
280
|
-
|
|
336
|
+
const rerun = { count: 1, waitedMs, firstReportPath: reportPath };
|
|
337
|
+
return { ...result, evidenceReceipts: [...(outcome.evidenceReceipts ?? []), ...(result.evidenceReceipts ?? [])],
|
|
338
|
+
meta: { ...result.meta, ...(shaped
|
|
339
|
+
? { remeasured: { ...rerun, first: { pass: outcome.pass, details: outcome.details, meta: outcome.meta } } }
|
|
340
|
+
: { runnerInfraRerun: rerun }) } };
|
|
281
341
|
}
|
|
282
342
|
return {
|
|
283
343
|
...evidence,
|
|
@@ -290,7 +350,8 @@ async function runVitestManifestGate(worktree, cmd, baseline, selected, artifact
|
|
|
290
350
|
/** A retry base no runner invocation parses. evaluateManifestedTest builds its stranded single-fork
|
|
291
351
|
* retry from the base it is handed, and one it cannot parse throws before any spawn — so this base
|
|
292
352
|
* disables that inner recovery: a worker-RPC-stranded re-observation comes back infra (the caller
|
|
293
|
-
* parks it as ambiguous) instead of launching a second execution.
|
|
353
|
+
* parks it as ambiguous) instead of launching a second execution. runVitestManifestGate reads it as
|
|
354
|
+
* the mark of a diagnostic subset, which never buys the outer timeout-shaped remeasurement either. */
|
|
294
355
|
export const REOBSERVATION_RETRY_BASE = "tickmarkr-reobservation-refuses-stranded-retry";
|
|
295
356
|
/** OBS-1106 residual: ONE isolated re-observation of a timeout-shaped red's attributed failing files on
|
|
296
357
|
* the same checkout, narrowed exactly as a screen is. Never cached and never a verdict: the caller
|
|
@@ -310,14 +371,26 @@ const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+co
|
|
|
310
371
|
const FAILURE_IDENTITY_RE = /\b(?:AssertionError|FAIL\s+\S|Tests?\s+\d+\s+failed|expected\s+.+\s+to\s+)\b/i;
|
|
311
372
|
/** D1: apply the daemon's signal-only rider before either battery cache read or write. Its onGate
|
|
312
373
|
* classification happens after persistence, too late to keep a scripted runner's non-verdict out.
|
|
313
|
-
* Keep named failures as work verdicts and preserve details for failure-policy fingerprinting.
|
|
314
|
-
|
|
315
|
-
|
|
374
|
+
* Keep named failures as work verdicts and preserve details for failure-policy fingerprinting.
|
|
375
|
+
* A baseline-forgiven "green" whose runner was signal-killed is the same non-verdict: a killed job
|
|
376
|
+
* completed no suite, so forgiveness never publishes or caches it green. The authoritative termination
|
|
377
|
+
* receipt decides, never the verdict text: a signal-terminated job is killed whatever its details say, and
|
|
378
|
+
* a green stands only on a receipt showing a normal exit — a receiptless CACHED one (an older or planted
|
|
379
|
+
* entry) is unavailable proof, so a fresh job measures instead. A fresh row is this invocation's own: every
|
|
380
|
+
* production runner attaches its receipt, and the manifest path's verdict already requires a clean exit
|
|
381
|
+
* (test-manifest.ts verifyManifestReport). A no-command skip ran nothing and claims none. */
|
|
382
|
+
function classifySignalOnlyTest(g, cached = false) {
|
|
383
|
+
const termination = g.evidenceReceipt?.termination.kind;
|
|
384
|
+
const exitCode = g.evidenceReceipt?.termination.exitCode;
|
|
385
|
+
const killed = termination === "signal" || exitCode === 137 || exitCode === 143 || (!g.pass && SIGNAL_EXIT_RE.test(g.details));
|
|
386
|
+
const unproven = g.pass && g.meta?.skipped !== true && termination !== "exit" && (cached || termination !== undefined);
|
|
387
|
+
if (g.gate !== "test" || g.meta?.infra === true || !(killed || unproven))
|
|
316
388
|
return;
|
|
317
389
|
const named = Array.isArray(g.meta?.failingTests) && g.meta.failingTests.length > 0;
|
|
318
|
-
if (named || FAILURE_IDENTITY_RE.test(g.details))
|
|
390
|
+
if (!g.pass && (named || FAILURE_IDENTITY_RE.test(g.details)))
|
|
319
391
|
return;
|
|
320
|
-
g.
|
|
392
|
+
g.pass = false;
|
|
393
|
+
g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false, kind: killed || termination ? "signal-exit" : "no-exit-receipt" };
|
|
321
394
|
}
|
|
322
395
|
/** OBS-1151: a criterion's comparable subject — its canonical text, the task's declared bounds and the
|
|
323
396
|
* operator context. The cited files' blobs are compared separately, over the union of both citations. */
|
|
@@ -406,9 +479,8 @@ export async function runGates(task, ctx) {
|
|
|
406
479
|
await receiptNotes;
|
|
407
480
|
};
|
|
408
481
|
let selectionDecision;
|
|
409
|
-
// OBS-635:
|
|
410
|
-
let
|
|
411
|
-
let batteryFullIdentity;
|
|
482
|
+
// OBS-635: a stale in-battery full green buys one fresh merge-candidate job (see answersNow).
|
|
483
|
+
let rerunFull = false;
|
|
412
484
|
let commits = [];
|
|
413
485
|
// Check before cache identity, npm policy probes, or any gate command.
|
|
414
486
|
const dependencyRefusal = dependencyLinkRefusal(ctx.worktree);
|
|
@@ -467,28 +539,20 @@ export async function runGates(task, ctx) {
|
|
|
467
539
|
};
|
|
468
540
|
const shapeGates = ctx.cfg.gates.byShape?.[task.shape];
|
|
469
541
|
const enabled = (g) => task.gates.includes(g) && (g !== "acceptance" && g !== "review" || shapeGates?.[g] !== false);
|
|
470
|
-
// T4 (OBS-265): a GREEN selected-test run is a screen, not the round's verdict — the merge-candidate
|
|
471
|
-
// round re-runs the full suite on the same commit and THAT is what the round reports. With no
|
|
472
|
-
// semantic gate to act on it, the screen is held so its full suite speaks for it in one row.
|
|
473
|
-
// (A RED screen IS the verdict: the round ends there, so it is recorded immediately.)
|
|
474
|
-
let heldTest;
|
|
475
542
|
const semantic = enabled("acceptance") || enabled("review");
|
|
476
|
-
// v2.6.
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
// it carries only its own receipts and interval, so it neither erases nor re-counts the screen.
|
|
490
|
-
// Semantic-first, nothing acts on the screen but its full suite: it is held like a gate-less round's.
|
|
491
|
-
const publishScreen = semantic && !semanticFirst;
|
|
543
|
+
// v2.6.7 T1 (closed order table): after the cheap build/lint/evidence/scope checks, acceptance and
|
|
544
|
+
// review run BEFORE any test-gate payload for every candidate — fresh, semantic repair, or a test-red
|
|
545
|
+
// repair without an admitted diagnostic — so a decisive semantic red costs zero test starts. Only a
|
|
546
|
+
// concrete attributed behavioral test-red repair may buy one cheap diagnostic first (see below).
|
|
547
|
+
// A review that returned NO verdict is not a red that ends the round before its test proof: the
|
|
548
|
+
// daemon re-asks only the review (runReviewRecovery) and merges on the rows beside it, so the round
|
|
549
|
+
// still buys its full job. The row stays red; the round is unsatisfied until that recovery answers.
|
|
550
|
+
// A gate that DECLINED (meta.skipped, R3) is no red either: a declined review no longer ends the round
|
|
551
|
+
// just because it now precedes the test gate.
|
|
552
|
+
const failed = () => results.some((r) => !r.pass && r.meta?.skipped !== true && !(r.gate === "review" && r.meta?.noVerdict === true));
|
|
553
|
+
// D-974: set once the cheap checks pass. From then on an enabled gate the round leaves unrun is owed
|
|
554
|
+
// proof (done()) unless a decisive red already decided the round.
|
|
555
|
+
let proofOwed = false;
|
|
492
556
|
// v2.0 T2 (OBS-554): this round's per-gate measurement. Every interval a gate actually spends
|
|
493
557
|
// executing is added HERE, at the call site that runs it, so a gate that runs twice (the test
|
|
494
558
|
// gate's screen and its full suite) sums to its own cost and never to the span between them.
|
|
@@ -566,14 +630,7 @@ export async function runGates(task, ctx) {
|
|
|
566
630
|
await ctx.onGate?.({ phase: "start", gate, index: indexOf(gate), total, ...(parentAt === undefined ? {} : { parentAt }) });
|
|
567
631
|
};
|
|
568
632
|
// The returned array stays in GATE_NAMES order however few gates a short-circuiting round reached.
|
|
569
|
-
// A round that ends before its merge-candidate stage flushes the held screen on the way out, so a
|
|
570
|
-
// green subset run is still journaled exactly once — as a selected run, which is what it was.
|
|
571
633
|
const done = async () => {
|
|
572
|
-
if (heldTest) {
|
|
573
|
-
const held = heldTest;
|
|
574
|
-
heldTest = undefined;
|
|
575
|
-
await ctx.onGate?.({ phase: "end", gate: "test", result: held }); // stamped when it was held
|
|
576
|
-
}
|
|
577
634
|
const sorted = [...results].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate));
|
|
578
635
|
// v1.87 T5: no round returns a MERGEABLE GREEN on a dirty tree. The battery is not the only gate
|
|
579
636
|
// that executes shell in this worktree — the acceptance gate runs command and named-test oracles
|
|
@@ -592,6 +649,29 @@ export async function runGates(task, ctx) {
|
|
|
592
649
|
await ctx.onGate?.({ phase: "end", gate: refusal.gate, result: refusal });
|
|
593
650
|
}
|
|
594
651
|
}
|
|
652
|
+
// D-974 (closed missing-proof table): owed rows land ONLY when the round holds no decisive red —
|
|
653
|
+
// every unsatisfied row is infra or no-verdict and could be recovered — so a review-only recovery
|
|
654
|
+
// can never merge over a gate the round never ran. After a decisive rejection the gates it left
|
|
655
|
+
// unrun owe nothing. A selected diagnostic green is not full proof, so it is owed too. Gates the
|
|
656
|
+
// task omits or its shape disables are outside `sequence` and create no debt.
|
|
657
|
+
const unsatisfied = results.filter((r) => !(r.pass || r.meta?.skipped === true) || r.meta?.infra === true);
|
|
658
|
+
if (proofOwed && unsatisfied.length && unsatisfied.every((r) => r.meta?.infra === true || r.meta?.noVerdict === true)) {
|
|
659
|
+
for (const gate of sequence) {
|
|
660
|
+
if (gate === "test" && ctx.commands.test === undefined)
|
|
661
|
+
continue;
|
|
662
|
+
const at = results.findIndex((r) => r.gate === gate);
|
|
663
|
+
const diagnosticOnly = gate === "test" && at >= 0 && results[at].pass
|
|
664
|
+
&& Array.isArray(results[at].meta?.selectedTests) && results[at].meta?.fullSuite !== true;
|
|
665
|
+
if (at >= 0 && !diagnosticOnly)
|
|
666
|
+
continue;
|
|
667
|
+
if (at >= 0)
|
|
668
|
+
results.splice(at, 1);
|
|
669
|
+
await record({ gate, pass: false, details: `${gate} not run — the round ended on a recoverable non-verdict before `
|
|
670
|
+
+ `${gate === "test" ? "its full verification job" : "this gate published a verdict"}; that proof is still owed on this subject`,
|
|
671
|
+
meta: { skipped: true, infra: true, classification: "infra", retryable: false, gateOwed: gate, ...(gate === "test" ? { testOwed: true } : {}) } });
|
|
672
|
+
}
|
|
673
|
+
return { results: [...results].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate)), commits };
|
|
674
|
+
}
|
|
595
675
|
return { results: sorted, commits };
|
|
596
676
|
};
|
|
597
677
|
const toolGates = ["build", "lint"].filter(enabled);
|
|
@@ -788,18 +868,48 @@ export async function runGates(task, ctx) {
|
|
|
788
868
|
capacity: resolvedCapacity(),
|
|
789
869
|
});
|
|
790
870
|
// OBS-635: a full green answers only for the manifest it certified. The tree identity cannot see an
|
|
791
|
-
// ignored generated test the runner would collect, so every full-green
|
|
792
|
-
// runner's listing and requires the verdict's to equal it; a runner without a listing is bound by
|
|
793
|
-
// its identity alone.
|
|
794
|
-
|
|
795
|
-
|
|
871
|
+
// ignored generated test the runner would collect, so every full-green check rediscovers the
|
|
872
|
+
// runner's listing NOW and requires the verdict's to equal it; a runner without a listing is bound by
|
|
873
|
+
// its identity alone. So is a FRESH green that certified no manifest beside a runner that lists none
|
|
874
|
+
// now: neither side holds a manifest that could have moved. A cached one certifies nothing (no reuse).
|
|
875
|
+
// OOB M1: the listing is itself a command — one that creates an ignored collectable test AFTER taking
|
|
876
|
+
// its own snapshot returns the manifest it just made stale, and neither cleanliness nor the identity
|
|
877
|
+
// sees an ignored file. So an agreeing listing certifies only when STABLE: one more listing, taken
|
|
878
|
+
// after the first one's side effects, must return the same set (one extra listing per proof, and only
|
|
879
|
+
// on the agreeing path). An unstable listing is stale proof like any other moved manifest.
|
|
880
|
+
const certifiesFullManifest = async (verdict, fresh = false) => {
|
|
796
881
|
if (!verdict.pass || !isVitestTestCommand(ctx.commands.test, ctx.worktree))
|
|
797
882
|
return true;
|
|
798
883
|
const certified = verdict.meta?.manifest;
|
|
799
|
-
const current = await
|
|
800
|
-
|
|
801
|
-
|
|
884
|
+
const current = await listFullManifest(ctx.commands.test, ctx.worktree);
|
|
885
|
+
if (fresh && certified === undefined && current === undefined)
|
|
886
|
+
return true;
|
|
887
|
+
if (!Array.isArray(certified) || current === undefined
|
|
888
|
+
|| certified.length !== current.length || ![...certified].sort().every((file, i) => file === current[i]))
|
|
889
|
+
return false;
|
|
890
|
+
const settled = await listFullManifest(ctx.commands.test, ctx.worktree);
|
|
891
|
+
return settled !== undefined && settled.length === current.length && settled.every((file, i) => file === current[i]);
|
|
802
892
|
};
|
|
893
|
+
// OBS-635: a full green — fresh or cached — answers only while the identity it measured and the complete
|
|
894
|
+
// manifest the runner lists NOW still hold — the job's own command may have written an ignored lockfile or
|
|
895
|
+
// a test the runner now collects. Checked before that green is cached, reused or published. `strict` (the in-battery job,
|
|
896
|
+
// which may still buy a fresh one) adds D-598: an unmeasurable lifecycle on either side is not comparable
|
|
897
|
+
// to anything (VerdictStore R41 refuses it), so two `unknown`s hashing equal is no unchanged identity.
|
|
898
|
+
const answersNow = async (before, verdict, strict, fresh = strict) => {
|
|
899
|
+
// The listing is a command too (it may write an ignored lockfile or a tracked byte), so it runs FIRST
|
|
900
|
+
// and cleanliness and identity are sampled after it — never before.
|
|
901
|
+
if (!(await certifiesFullManifest(verdict, fresh)) || await dirtyWorktree())
|
|
902
|
+
return false;
|
|
903
|
+
const now = await fullTestIdentity();
|
|
904
|
+
const measurable = (id) => !strict || id.envParts?.verification?.lifecycle !== "unknown";
|
|
905
|
+
return !!now && !!before && measurable(now) && measurable(before)
|
|
906
|
+
&& verificationIdentityKey(now) === verificationIdentityKey(before);
|
|
907
|
+
};
|
|
908
|
+
// A stale green is never green: an infra row naming why, unverdicted (skipped) while a fresh job supersedes it.
|
|
909
|
+
const staleProof = (r, superseded) => ({ ...r, pass: false,
|
|
910
|
+
details: `stale full proof: this job's own command moved the verification identity or the complete manifest the runner `
|
|
911
|
+
+ `lists now, so its green answers an earlier subject — ${superseded ? "one fresh full job measures this one" : "failing closed"}\n${r.details}`,
|
|
912
|
+
meta: { ...r.meta, infra: true, classification: "infra", retryable: false, staleProof: true, ...(superseded ? { skipped: true } : {}) } });
|
|
803
913
|
// shell tools vs the shared baseline
|
|
804
914
|
const retryOptions = (identity) => ctx.authorizeInfraRetry
|
|
805
915
|
? { authorizeRetry: (cause) => ctx.authorizeInfraRetry(identity ? verificationIdentityKey(identity) : "", cause === "infra" ? "infrastructure" : "host-starved") }
|
|
@@ -827,14 +937,21 @@ export async function runGates(task, ctx) {
|
|
|
827
937
|
});
|
|
828
938
|
const hit = verdictStore.get(identity);
|
|
829
939
|
if (hit)
|
|
830
|
-
classifySignalOnlyTest(hit); // Older entries predate classification at the write seam.
|
|
940
|
+
classifySignalOnlyTest(hit, true); // Older entries predate classification at the write seam.
|
|
831
941
|
if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")
|
|
832
|
-
&& !(await discardCachedRed(g, hit))
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
942
|
+
&& !(await discardCachedRed(g, hit))) {
|
|
943
|
+
// RE-PROOF: a cached FULL green is reused only after the current manifest listing, then cleanliness
|
|
944
|
+
// and the identity sampled after it, agree with it. Stale, it is never relabelled: the fresh job
|
|
945
|
+
// measures under the identity sampled now.
|
|
946
|
+
if (g !== "test" || selected !== undefined || !hit.pass || await answersNow(identity, hit, false)) {
|
|
947
|
+
r = formatReusedRow(hit, identity);
|
|
948
|
+
cached = true;
|
|
949
|
+
if (g === "build")
|
|
950
|
+
await noBuild("reused-result", "verdict reused; no fresh build ran in this invocation");
|
|
951
|
+
await noteReuse(g, r, identity);
|
|
952
|
+
}
|
|
953
|
+
else
|
|
954
|
+
identity = await fullTestIdentity();
|
|
838
955
|
}
|
|
839
956
|
}
|
|
840
957
|
if (!r) {
|
|
@@ -856,10 +973,6 @@ export async function runGates(task, ctx) {
|
|
|
856
973
|
// the screen's interval IS the test gate's first interval, so the split needs no second clock
|
|
857
974
|
if (g === "test" && selected)
|
|
858
975
|
selectedDurationMs = spans.get("test")?.durationMs ?? 0;
|
|
859
|
-
if (g === "test" && !selected) {
|
|
860
|
-
fullInBattery = true;
|
|
861
|
-
batteryFullIdentity = identity;
|
|
862
|
-
}
|
|
863
976
|
// The pre-battery check proves the tree clean ONCE; a command that exits 0 having rewritten a
|
|
864
977
|
// tracked file makes it dirty again, and every gate after it — including the next shell gate,
|
|
865
978
|
// which would then run against bytes HEAD does not hold — inherits that. So re-check after each
|
|
@@ -874,24 +987,31 @@ export async function runGates(task, ctx) {
|
|
|
874
987
|
}
|
|
875
988
|
if (r)
|
|
876
989
|
classifySignalOnlyTest(r);
|
|
990
|
+
// Every round's in-battery full job is its first — test-only verification included: stale, it is
|
|
991
|
+
// published unverdicted and buys one fresh merge-candidate job below, whose own stale green fails closed.
|
|
992
|
+
if (g === "test" && !selected && cmd !== undefined && !cached && r.pass && !(await answersNow(identity, r, true))) {
|
|
993
|
+
r = staleProof(r, true);
|
|
994
|
+
rerunFull = true;
|
|
995
|
+
}
|
|
877
996
|
if (!cached && identity && r && !isInfraResult(r)) {
|
|
878
997
|
verdictStore.set(identity, { ...r, meta: { ...r.meta, source: "gate", runDir: ctx.artifactDir } });
|
|
879
998
|
}
|
|
880
999
|
if (g === "test" && selected) {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1000
|
+
// v2.6.7 T1: the attributed diagnostic. A behavioral red IS the round's verdict and ends it
|
|
1001
|
+
// before semantics. An infrastructure red is no verdict either way: it is published UNVERDICTED
|
|
1002
|
+
// (skipped — the journal row keeps its infra evidence and receipts but no `pass`, so the review
|
|
1003
|
+
// round counter never reads it as a decisive test red) and never enters the round's results, so
|
|
1004
|
+
// it can neither end the round nor become green — semantics and the full job decide. A green is
|
|
1005
|
+
// published as its own selected row; the full job afterwards replaces it in the results.
|
|
1006
|
+
const inconclusive = !r.pass && isInfraResult(r);
|
|
1007
|
+
const screened = withTelemetry({ ...r, meta: { ...r.meta, selectedTests: selected, ...(inconclusive ? { skipped: true } : {}) } });
|
|
1008
|
+
if (!inconclusive)
|
|
1009
|
+
results.push(screened);
|
|
1010
|
+
await ctx.onGate?.({ phase: "end", gate: "test", result: screened });
|
|
1011
|
+
// The diagnostic's interval now lives on its own row; the full job measures from zero.
|
|
1012
|
+
spans.delete("test");
|
|
1013
|
+
loadSamples.delete("test");
|
|
1014
|
+
selectedDurationMs = undefined;
|
|
895
1015
|
}
|
|
896
1016
|
else {
|
|
897
1017
|
await record(r);
|
|
@@ -1309,9 +1429,11 @@ export async function runGates(task, ctx) {
|
|
|
1309
1429
|
// enough: an acceptance-first await withholds a completed review behind a slow/hung judge and a
|
|
1310
1430
|
// process death can lose that already-earned verdict. The returned result is still sorted into
|
|
1311
1431
|
// GATE_NAMES order by done(); the event stream truthfully records each independent completion.
|
|
1312
|
-
// OBS-1168(c): a sibling that throws, or
|
|
1432
|
+
// OBS-1168(c): a sibling that throws, or a seatless JUDGE (no seat could launch, so the round can
|
|
1313
1433
|
// only park infra), cancels the other — its seats are closed — and the round still AWAITS it, with
|
|
1314
1434
|
// or without an execution policy, so no verdict of a settled round publishes after its engagement.
|
|
1435
|
+
// v2.6.7 T1: a seatless REVIEW is recoverable (runReviewRecovery re-asks it alone), so it cancels
|
|
1436
|
+
// nothing — acceptance finishes independently and publishes its own result.
|
|
1315
1437
|
const seatless = (r) => r.meta?.cause === "seat-launch-failed" && r.meta?.infra === true;
|
|
1316
1438
|
let failure;
|
|
1317
1439
|
const fail = (reason) => {
|
|
@@ -1322,16 +1444,18 @@ export async function runGates(task, ctx) {
|
|
|
1322
1444
|
const judged = judging?.then(async (outcome) => {
|
|
1323
1445
|
if (cancelled)
|
|
1324
1446
|
return;
|
|
1325
|
-
|
|
1326
|
-
|
|
1447
|
+
// D-974: a seatless judge beside an already-settled no-verdict review has no sibling to cancel —
|
|
1448
|
+
// acceptance is MISSING proof: its row is owed and the round still buys its full job.
|
|
1449
|
+
const missing = seatless(outcome.result) && results.some((r) => r.gate === "review" && r.meta?.noVerdict === true);
|
|
1450
|
+
await withJudgeInvocationEvidence(outcome.invocations, () => record(missing
|
|
1451
|
+
? { ...outcome.result, meta: { ...outcome.result.meta, skipped: true, gateOwed: "acceptance" } } : outcome.result));
|
|
1452
|
+
if (seatless(outcome.result) && !missing)
|
|
1327
1453
|
cancelSemantic();
|
|
1328
1454
|
}).catch(fail);
|
|
1329
1455
|
const reviewed = reviewing?.then(async (outcome) => {
|
|
1330
1456
|
if (cancelled)
|
|
1331
1457
|
return;
|
|
1332
1458
|
await record(outcome);
|
|
1333
|
-
if (seatless(outcome))
|
|
1334
|
-
cancelSemantic();
|
|
1335
1459
|
}).catch(fail);
|
|
1336
1460
|
// ponytail: a headless seat has no slot to close; it is awaited to its own timeout, never abandoned.
|
|
1337
1461
|
await Promise.all([judged, reviewed]);
|
|
@@ -1341,108 +1465,105 @@ export async function runGates(task, ctx) {
|
|
|
1341
1465
|
executionSignal()?.throwIfAborted();
|
|
1342
1466
|
return cancelled || failed();
|
|
1343
1467
|
};
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
await emitStart("test");
|
|
1360
|
-
await record(await dirtyRefusal("test", dirt));
|
|
1361
|
-
return done();
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
// A non-final round may run only the tests covering its own diff; the merge-candidate round below
|
|
1365
|
-
// pays the full suite anyway, so a selection that misses costs a round and can never merge.
|
|
1366
|
-
let selected = ctx.selectTests && enabled("test") && ctx.commands.test
|
|
1367
|
-
? await coveringTests(ctx.worktree, ctx.baseRef)
|
|
1368
|
-
: undefined;
|
|
1369
|
-
let selectionReason = ctx.selectionReason ?? (selected ? "affected-tests" : "full-suite-fallback");
|
|
1370
|
-
if (selected && ctx.requiredRepairTests?.length) {
|
|
1371
|
-
const required = ctx.requiredRepairTests;
|
|
1468
|
+
proofOwed = true;
|
|
1469
|
+
const testRuns = enabled("test") && ctx.commands.test !== undefined;
|
|
1470
|
+
// v2.6.7 T1 (closed order table): the ONE diagnostic a round may buy before semantics — a concrete
|
|
1471
|
+
// attributed behavioral test-red repair (the caller's known failing files) whose diagnostic covers
|
|
1472
|
+
// every required failing file plus the conservatively selected tests reaching its diff, admitted only
|
|
1473
|
+
// on comparable harness timing with ratio <= 0.15 AND estimate <= 60000 ms. Selection disabled,
|
|
1474
|
+
// unsupported attribution, the analyzable-path cap or unknown/over-bound timing skip it: semantics
|
|
1475
|
+
// then precede the one full job, never an expensive screen standing in front of them. Test-only
|
|
1476
|
+
// verification (no semantic gate) or an explicit full recheck (no selection) keeps its full scope.
|
|
1477
|
+
let diagnostic;
|
|
1478
|
+
let decision;
|
|
1479
|
+
const required = ctx.requiredRepairTests ?? [];
|
|
1480
|
+
if (testRuns && ctx.selectTests === true && required.length && !semantic)
|
|
1481
|
+
decision = { reason: "test-only-verification" };
|
|
1482
|
+
else if (testRuns && ctx.selectTests === true && required.length) {
|
|
1372
1483
|
const safe = required.every((path) => posix.normalize(path) === path && !path.startsWith("../")
|
|
1373
1484
|
&& !path.startsWith("/") && TEST_FILE_RE.test(path) && existsSync(join(ctx.worktree, path)));
|
|
1374
1485
|
const listed = safe ? await shGit(`git ls-files -z -- ${required.map(shq).join(" ")}`, ctx.worktree) : undefined;
|
|
1375
1486
|
const tracked = new Set(listed?.stdout.split("\0").filter(Boolean));
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1487
|
+
const covering = await coveringTests(ctx.worktree, ctx.baseRef);
|
|
1488
|
+
if (!listed || listed.code !== 0 || required.some((path) => !tracked.has(path)))
|
|
1489
|
+
decision = { reason: "required-repair-test-unavailable" };
|
|
1490
|
+
else if (!covering.files)
|
|
1491
|
+
decision = { reason: covering.reason };
|
|
1492
|
+
else {
|
|
1493
|
+
const files = [...new Set([...covering.files, ...required])].sort();
|
|
1494
|
+
// An exact full green already answers the full job; reusing it is the full job, not a screen.
|
|
1495
|
+
const id = await fullTestIdentity();
|
|
1496
|
+
const hit = verdictStore.get(id);
|
|
1497
|
+
if (hit)
|
|
1498
|
+
classifySignalOnlyTest(hit, true);
|
|
1499
|
+
if (hit?.pass === true && !isInfraResult(hit) && await answersNow(id, hit, false))
|
|
1500
|
+
decision = { reason: "full-green-cache" };
|
|
1501
|
+
else {
|
|
1502
|
+
decision = diagnosticAdmission(ctx.baseline, files);
|
|
1503
|
+
if (decision.admitted)
|
|
1504
|
+
diagnostic = files;
|
|
1505
|
+
}
|
|
1379
1506
|
}
|
|
1380
|
-
else
|
|
1381
|
-
selected = [...new Set([...selected, ...required])].sort();
|
|
1382
1507
|
}
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
|
|
1387
|
-
let promotion;
|
|
1388
|
-
if (selected) {
|
|
1389
|
-
const hit = verdictStore.get(await fullTestIdentity());
|
|
1390
|
-
const costRatio = screenCostRatio(ctx.baseline, selected);
|
|
1391
|
-
if (hit?.pass === true && !isInfraResult(hit) && await certifiesFullManifest(hit))
|
|
1392
|
-
promotion = { reason: "full-green-cache" };
|
|
1393
|
-
else if (costRatio !== undefined && costRatio >= SCREEN_PROMOTION_RATIO)
|
|
1394
|
-
promotion = { reason: "screen-cost-promoted", costRatio };
|
|
1395
|
-
if (promotion)
|
|
1396
|
-
selected = undefined;
|
|
1397
|
-
}
|
|
1398
|
-
if (ctx.selectionReason || promotion)
|
|
1508
|
+
// The recorded reason is the one that actually decided scope: with no decision above, it is the
|
|
1509
|
+
// eligibility check that failed. Selection off: the caller's reason is what turned it off (an explicit
|
|
1510
|
+
// full recheck, a distrusted history) — unless it names selection itself, which cannot have disabled it.
|
|
1511
|
+
if (ctx.selectionReason)
|
|
1399
1512
|
selectionDecision = {
|
|
1400
|
-
scope:
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1513
|
+
scope: diagnostic ? "selected" : "full",
|
|
1514
|
+
reason: decision?.reason ?? (ctx.selectTests !== true
|
|
1515
|
+
? (ctx.selectionReason === "known-failing-files" ? "selection-disabled" : ctx.selectionReason)
|
|
1516
|
+
: !required.length ? "no-required-repair-tests" : "test-gate-disabled"),
|
|
1517
|
+
requiredFiles: [...required],
|
|
1518
|
+
...(decision?.costRatio !== undefined ? { costRatio: decision.costRatio } : {}),
|
|
1519
|
+
...(decision?.estimatedMs !== undefined ? { estimatedMs: decision.estimatedMs } : {}),
|
|
1404
1520
|
};
|
|
1405
|
-
|
|
1406
|
-
if (
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1521
|
+
let diagnosed = false;
|
|
1522
|
+
if (diagnostic) {
|
|
1523
|
+
await runBattery({ ...ctx.commands, test: testCommandForFiles(ctx.commands.test, diagnostic) }, diagnostic, ["test"]);
|
|
1524
|
+
if (failed())
|
|
1525
|
+
return done();
|
|
1526
|
+
diagnosed = results.some((r) => r.gate === "test");
|
|
1527
|
+
// An inconclusive (infrastructure) diagnostic answered nothing: the full job's row says so.
|
|
1528
|
+
if (!diagnosed && selectionDecision)
|
|
1529
|
+
selectionDecision = { ...selectionDecision, scope: "full", reason: "diagnostic-inconclusive" };
|
|
1530
|
+
}
|
|
1531
|
+
if (semantic) {
|
|
1532
|
+
// A cancelled round starts no test; done() records its proof OWED (D-974), never a verdict.
|
|
1533
|
+
if (await runSemantics())
|
|
1534
|
+
return done();
|
|
1535
|
+
// Subject freshness: the semantic gates ran oracles and vendor CLIs in this worktree. Their dirt is
|
|
1536
|
+
// not the committed subject, so no full job measures it and no cached green row answers for it.
|
|
1537
|
+
const dirt = testRuns ? await dirtyWorktree() : undefined;
|
|
1420
1538
|
if (dirt) {
|
|
1421
|
-
const
|
|
1422
|
-
|
|
1423
|
-
|
|
1539
|
+
const at = results.findIndex((r) => r.gate === "test");
|
|
1540
|
+
if (at >= 0)
|
|
1541
|
+
results.splice(at, 1);
|
|
1542
|
+
await emitStart("test");
|
|
1543
|
+
await record(await dirtyRefusal("test", dirt));
|
|
1424
1544
|
return done();
|
|
1425
1545
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
loadSamples.delete("test");
|
|
1437
|
-
}
|
|
1546
|
+
}
|
|
1547
|
+
if (!diagnosed) {
|
|
1548
|
+
await runBattery(ctx.commands, undefined, enabled("test") ? ["test"] : []);
|
|
1549
|
+
if (failed())
|
|
1550
|
+
return done();
|
|
1551
|
+
}
|
|
1552
|
+
if (rerunFull) {
|
|
1553
|
+
// The superseded in-battery row keeps its own interval; the replacement measures from zero.
|
|
1554
|
+
spans.delete("test");
|
|
1555
|
+
loadSamples.delete("test");
|
|
1438
1556
|
}
|
|
1439
1557
|
// The merge-candidate round: every other gate is green, so THIS round is the one that can merge —
|
|
1440
1558
|
// the full suite runs on the exact gated commit before the pipeline reports green. Nothing merges
|
|
1441
1559
|
// on a subset (spec: "nothing merges without a complete green suite"). Its verdict replaces the
|
|
1442
1560
|
// screen's entry in the returned record (one `test` entry), and `fullSuite` says which suite spoke
|
|
1443
|
-
// while `selectedTests` keeps what the screen ran. In the stream
|
|
1444
|
-
//
|
|
1445
|
-
|
|
1561
|
+
// while `selectedTests` keeps what the screen ran. In the stream the diagnostic keeps its own
|
|
1562
|
+
// earlier event and this is the second.
|
|
1563
|
+
// OBS-635 (answersNow): a diagnosed round's full job is its first, so, like the in-battery job, a stale
|
|
1564
|
+
// green buys one fresh job (published unverdicted); a stale last job fails closed. Neither is cached.
|
|
1565
|
+
let spare = diagnosed ? 1 : 0;
|
|
1566
|
+
while (diagnosed || rerunFull) {
|
|
1446
1567
|
await emitStart("test");
|
|
1447
1568
|
// This is the last shell command a round can run — the judge's named-test oracle (acceptance.ts)
|
|
1448
1569
|
// may have run one before it, and every gate between the battery and here reads commits only, so
|
|
@@ -1456,12 +1577,17 @@ export async function runGates(task, ctx) {
|
|
|
1456
1577
|
identity = await fullTestIdentity();
|
|
1457
1578
|
const hit = verdictStore.get(identity);
|
|
1458
1579
|
if (hit)
|
|
1459
|
-
classifySignalOnlyTest(hit);
|
|
1580
|
+
classifySignalOnlyTest(hit, true);
|
|
1460
1581
|
if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")
|
|
1461
|
-
&& !(await discardCachedRed("test", hit))
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1582
|
+
&& !(await discardCachedRed("test", hit))) {
|
|
1583
|
+
// RE-PROOF, as in the battery: listing, then cleanliness and identity; stale, a fresh job measures now.
|
|
1584
|
+
if (!hit.pass || await answersNow(identity, hit, false)) {
|
|
1585
|
+
full = formatReusedRow(hit, identity);
|
|
1586
|
+
cached = true;
|
|
1587
|
+
await noteReuse("test", full, identity);
|
|
1588
|
+
}
|
|
1589
|
+
else
|
|
1590
|
+
identity = await fullTestIdentity();
|
|
1465
1591
|
}
|
|
1466
1592
|
}
|
|
1467
1593
|
if (!full) {
|
|
@@ -1474,16 +1600,25 @@ export async function runGates(task, ctx) {
|
|
|
1474
1600
|
const dirt = (!cached && full.pass) ? await dirtyWorktree() : undefined;
|
|
1475
1601
|
if (full)
|
|
1476
1602
|
classifySignalOnlyTest(full);
|
|
1477
|
-
|
|
1603
|
+
const stale = !cached && !dirt && full.pass && !(await answersNow(identity, full, false, true));
|
|
1604
|
+
if (!cached && identity && full && !dirt && !stale && !isInfraResult(full)) {
|
|
1478
1605
|
verdictStore.set(identity, { ...full, meta: { ...full.meta, source: "gate", runDir: ctx.artifactDir } });
|
|
1479
1606
|
}
|
|
1607
|
+
const spoke = stale ? staleProof(full, spare > 0) : full;
|
|
1480
1608
|
const merged = withTelemetry(dirt
|
|
1481
1609
|
? { ...await dirtyRefusal("test", dirt, ctx.commands.test), evidenceReceipt: full.evidenceReceipt, evidenceReceipts: full.evidenceReceipts }
|
|
1482
|
-
: { ...
|
|
1483
|
-
merged.evidenceReceipts = [...(
|
|
1610
|
+
: { ...spoke, meta: { ...spoke.meta, fullSuite: true, selectedTests: diagnosed ? diagnostic : undefined } });
|
|
1611
|
+
merged.evidenceReceipts = [...(full?.evidenceReceipts ?? [])];
|
|
1612
|
+
if (stale && spare-- > 0) {
|
|
1613
|
+
await ctx.onGate?.({ phase: "end", gate: "test", result: merged });
|
|
1614
|
+
// The superseded job keeps its own interval; the replacement measures from zero.
|
|
1615
|
+
spans.delete("test");
|
|
1616
|
+
loadSamples.delete("test");
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1484
1619
|
results[results.findIndex((r) => r.gate === "test")] = merged;
|
|
1485
|
-
heldTest = undefined;
|
|
1486
1620
|
await ctx.onGate?.({ phase: "end", gate: "test", result: merged });
|
|
1621
|
+
break;
|
|
1487
1622
|
}
|
|
1488
1623
|
return done();
|
|
1489
1624
|
}
|