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.
Files changed (69) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/prompt.js +21 -1
  3. package/dist/cli/commands/approve.js +69 -8
  4. package/dist/cli/commands/doctor.d.ts +10 -0
  5. package/dist/cli/commands/fleet.js +4 -0
  6. package/dist/cli/commands/plan.js +20 -4
  7. package/dist/cli/commands/status.js +95 -34
  8. package/dist/cli/commands/verify.js +108 -85
  9. package/dist/config/config.d.ts +11 -0
  10. package/dist/config/config.js +21 -12
  11. package/dist/config/fleet-overlay.js +55 -17
  12. package/dist/drivers/index.js +2 -1
  13. package/dist/drivers/orca.d.ts +21 -1
  14. package/dist/drivers/orca.js +209 -27
  15. package/dist/gates/baseline.d.ts +21 -5
  16. package/dist/gates/baseline.js +67 -17
  17. package/dist/gates/cache.d.ts +14 -13
  18. package/dist/gates/cache.js +17 -5
  19. package/dist/gates/llm.d.ts +3 -0
  20. package/dist/gates/llm.js +11 -0
  21. package/dist/gates/review.d.ts +28 -3
  22. package/dist/gates/review.js +118 -15
  23. package/dist/gates/run-gates.d.ts +10 -2
  24. package/dist/gates/run-gates.js +362 -108
  25. package/dist/gates/test-manifest.d.ts +33 -1
  26. package/dist/gates/test-manifest.js +132 -40
  27. package/dist/gates/test-reporter.js +20 -7
  28. package/dist/graph/graph.d.ts +4 -0
  29. package/dist/graph/graph.js +50 -1
  30. package/dist/run/activity.d.ts +28 -0
  31. package/dist/run/activity.js +194 -0
  32. package/dist/run/consult.js +5 -4
  33. package/dist/run/daemon.d.ts +11 -0
  34. package/dist/run/daemon.js +663 -128
  35. package/dist/run/execution-budget.d.ts +25 -0
  36. package/dist/run/execution-budget.js +142 -0
  37. package/dist/run/git.d.ts +46 -1
  38. package/dist/run/git.js +149 -12
  39. package/dist/run/journal.d.ts +23 -5
  40. package/dist/run/journal.js +98 -25
  41. package/dist/run/lease.d.ts +44 -0
  42. package/dist/run/lease.js +226 -3
  43. package/dist/run/operator-page-summary.d.ts +56 -0
  44. package/dist/run/operator-page-summary.js +68 -0
  45. package/dist/run/operator-summary.d.ts +69 -0
  46. package/dist/run/operator-summary.js +77 -0
  47. package/dist/run/protocol.d.ts +71 -0
  48. package/dist/run/protocol.js +32 -0
  49. package/dist/run/recovery.d.ts +8 -0
  50. package/dist/run/recovery.js +25 -0
  51. package/dist/run/repair-selection.d.ts +12 -0
  52. package/dist/run/repair-selection.js +56 -0
  53. package/dist/run/stall.d.ts +6 -1
  54. package/dist/run/stall.js +60 -3
  55. package/dist/tui/cockpit/board.d.ts +9 -0
  56. package/dist/tui/cockpit/board.js +10 -0
  57. package/dist/tui/cockpit/derive.d.ts +35 -0
  58. package/dist/tui/cockpit/derive.js +152 -10
  59. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  60. package/dist/tui/cockpit/evidence-view.js +42 -12
  61. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  62. package/dist/tui/cockpit/run-cockpit.js +95 -1
  63. package/dist/tui/cockpit/run-view.d.ts +37 -0
  64. package/dist/tui/cockpit/run-view.js +189 -2
  65. package/dist/tui/ink/fleet-app.d.ts +10 -2
  66. package/dist/tui/ink/fleet-app.js +33 -15
  67. package/package.json +2 -2
  68. package/skills/tickmarkr-overseer/SKILL.md +55 -3
  69. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -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 = Date.now() + 2_000;
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
- if (Date.now() >= deadline)
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
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
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
- rmSync(lock, { recursive: true, force: true });
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
- // Drain to the current stream cursor before sending. A message already present makes the
1242
- // proof ambiguous, so decline rather than reporting delivery from old scrollback.
1243
- const before = await this.sweep(st);
1244
- if (before.includes(message) || joinWrapped(before).includes(message))
1245
- return false;
1246
- await this.sendText(st, message);
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 after = await this.sweep(st);
1250
- if (after.includes(message) || joinWrapped(after).includes(message))
1251
- return true;
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
- throw new OrcaError("split", `Orca narrator placement remains unresolved for ${name} (${state}, no bound pane); ${kept}`, "");
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, but the refused placement's record stays as it is.
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
@@ -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 reported by the runner summary; null when unavailable. */
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
- export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;
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 declare function compareToBaseline(cwd: string, commands: Record<string, string>, baseline: Baseline, enabled: string[], opts?: {
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 waitForCalmWindow(): Promise<number>;
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?: {
@@ -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
- export async function captureBaseline(cwd, commands) {
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"), fileCount: runnerFileCount(raw) };
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), fileCount: runnerFileCount(raw) };
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, "")))), fileCount: runnerFileCount(raw) };
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
- fileCount: runnerFileCount(raw),
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
- const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
702
- results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, infraRerun: provenance }));
703
- continue;
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
- const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
711
- results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, rerunOf: provenance }));
712
- continue;
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 async function waitForCalmWindow() {
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
- await new Promise((resolve) => setTimeout(resolve, calm.pollMs));
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. */