tickmarkr 2.5.5 → 2.5.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 +3 -1
- package/dist/adapters/prompt.js +21 -1
- package/dist/cli/commands/approve.js +69 -8
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/fleet.js +4 -0
- package/dist/cli/commands/plan.js +20 -4
- package/dist/cli/commands/status.js +95 -34
- package/dist/cli/commands/verify.js +108 -85
- package/dist/config/config.d.ts +11 -0
- package/dist/config/config.js +21 -12
- package/dist/config/fleet-overlay.js +55 -17
- package/dist/drivers/index.js +2 -1
- package/dist/drivers/orca.d.ts +21 -1
- package/dist/drivers/orca.js +209 -27
- package/dist/gates/baseline.d.ts +21 -5
- package/dist/gates/baseline.js +67 -17
- package/dist/gates/cache.d.ts +14 -13
- package/dist/gates/cache.js +17 -5
- package/dist/gates/llm.d.ts +3 -0
- package/dist/gates/llm.js +11 -0
- package/dist/gates/review.d.ts +28 -3
- package/dist/gates/review.js +118 -15
- package/dist/gates/run-gates.d.ts +10 -2
- package/dist/gates/run-gates.js +362 -108
- package/dist/gates/test-manifest.d.ts +33 -1
- package/dist/gates/test-manifest.js +132 -40
- package/dist/gates/test-reporter.js +20 -7
- package/dist/graph/graph.d.ts +4 -0
- package/dist/graph/graph.js +50 -1
- package/dist/run/activity.d.ts +28 -0
- package/dist/run/activity.js +194 -0
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +11 -0
- package/dist/run/daemon.js +663 -128
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +46 -1
- package/dist/run/git.js +149 -12
- package/dist/run/journal.d.ts +23 -5
- package/dist/run/journal.js +98 -25
- package/dist/run/lease.d.ts +44 -0
- package/dist/run/lease.js +226 -3
- package/dist/run/operator-page-summary.d.ts +56 -0
- package/dist/run/operator-page-summary.js +68 -0
- package/dist/run/operator-summary.d.ts +69 -0
- package/dist/run/operator-summary.js +77 -0
- package/dist/run/protocol.d.ts +71 -0
- package/dist/run/protocol.js +32 -0
- package/dist/run/recovery.d.ts +8 -0
- package/dist/run/recovery.js +25 -0
- package/dist/run/repair-selection.d.ts +12 -0
- package/dist/run/repair-selection.js +56 -0
- package/dist/run/stall.d.ts +6 -1
- package/dist/run/stall.js +60 -3
- package/dist/tui/cockpit/board.d.ts +9 -0
- package/dist/tui/cockpit/board.js +10 -0
- package/dist/tui/cockpit/derive.d.ts +35 -0
- package/dist/tui/cockpit/derive.js +152 -10
- package/dist/tui/cockpit/evidence-view.d.ts +2 -0
- package/dist/tui/cockpit/evidence-view.js +42 -12
- package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
- package/dist/tui/cockpit/run-cockpit.js +95 -1
- package/dist/tui/cockpit/run-view.d.ts +37 -0
- package/dist/tui/cockpit/run-view.js +189 -2
- package/dist/tui/ink/fleet-app.d.ts +10 -2
- package/dist/tui/ink/fleet-app.js +33 -15
- package/package.json +2 -2
- package/skills/tickmarkr-overseer/SKILL.md +55 -3
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
package/dist/drivers/orca.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { shq } from "../adapters/types.js";
|
|
@@ -300,26 +300,57 @@ function readBoard(path, repo, runId) {
|
|
|
300
300
|
* excludes other writers, then create-only `link`s the new inode (fails if anything exists) or
|
|
301
301
|
* `rename`s the new file over the live path (POSIX atomic replace — readers see old or new, never
|
|
302
302
|
* absence). A crash that leaves a `.tmp` or `.lock` does not drop the previous record.
|
|
303
|
+
* Each lock generation publishes its pid and nonce atomically in a symlink target. A holder that died
|
|
304
|
+
* between taking and releasing it is recovered at once (its lock is taken over in place); a live
|
|
305
|
+
* holder bounds the wait on injected time and is then refused with the record untouched. A legacy
|
|
306
|
+
* pid-less or malformed generation is recoverable too: a contender atomically creates the next owner
|
|
307
|
+
* generation, and release removes the directory only while that exact generation is still current.
|
|
303
308
|
* ponytail: observeNamedRun (supervision.ts) renames without CAS. It cannot interleave with a swap
|
|
304
309
|
* because the narrator writes nothing between reserve and claim, and no transition here swaps one.
|
|
305
310
|
*/
|
|
306
|
-
export function casBoard(family, path, expected, next) {
|
|
311
|
+
export async function casBoard(family, path, expected, next, time = SYSTEM_TIME) {
|
|
307
312
|
const raw = JSON.stringify(next) + "\n";
|
|
308
313
|
const refused = () => new OrcaError(family, `watch owner record ${path} changed underneath; swap refused and the current record kept`, "");
|
|
309
314
|
mkdirSync(dirname(path), { recursive: true });
|
|
310
315
|
const lock = `${path}.lock`;
|
|
311
|
-
const deadline =
|
|
316
|
+
const deadline = time.now() + LOCK_WAIT_MS;
|
|
317
|
+
const ownerTarget = `${process.pid}:${randomUUID()}`;
|
|
318
|
+
let ownerEntry = "";
|
|
319
|
+
let acquiredEntries = [];
|
|
312
320
|
for (;;) {
|
|
313
321
|
try {
|
|
314
322
|
mkdirSync(lock);
|
|
315
|
-
break;
|
|
316
323
|
}
|
|
317
324
|
catch (error) {
|
|
318
325
|
if (error.code !== "EEXIST")
|
|
319
326
|
throw error;
|
|
320
|
-
|
|
327
|
+
}
|
|
328
|
+
// The marker's directory entry and target appear as one filesystem operation. If the creator
|
|
329
|
+
// dies after mkdir but before this call, a contender claims owner.0; if both race, only one
|
|
330
|
+
// symlink wins and the loser judges that published owner before entering the CAS section.
|
|
331
|
+
const holder = lockHolder(lock);
|
|
332
|
+
if (holder?.pid !== undefined && pidLive(holder.pid)) {
|
|
333
|
+
if (time.now() >= deadline)
|
|
321
334
|
throw new OrcaError(family, `watch owner record ${path} lock not acquired; current record kept`, "");
|
|
322
|
-
|
|
335
|
+
await time.sleep(10);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
ownerEntry = `owner.${(holder?.gen ?? -1) + 1}`;
|
|
339
|
+
try {
|
|
340
|
+
symlinkSync(ownerTarget, join(lock, ownerEntry));
|
|
341
|
+
// Snapshot only generations that this takeover superseded, with our marker last. Release must
|
|
342
|
+
// never recursively scan: after our final unlink a contender may publish a replacement marker
|
|
343
|
+
// before rmdir, and that marker belongs to the contender, not to this generation.
|
|
344
|
+
acquiredEntries = readdirSync(lock)
|
|
345
|
+
.filter((entry) => entry === ownerEntry || lockEntryGeneration(entry) !== undefined)
|
|
346
|
+
.sort((a, b) => Number(a === ownerEntry) - Number(b === ownerEntry));
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
const code = error.code;
|
|
351
|
+
if (code !== "EEXIST" && code !== "ENOENT")
|
|
352
|
+
throw error;
|
|
353
|
+
continue; // another contender published this generation, or the prior holder just released
|
|
323
354
|
}
|
|
324
355
|
}
|
|
325
356
|
try {
|
|
@@ -354,9 +385,69 @@ export function casBoard(family, path, expected, next) {
|
|
|
354
385
|
return raw;
|
|
355
386
|
}
|
|
356
387
|
finally {
|
|
357
|
-
|
|
388
|
+
releaseBoardLock(lock, ownerEntry, ownerTarget, acquiredEntries);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const LOCK_WAIT_MS = 2_000;
|
|
392
|
+
/** Best-effort release cannot invalidate a committed CAS. Superseded entries go first and our marker
|
|
393
|
+
* goes last; after that unlink, a non-recursive rmdir either removes the empty old generation or
|
|
394
|
+
* leaves a contender's newly published marker untouched. Any abnormal filesystem error leaves the
|
|
395
|
+
* lock for the next generation's stale-owner recovery instead of escaping from the caller's finally. */
|
|
396
|
+
function releaseBoardLock(lock, ownerEntry, ownerTarget, acquiredEntries) {
|
|
397
|
+
const holder = lockHolder(lock);
|
|
398
|
+
if (holder?.entry !== ownerEntry || holder.target !== ownerTarget)
|
|
399
|
+
return;
|
|
400
|
+
for (const entry of acquiredEntries) {
|
|
401
|
+
try {
|
|
402
|
+
unlinkSync(join(lock, entry));
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
if (error.code !== "ENOENT")
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
rmdirSync(lock);
|
|
411
|
+
}
|
|
412
|
+
catch { /* ENOTEMPTY means a contender owns it; any other failure is recoverable stale state. */ }
|
|
413
|
+
}
|
|
414
|
+
/** The highest published generation. `pid*` files are accepted for recovery compatibility; new
|
|
415
|
+
* owners use atomic `owner.N -> pid:nonce` symlinks. Missing or malformed contents return a
|
|
416
|
+
* generation with no pid, which is stale and can only be superseded by creating the next marker. */
|
|
417
|
+
function lockHolder(lock) {
|
|
418
|
+
try {
|
|
419
|
+
const candidates = readdirSync(lock).flatMap((entry) => {
|
|
420
|
+
const owner = /^owner\.(\d+)$/.exec(entry);
|
|
421
|
+
const gen = lockEntryGeneration(entry);
|
|
422
|
+
return gen === undefined ? [] : [{ gen, entry, owner: owner !== null }];
|
|
423
|
+
}).sort((a, b) => b.gen - a.gen || Number(b.owner) - Number(a.owner));
|
|
424
|
+
const current = candidates[0];
|
|
425
|
+
if (!current)
|
|
426
|
+
return undefined;
|
|
427
|
+
let target;
|
|
428
|
+
try {
|
|
429
|
+
target = current.owner
|
|
430
|
+
? readlinkSync(join(lock, current.entry))
|
|
431
|
+
: readFileSync(join(lock, current.entry), "utf8").trim();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return { gen: current.gen, entry: current.entry };
|
|
435
|
+
}
|
|
436
|
+
const pidText = current.owner ? /^([1-9]\d*):.+$/.exec(target)?.[1] : target;
|
|
437
|
+
const pid = Number(pidText);
|
|
438
|
+
return Number.isInteger(pid) && pid > 0
|
|
439
|
+
? { gen: current.gen, entry: current.entry, target, pid }
|
|
440
|
+
: { gen: current.gen, entry: current.entry, target };
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return undefined;
|
|
358
444
|
}
|
|
359
445
|
}
|
|
446
|
+
function lockEntryGeneration(entry) {
|
|
447
|
+
const owner = /^owner\.(\d+)$/.exec(entry);
|
|
448
|
+
const legacy = /^pid(?:\.(\d+))?$/.exec(entry);
|
|
449
|
+
return owner ? Number(owner[1]) : legacy ? Number(legacy[1] ?? 0) : undefined;
|
|
450
|
+
}
|
|
360
451
|
function pidLive(pid) {
|
|
361
452
|
try {
|
|
362
453
|
process.kill(pid, 0);
|
|
@@ -463,6 +554,13 @@ function collectTabs(node, out) {
|
|
|
463
554
|
export function joinWrapped(raw) {
|
|
464
555
|
return raw.split("\n").map((l) => l.replace(/^[\s│|]+/, "").replace(/[\s│|]+$/, "")).join("");
|
|
465
556
|
}
|
|
557
|
+
/** The OBS-1011 add.1 capture: `status:"exited", tail:[], returnedLineCount 0` from a stream read. */
|
|
558
|
+
export function isBlindStreamPage(term) {
|
|
559
|
+
return str(term.status) === "exited"
|
|
560
|
+
&& term.source !== "screen"
|
|
561
|
+
&& Array.isArray(term.tail) && term.tail.length === 0
|
|
562
|
+
&& (term.returnedLineCount === undefined || term.returnedLineCount === 0);
|
|
563
|
+
}
|
|
466
564
|
export class OrcaDriver {
|
|
467
565
|
id = "orca";
|
|
468
566
|
interactive = true; // a visible terminal the operator can watch and answer
|
|
@@ -637,8 +735,8 @@ export class OrcaDriver {
|
|
|
637
735
|
// terminalOp proves the runtime binding before the handle goes on the wire.
|
|
638
736
|
await this.sendText(st, cmd);
|
|
639
737
|
}
|
|
640
|
-
async sendText(st, text) {
|
|
641
|
-
const env = await this.terminalOp("send", st, (h) => this.call("send", ["terminal", "send", "--terminal", h, "--text", text, "--enter"], this.cliCwd(st)), { mutating: true });
|
|
738
|
+
async sendText(st, text, waitSubmit = false) {
|
|
739
|
+
const env = await this.terminalOp("send", st, (h) => this.call("send", ["terminal", "send", "--terminal", h, "--text", text, "--enter", ...(waitSubmit ? ["--wait-submit", "15"] : [])], this.cliCwd(st)), { mutating: true });
|
|
642
740
|
// Recorded 1.4.195 send receipt: result.send = {handle, accepted, bytesWritten}. `ok:true`
|
|
643
741
|
// alone is not delivery: the accepted receipt must name this handle and account for the bytes.
|
|
644
742
|
const receipt = this.sendReceipt(env, st);
|
|
@@ -646,6 +744,7 @@ export class OrcaDriver {
|
|
|
646
744
|
if (receipt.accepted !== true || typeof receipt.bytesWritten !== "number" || receipt.bytesWritten !== expectedBytes) {
|
|
647
745
|
throw new OrcaError("send", `send receipt does not report expected byte delivery (accepted: ${JSON.stringify(receipt.accepted)}, bytesWritten: ${receipt.bytesWritten}, expected: ${expectedBytes})`, env.raw);
|
|
648
746
|
}
|
|
747
|
+
return receipt;
|
|
649
748
|
}
|
|
650
749
|
sendReceipt(env, st) {
|
|
651
750
|
const receipt = env.result.send;
|
|
@@ -989,8 +1088,35 @@ export class OrcaDriver {
|
|
|
989
1088
|
...(cursor === undefined || recovered ? [] : ["--cursor", cursor]),
|
|
990
1089
|
"--limit", String(lines),
|
|
991
1090
|
], this.cliCwd(st)), { onRecovered: () => { recovered = true; } });
|
|
1091
|
+
if (isBlindStreamPage(requireTerminal(family, env))) {
|
|
1092
|
+
return { term: await this.screenBehindBlindStream(family, st, env), raw: env.raw, recovered };
|
|
1093
|
+
}
|
|
992
1094
|
return { term: this.validated(family, st, env), raw: env.raw, recovered };
|
|
993
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* OBS-1011 add.1 / OBS-1016: the captured incident shape — a stream page answering status exited
|
|
1098
|
+
* with an empty tail on a terminal that accepted a send seconds earlier — is BLIND, not dead, when
|
|
1099
|
+
* the same handle's show record on the same runtime reports connected and not orphaned (show carries
|
|
1100
|
+
* no status field; none is demanded) and its screen read reports running. Then the rendered frame is
|
|
1101
|
+
* the terminal's bytes. Anything less — disconnected, orphaned, another handle or runtime, a screen
|
|
1102
|
+
* that is unavailable or exited — is refused as unavailable, exactly as the dead record would be.
|
|
1103
|
+
*/
|
|
1104
|
+
async screenBehindBlindStream(family, st, blind) {
|
|
1105
|
+
const handle = str(requireTerminal(family, blind).handle);
|
|
1106
|
+
if (handle !== st.handle) {
|
|
1107
|
+
throw new OrcaUnavailableError(family, `terminal record names ${handle ?? "no handle"}, not the addressed ${st.handle}`, blind.raw);
|
|
1108
|
+
}
|
|
1109
|
+
const show = await this.call("show", ["terminal", "show", "--terminal", st.handle], this.cliCwd(st));
|
|
1110
|
+
if (show.runtimeId !== st.runtimeId) {
|
|
1111
|
+
throw new OrcaUnavailableError(family, `exited-shaped stream page: show answered by runtime ${show.runtimeId}, not the bound ${st.runtimeId}`, show.raw, "exited");
|
|
1112
|
+
}
|
|
1113
|
+
this.liveShowTerm(family, st, show);
|
|
1114
|
+
const screen = await this.readScreen(st);
|
|
1115
|
+
if (screen.source !== "screen") {
|
|
1116
|
+
throw new OrcaUnavailableError(family, `exited-shaped stream page and no rendered screen for ${st.handle}`, blind.raw, "exited");
|
|
1117
|
+
}
|
|
1118
|
+
return screen.term;
|
|
1119
|
+
}
|
|
994
1120
|
/** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
|
|
995
1121
|
async readScreen(st) {
|
|
996
1122
|
const env = await this.terminalOp("status", st, (h) => this.call("read", [
|
|
@@ -1238,17 +1364,26 @@ export class OrcaDriver {
|
|
|
1238
1364
|
const st = this.state(slot);
|
|
1239
1365
|
if (!await this.waitCondition(st, "tui-idle", 1))
|
|
1240
1366
|
return false;
|
|
1241
|
-
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1367
|
+
const receipt = await this.sendText(st, message, true);
|
|
1368
|
+
// OBS-1016: delivery is proven by the receipt — a prompt stage of turn_started — or by the
|
|
1369
|
+
// composer emptying on a screen read, never by stream echo: the stream is blind on a live Orca
|
|
1370
|
+
// terminal (OBS-1011 add.1), so an echo sweep fails every nudge and latches the harvest hold.
|
|
1371
|
+
const prompt = typeof receipt.prompt === "object" && receipt.prompt !== null && !Array.isArray(receipt.prompt)
|
|
1372
|
+
? receipt.prompt
|
|
1373
|
+
: {};
|
|
1374
|
+
const stages = Array.isArray(prompt.stages) ? prompt.stages : [];
|
|
1375
|
+
if (stages.includes("turn_started"))
|
|
1376
|
+
return true;
|
|
1247
1377
|
const deadline = this.time.now() + NUDGE_ECHO_TIMEOUT_MS;
|
|
1248
1378
|
for (;;) {
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1379
|
+
const screen = await this.readScreen(st);
|
|
1380
|
+
// ponytail: "composer emptied" is "the text is no longer on the frame" — a TUI that keeps the
|
|
1381
|
+
// submitted turn on screen reads as undelivered until the timeout; the receipt stage is primary.
|
|
1382
|
+
if (screen.source === "screen") {
|
|
1383
|
+
const frame = this.tailText("status", screen.term, "");
|
|
1384
|
+
if (!frame.includes(message) && !joinWrapped(frame).includes(message))
|
|
1385
|
+
return true;
|
|
1386
|
+
}
|
|
1252
1387
|
const left = deadline - this.time.now();
|
|
1253
1388
|
if (left <= 0)
|
|
1254
1389
|
return false;
|
|
@@ -1280,7 +1415,36 @@ export class OrcaDriver {
|
|
|
1280
1415
|
else if (current) {
|
|
1281
1416
|
const state = boardState(current.record);
|
|
1282
1417
|
if (state === "reserved" || state === "claimed") {
|
|
1283
|
-
|
|
1418
|
+
// A reservation or claim with no bound pane is the placing narrator's normal intermediate
|
|
1419
|
+
// state while that narrator lives (awaiting its receipt, listing, claim or bind): refused
|
|
1420
|
+
// untouched, its observer never asked to stop. Only a DEAD placer (or one unrecorded) makes
|
|
1421
|
+
// it a crash to recover.
|
|
1422
|
+
const { placer } = current.record;
|
|
1423
|
+
if (typeof placer !== "number" || pidLive(placer)) {
|
|
1424
|
+
throw new OrcaError("split", `Orca narrator placement remains unresolved for ${name} (${state}, no bound pane) while placing narrator ${placer ?? "unrecorded"} lives; ${kept}`, "");
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (state === "claimed") {
|
|
1428
|
+
// The placing driver crashed between the observer's claim and its bind. The record is
|
|
1429
|
+
// replaced only once that observer is proven gone: dead outright, or live and stopped with
|
|
1430
|
+
// its acknowledgement awaited on injected time — unacknowledged, it is refused untouched.
|
|
1431
|
+
const pid = current.record.pid;
|
|
1432
|
+
if (pidLive(pid)) {
|
|
1433
|
+
try {
|
|
1434
|
+
await stopWatchBoard(current.record, this.time);
|
|
1435
|
+
}
|
|
1436
|
+
catch (error) {
|
|
1437
|
+
throw new OrcaError("split", `Orca narrator placement refused for ${name}: claimed board's live observer unacknowledged; ${kept}; ${errorText(error)}`, "");
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
const retired = { ...current.record, retired: true };
|
|
1441
|
+
current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
|
|
1442
|
+
}
|
|
1443
|
+
if (state === "reserved") {
|
|
1444
|
+
// The placer died before any claim: no observer exists, its split pane (if any) is unknown
|
|
1445
|
+
// to the record. Tombstoned in place so the generic tail below replaces it.
|
|
1446
|
+
const retired = { ...current.record, retired: true };
|
|
1447
|
+
current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
|
|
1284
1448
|
}
|
|
1285
1449
|
if (state === "bound") {
|
|
1286
1450
|
// The bound record is the durable truth of placement: the child handle PLUS the split
|
|
@@ -1290,7 +1454,7 @@ export class OrcaDriver {
|
|
|
1290
1454
|
}
|
|
1291
1455
|
// Lost: this runtime no longer has that pane. Tombstone before anything else.
|
|
1292
1456
|
const retired = { ...current.record, retired: true };
|
|
1293
|
-
current = { raw: casBoard("split", path, current.raw, retired), record: retired };
|
|
1457
|
+
current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
|
|
1294
1458
|
}
|
|
1295
1459
|
// A tombstone can be an acknowledgement timeout, not completed observer cleanup. A live
|
|
1296
1460
|
// observer must stop and ack on injected time before the handle-bound close; timeout keeps
|
|
@@ -1316,7 +1480,19 @@ export class OrcaDriver {
|
|
|
1316
1480
|
// Reserve before any command can read the token: create-only, or a swap of the exact record
|
|
1317
1481
|
// judged replaceable above.
|
|
1318
1482
|
const token = randomUUID();
|
|
1319
|
-
casBoard("split", path, current?.raw, { repo: realpathSync(cwd), runId, driver: this.id, workspace: ORCA_SPACE, pane: "", name, token });
|
|
1483
|
+
await casBoard("split", path, current?.raw, { repo: realpathSync(cwd), runId, driver: this.id, workspace: ORCA_SPACE, pane: "", name, token, placer: process.pid }, this.time);
|
|
1484
|
+
// A reservation that can never be bound — no usable receipt, or no claim — is tombstoned so the
|
|
1485
|
+
// next narrator call splits afresh instead of refusing forever. Only THIS reservation, and only
|
|
1486
|
+
// while it is still the untouched reservation; a record anyone else moved is left to them.
|
|
1487
|
+
const tombstone = async (extra = {}) => {
|
|
1488
|
+
try {
|
|
1489
|
+
const now = readBoard(path, cwd, runId);
|
|
1490
|
+
if (now?.record.token === token && boardState(now.record) === "reserved") {
|
|
1491
|
+
await casBoard("split", path, now.raw, { ...now.record, ...extra, retired: true }, this.time);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
catch { /* the record is kept as it stands; the next narrator judges it */ }
|
|
1495
|
+
};
|
|
1320
1496
|
// Exactly one terminal split of the launching handle, horizontal, carrying token and command.
|
|
1321
1497
|
// An unknown receipt (transport failure, refusal, unparseable output) after the verb was issued
|
|
1322
1498
|
// is as indeterminate as a handle-less one: the pane may exist, so the reservation stays.
|
|
@@ -1325,7 +1501,8 @@ export class OrcaDriver {
|
|
|
1325
1501
|
"--terminal", launchingHandle,
|
|
1326
1502
|
"--direction", "horizontal",
|
|
1327
1503
|
"--command", `${WATCH_OWNER_ENV}=${shq(token)} ${command}`,
|
|
1328
|
-
], cwd).catch((error) => {
|
|
1504
|
+
], cwd).catch(async (error) => {
|
|
1505
|
+
await tombstone();
|
|
1329
1506
|
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown (${error instanceof OrcaError ? error.reason : errorText(error)}); ${kept}`, error instanceof OrcaError ? error.raw : "");
|
|
1330
1507
|
});
|
|
1331
1508
|
let child;
|
|
@@ -1333,6 +1510,7 @@ export class OrcaDriver {
|
|
|
1333
1510
|
child = splitReceipt(env);
|
|
1334
1511
|
}
|
|
1335
1512
|
catch {
|
|
1513
|
+
await tombstone();
|
|
1336
1514
|
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, env.raw);
|
|
1337
1515
|
}
|
|
1338
1516
|
// Parent tabId must name the launching terminal's tab, and the child must actually appear
|
|
@@ -1343,6 +1521,7 @@ export class OrcaDriver {
|
|
|
1343
1521
|
listing = await this.listAll(cwd);
|
|
1344
1522
|
}
|
|
1345
1523
|
catch (error) {
|
|
1524
|
+
await tombstone();
|
|
1346
1525
|
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, error instanceof OrcaError ? error.raw : env.raw);
|
|
1347
1526
|
}
|
|
1348
1527
|
const launchingTabId = terminalTabId(listing, launchingHandle);
|
|
@@ -1350,6 +1529,7 @@ export class OrcaDriver {
|
|
|
1350
1529
|
|| child.tabId !== launchingTabId
|
|
1351
1530
|
|| child.handle === launchingHandle
|
|
1352
1531
|
|| terminalTabId(listing, child.handle) !== launchingTabId) {
|
|
1532
|
+
await tombstone();
|
|
1353
1533
|
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, env.raw);
|
|
1354
1534
|
}
|
|
1355
1535
|
const childHandle = child.handle;
|
|
@@ -1370,7 +1550,9 @@ export class OrcaDriver {
|
|
|
1370
1550
|
await this.time.sleep(20);
|
|
1371
1551
|
}
|
|
1372
1552
|
if (!claim) {
|
|
1373
|
-
// The receipt's handle may be closed
|
|
1553
|
+
// The receipt's handle may be closed; the reservation is tombstoned naming that pane so the
|
|
1554
|
+
// next narrator can prove it gone (or close it) and split afresh.
|
|
1555
|
+
await tombstone({ pane: childHandle, runtimeId: env.runtimeId });
|
|
1374
1556
|
let pane;
|
|
1375
1557
|
try {
|
|
1376
1558
|
await this.closeRecordedPane(cwd, name, childHandle, env.runtimeId);
|
|
@@ -1381,7 +1563,7 @@ export class OrcaDriver {
|
|
|
1381
1563
|
}
|
|
1382
1564
|
throw new OrcaError("split", `Orca narrator board unclaimed for ${name}: the observer never claimed the record (unclaimed board); ${kept}; ${pane}`, env.raw);
|
|
1383
1565
|
}
|
|
1384
|
-
casBoard("split", path, claim.raw, { ...claim.record, pane: childHandle, runtimeId: env.runtimeId });
|
|
1566
|
+
await casBoard("split", path, claim.raw, { ...claim.record, pane: childHandle, runtimeId: env.runtimeId }, this.time);
|
|
1385
1567
|
return watchSlot(cwd, name, childHandle);
|
|
1386
1568
|
});
|
|
1387
1569
|
}
|
|
@@ -1401,7 +1583,7 @@ export class OrcaDriver {
|
|
|
1401
1583
|
}
|
|
1402
1584
|
/** bound → retired, decided from the record alone: it must be this driver's board for exactly this
|
|
1403
1585
|
* slot's pane. Already retired is returned as it is. */
|
|
1404
|
-
retireBoard(family, slot) {
|
|
1586
|
+
async retireBoard(family, slot) {
|
|
1405
1587
|
const runId = parseOwnedName(slot.name)?.runId;
|
|
1406
1588
|
const path = runId ? this.boardPath(slot.cwd, runId) : undefined;
|
|
1407
1589
|
const current = path && runId ? readBoard(path, slot.cwd, runId) : undefined;
|
|
@@ -1412,7 +1594,7 @@ export class OrcaDriver {
|
|
|
1412
1594
|
if (r.retired)
|
|
1413
1595
|
return r;
|
|
1414
1596
|
const retired = { ...r, retired: true };
|
|
1415
|
-
casBoard(family, path, current.raw, retired);
|
|
1597
|
+
await casBoard(family, path, current.raw, retired, this.time);
|
|
1416
1598
|
return retired;
|
|
1417
1599
|
}
|
|
1418
1600
|
/** bound → retired first: whatever fails below, no later call answers this board again. A live
|
|
@@ -1421,7 +1603,7 @@ export class OrcaDriver {
|
|
|
1421
1603
|
* so it is only asked. */
|
|
1422
1604
|
async retireAndClose(slot) {
|
|
1423
1605
|
await this.serial(async () => {
|
|
1424
|
-
const retired = this.retireBoard("close", slot);
|
|
1606
|
+
const retired = await this.retireBoard("close", slot);
|
|
1425
1607
|
if (typeof retired.pid === "number" && pidLive(retired.pid))
|
|
1426
1608
|
await stopWatchBoard(retired, this.time);
|
|
1427
1609
|
else
|
package/dist/gates/baseline.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TickmarkrConfig } from "../config/config.js";
|
|
2
2
|
import type { AcceptanceItem } from "../graph/schema.js";
|
|
3
|
-
import { type RunCapacity, type ShResult } from "../run/git.js";
|
|
3
|
+
import { type RunCapacity, type ShellOptions, type ShResult } from "../run/git.js";
|
|
4
4
|
import type { GateResult } from "./types.js";
|
|
5
5
|
/**
|
|
6
6
|
* T7: the capacity a gate's own command ran under rides the RESULT, beside the verdict it explains,
|
|
@@ -31,8 +31,12 @@ export interface BaselineCommand {
|
|
|
31
31
|
durationMs?: number;
|
|
32
32
|
/** Sum of the per-file durations named by the runner; null when its output names none. */
|
|
33
33
|
fileDurationSumMs?: number | null;
|
|
34
|
-
/** Total files
|
|
34
|
+
/** Total files the runner would collect; null when unavailable. */
|
|
35
35
|
fileCount?: number | null;
|
|
36
|
+
/** OBS-1044: `manifest` when `fileCount` came from the runner's own listing (test-manifest.ts's
|
|
37
|
+
* discovery seam) rather than a stdout summary sum. Absent on every pre-2.5.6 entry and on every
|
|
38
|
+
* scripted non-runner command, whose summary count keeps the stdout deficit rule byte-identically. */
|
|
39
|
+
fileCountSource?: "manifest";
|
|
36
40
|
/** fileDurationSumMs / durationMs — average implied file concurrency, not a configured fork count. */
|
|
37
41
|
impliedParallelism?: number | null;
|
|
38
42
|
/** The slowest per-file entry named by the runner; null when per-file timing is unavailable. */
|
|
@@ -150,7 +154,15 @@ export declare function ceilingKillResult(gate: string, r: ShResult, ceilingMs:
|
|
|
150
154
|
* shell asks it to finish faster than the thing it is measuring.
|
|
151
155
|
*/
|
|
152
156
|
export declare const CAPTURE_CEILING_MS = 1800000;
|
|
153
|
-
|
|
157
|
+
/** OBS-1044: a cached vitest entry whose count is a stdout sum may be inflated; only a manifest-derived
|
|
158
|
+
* count is safe to apply as a deficit floor. A null count compares nothing and is safe as-is. */
|
|
159
|
+
export declare function staleFileCountCommands(baseline: Baseline, commands: Record<string, string>, cwd: string): string[];
|
|
160
|
+
export interface BaselineReceiptOptions {
|
|
161
|
+
onReceipt?: ShellOptions["onReceipt"];
|
|
162
|
+
/** Only comparison's build command may carry task-build identity; capture never does. */
|
|
163
|
+
taskBuildAttribution?: ShellOptions["receiptAttribution"];
|
|
164
|
+
}
|
|
165
|
+
export declare function captureBaseline(cwd: string, commands: Record<string, string>, opts?: BaselineReceiptOptions): Promise<Baseline>;
|
|
154
166
|
export interface VacuousOracleWarning {
|
|
155
167
|
kind: "vacuous-oracle";
|
|
156
168
|
taskId: string;
|
|
@@ -161,7 +173,10 @@ export declare function detectVacuousOracles(cwd: string, tasks: ReadonlyArray<{
|
|
|
161
173
|
id: string;
|
|
162
174
|
acceptance: AcceptanceItem[];
|
|
163
175
|
}>): Promise<VacuousOracleWarning[]>;
|
|
164
|
-
export
|
|
176
|
+
export interface RetryOptions {
|
|
177
|
+
authorizeRetry?: (cause: "infra" | "host-starved") => boolean;
|
|
178
|
+
}
|
|
179
|
+
export declare function compareToBaseline(cwd: string, commands: Record<string, string>, baseline: Baseline, enabled: string[], opts?: RetryOptions & BaselineReceiptOptions & {
|
|
165
180
|
rerunOf?: HostStarvedRerun;
|
|
166
181
|
infraRerun?: HostStarvedRerun;
|
|
167
182
|
selected?: readonly string[];
|
|
@@ -185,7 +200,8 @@ interface CalmWindow {
|
|
|
185
200
|
}
|
|
186
201
|
export declare function setCalmWindowForTests(over: Partial<CalmWindow>): void;
|
|
187
202
|
export declare function resetCalmWindowForTests(): void;
|
|
188
|
-
export declare function
|
|
203
|
+
export declare function calmWindowReady(): boolean;
|
|
204
|
+
export declare function waitForCalmWindow(signal?: AbortSignal): Promise<number>;
|
|
189
205
|
/** Summary totals survive digit-normalized fingerprints and exclude test-owned echoed output. */
|
|
190
206
|
export declare function runnerFileCount(raw: string): number | null;
|
|
191
207
|
export declare function fileCountDeficit(entry: BaselineCommand | undefined, raw: string, opts?: {
|
package/dist/gates/baseline.js
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { availableParallelism, loadavg } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
|
|
5
|
+
import { executionSignal } from "../run/execution-budget.js";
|
|
6
|
+
import { isVitestTestCommand, manifestFileCount } from "./test-manifest.js";
|
|
5
7
|
// incident #2 (run-20260709-104447): a vitest ✓ PASS line with "error" in the test NAME, wrapped in ANSI
|
|
6
8
|
// codes that varied between baseline and worktree runs, was reported as a "new failure". Strip ANSI first;
|
|
7
9
|
// a pass-marker line is never a failure. [\d;#] covers raw ANSI and digit-normalized ANSI ("\x1b[#m") from
|
|
@@ -464,13 +466,34 @@ const invalidCaptureEntry = (durationMs, invalidCause, invalidatingLines = []) =
|
|
|
464
466
|
ceilingMs: effectiveCeilingMs({ durationMs }),
|
|
465
467
|
...(invalidatingLines.length ? { invalidatingLines } : {}),
|
|
466
468
|
});
|
|
467
|
-
|
|
469
|
+
// OBS-1044: a vitest command's count is the manifest discovery seam's (the same listing the gate
|
|
470
|
+
// asks the report to certify), never a sum of every summary line in stdout — tests that spawn
|
|
471
|
+
// nested runners echo their own summaries through the filter and inflated a baseline by two files,
|
|
472
|
+
// so any diff that changed the nested echo read as a deficit. The listing is a collection, not a
|
|
473
|
+
// run: the suite still runs exactly once here. Any other command keeps the stdout sum.
|
|
474
|
+
const captureFileCount = async (cwd, cmd, raw) => {
|
|
475
|
+
if (!isVitestTestCommand(cmd, cwd))
|
|
476
|
+
return { fileCount: runnerFileCount(raw) };
|
|
477
|
+
const fileCount = await manifestFileCount(cmd, cwd);
|
|
478
|
+
return fileCount === null ? { fileCount } : { fileCount, fileCountSource: "manifest" };
|
|
479
|
+
};
|
|
480
|
+
/** OBS-1044: a cached vitest entry whose count is a stdout sum may be inflated; only a manifest-derived
|
|
481
|
+
* count is safe to apply as a deficit floor. A null count compares nothing and is safe as-is. */
|
|
482
|
+
export function staleFileCountCommands(baseline, commands, cwd) {
|
|
483
|
+
return Object.entries(commands)
|
|
484
|
+
.filter(([name, cmd]) => {
|
|
485
|
+
const entry = baseline.commands[name];
|
|
486
|
+
return entry?.fileCount != null && entry.fileCountSource !== "manifest" && isVitestTestCommand(cmd, cwd);
|
|
487
|
+
})
|
|
488
|
+
.map(([name]) => name);
|
|
489
|
+
}
|
|
490
|
+
export async function captureBaseline(cwd, commands, opts = {}) {
|
|
468
491
|
const base = { commands: {} };
|
|
469
492
|
for (const [name, cmd] of Object.entries(commands)) {
|
|
470
493
|
if (name === "tipTest" && commands.test !== undefined && cmd === commands.test) {
|
|
471
494
|
continue;
|
|
472
495
|
}
|
|
473
|
-
const r = await sh(cmd, cwd, CAPTURE_CEILING_MS);
|
|
496
|
+
const r = await sh(cmd, cwd, CAPTURE_CEILING_MS, { onReceipt: opts.onReceipt });
|
|
474
497
|
// ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
|
|
475
498
|
// ponytail: a capture that was itself killed records the ceiling as its "measurement", which
|
|
476
499
|
// scales the next ceiling up — the right direction for a suite that never finished once.
|
|
@@ -495,7 +518,7 @@ export async function captureBaseline(cwd, commands) {
|
|
|
495
518
|
console.error(`tickmarkr: baseline capture for "${name}" was killed at its ${CAPTURE_CEILING_MS}ms ceiling — `
|
|
496
519
|
+ `it recorded NO fingerprints, so nothing is forgiven and every gate will treat a pre-existing `
|
|
497
520
|
+ `failure as a fresh one. Raise the ceiling or shorten the command.`);
|
|
498
|
-
base.commands[name] = { ...invalidCaptureEntry(durationMs, "ceiling-kill"),
|
|
521
|
+
base.commands[name] = { ...invalidCaptureEntry(durationMs, "ceiling-kill"), ...await captureFileCount(cwd, cmd, raw) };
|
|
499
522
|
continue;
|
|
500
523
|
}
|
|
501
524
|
// Run 2137: the child exited and printed ordinary FAIL/AssertionError lines, so the gate-side
|
|
@@ -509,7 +532,7 @@ export async function captureBaseline(cwd, commands) {
|
|
|
509
532
|
+ `it recorded NO exit-code verdict and NO fingerprints, so nothing is forgiven for this command; `
|
|
510
533
|
+ `the measurement cannot distinguish a pre-existing failure from one caused by exhaustion. `
|
|
511
534
|
+ `First invalidating line: ${invalidatingLines[0]}`);
|
|
512
|
-
base.commands[name] = { ...invalidCaptureEntry(durationMs, "resource-exhaustion", invalidatingLines),
|
|
535
|
+
base.commands[name] = { ...invalidCaptureEntry(durationMs, "resource-exhaustion", invalidatingLines), ...await captureFileCount(cwd, cmd, raw) };
|
|
513
536
|
continue;
|
|
514
537
|
}
|
|
515
538
|
// OBS-966: a worker RPC timeout is infra even beside an all-green summary.
|
|
@@ -517,7 +540,7 @@ export async function captureBaseline(cwd, commands) {
|
|
|
517
540
|
const runnerVerdict = classifyRunnerOutput(raw, r.code);
|
|
518
541
|
if (runnerVerdict === "infra") {
|
|
519
542
|
console.error(`tickmarkr: baseline capture for "${name}" carries runner-infrastructure evidence — it recorded NO verdict; nothing is forgiven for this command`);
|
|
520
|
-
base.commands[name] = { ...invalidCaptureEntry(durationMs, "infra", withoutVitestEchoBlocks(combinedOutput).filter((line) => isInfraLine(line.replace(ANSI_RE, "")))),
|
|
543
|
+
base.commands[name] = { ...invalidCaptureEntry(durationMs, "infra", withoutVitestEchoBlocks(combinedOutput).filter((line) => isInfraLine(line.replace(ANSI_RE, "")))), ...await captureFileCount(cwd, cmd, raw) };
|
|
521
544
|
continue;
|
|
522
545
|
}
|
|
523
546
|
base.commands[name] = {
|
|
@@ -529,7 +552,7 @@ export async function captureBaseline(cwd, commands) {
|
|
|
529
552
|
missingCommand: missingConfiguredCommand(cmd, r),
|
|
530
553
|
durationMs,
|
|
531
554
|
...fileTiming(raw, durationMs),
|
|
532
|
-
|
|
555
|
+
...await captureFileCount(cwd, cmd, raw),
|
|
533
556
|
ceilingMs: effectiveCeilingMs({ durationMs }),
|
|
534
557
|
// T7: the world this measurement was taken in, so a later reader can ask whether its own world
|
|
535
558
|
// is the same one. Recorded from THIS command's own shell result, never re-derived here.
|
|
@@ -611,11 +634,17 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
|
|
|
611
634
|
}
|
|
612
635
|
const entry = baseline.commands[name];
|
|
613
636
|
const ceilingMs = effectiveCeilingMs(entry);
|
|
614
|
-
const r = await sh(cmd, cwd, ceilingMs
|
|
637
|
+
const r = await sh(cmd, cwd, ceilingMs, {
|
|
638
|
+
onReceipt: opts.onReceipt,
|
|
639
|
+
...(name === "build" ? { receiptAttribution: opts.taskBuildAttribution } : {}),
|
|
640
|
+
});
|
|
615
641
|
// T7: every verdict below carries the capacity ITS OWN command ran under, taken off the shell
|
|
616
642
|
// result rather than re-derived after the fact. The skip row above ran no command and therefore
|
|
617
643
|
// states no capacity — a row that never divided the machine must not claim that it did.
|
|
644
|
+
let recoveryBlocked;
|
|
618
645
|
const record = (g) => {
|
|
646
|
+
if (recoveryBlocked)
|
|
647
|
+
g = { ...g, meta: { ...g.meta, recoveryBlocked } };
|
|
619
648
|
const withReap = r.reapedGroup ? { ...g, meta: { ...g.meta, reapedGroup: true } } : g;
|
|
620
649
|
const withReapError = r.reapError ? { ...withReap, meta: { ...withReap.meta, reapError: r.reapError } } : withReap;
|
|
621
650
|
const withRerun = rerunOf ? {
|
|
@@ -697,19 +726,37 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
|
|
|
697
726
|
// retain the whole-output read so a repeated infra abort can never be baseline-forgiven as green.
|
|
698
727
|
const classification = classifyFreshRunnerOutput(entry, raw, r.code);
|
|
699
728
|
if (name === "test" && classification === "infra" && !rerunOf && !opts.infraRerun) {
|
|
700
|
-
const waitedMs = await waitForCalmWindow();
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
729
|
+
const waitedMs = await waitForCalmWindow(executionSignal());
|
|
730
|
+
if (opts.authorizeRetry && !calmWindowReady()) {
|
|
731
|
+
recoveryBlocked = "calm window unavailable within the existing wait ceiling";
|
|
732
|
+
}
|
|
733
|
+
else if (opts.authorizeRetry && !opts.authorizeRetry("infra")) {
|
|
734
|
+
recoveryBlocked = "infrastructure retry allowance exhausted or subject unavailable";
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
|
|
738
|
+
results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, infraRerun: provenance }));
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
704
741
|
}
|
|
705
742
|
// OBS-896: every fresh failure must be timeout-class, and the suite must take at least twice its
|
|
706
743
|
// own baseline measurement. The first read buys one calm rerun here, never a worker repair.
|
|
707
744
|
if (name === "test" && classification !== "infra" && failing.length && !rerunOf && !opts.infraRerun
|
|
708
745
|
&& hostStarved(failing.join("\n"), r.durationMs ?? 0, entry?.durationMs)) {
|
|
709
|
-
const waitedMs = await waitForCalmWindow();
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
746
|
+
const waitedMs = await waitForCalmWindow(executionSignal());
|
|
747
|
+
if (opts.authorizeRetry && !calmWindowReady()) {
|
|
748
|
+
recoveryBlocked = "calm window unavailable within the existing wait ceiling";
|
|
749
|
+
}
|
|
750
|
+
else if (opts.authorizeRetry && !opts.authorizeRetry("host-starved")) {
|
|
751
|
+
recoveryBlocked = "verification retry allowance exhausted or subject unavailable";
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
// Compatibility with OBS-896, not an infrastructure reclassification: a persistent
|
|
755
|
+
// timeout remains the ordinary conservative verdict after the single bounded remeasure.
|
|
756
|
+
const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
|
|
757
|
+
results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, rerunOf: provenance }));
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
713
760
|
}
|
|
714
761
|
if (classification === "infra") {
|
|
715
762
|
const evidence = failing.length
|
|
@@ -827,11 +874,14 @@ const DEFAULT_CALM = {
|
|
|
827
874
|
let calm = DEFAULT_CALM;
|
|
828
875
|
export function setCalmWindowForTests(over) { calm = { ...calm, ...over }; }
|
|
829
876
|
export function resetCalmWindowForTests() { calm = DEFAULT_CALM; }
|
|
830
|
-
export
|
|
877
|
+
export function calmWindowReady() { return calm.loadProvider() <= calm.calmLoad(); }
|
|
878
|
+
export async function waitForCalmWindow(signal) {
|
|
831
879
|
const started = Date.now();
|
|
832
880
|
while (calm.loadProvider() > calm.calmLoad() && Date.now() - started < calm.maxWaitMs) {
|
|
833
|
-
|
|
881
|
+
signal?.throwIfAborted();
|
|
882
|
+
await new Promise((resolve) => setTimeout(resolve, signal ? Math.min(calm.pollMs, 100) : calm.pollMs));
|
|
834
883
|
}
|
|
884
|
+
signal?.throwIfAborted();
|
|
835
885
|
return Date.now() - started;
|
|
836
886
|
}
|
|
837
887
|
/** Summary totals survive digit-normalized fingerprints and exclude test-owned echoed output. */
|