tickmarkr 2.5.5 → 2.5.6

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 (48) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/commands/approve.js +64 -3
  3. package/dist/cli/commands/doctor.d.ts +10 -0
  4. package/dist/cli/commands/fleet.js +4 -0
  5. package/dist/cli/commands/plan.js +20 -4
  6. package/dist/cli/commands/verify.js +102 -81
  7. package/dist/config/config.d.ts +11 -0
  8. package/dist/config/config.js +21 -12
  9. package/dist/config/fleet-overlay.js +55 -17
  10. package/dist/drivers/index.js +2 -1
  11. package/dist/drivers/orca.d.ts +21 -1
  12. package/dist/drivers/orca.js +209 -27
  13. package/dist/gates/baseline.d.ts +14 -3
  14. package/dist/gates/baseline.js +61 -14
  15. package/dist/gates/cache.d.ts +14 -13
  16. package/dist/gates/cache.js +17 -5
  17. package/dist/gates/llm.d.ts +3 -0
  18. package/dist/gates/llm.js +11 -0
  19. package/dist/gates/review.d.ts +28 -3
  20. package/dist/gates/review.js +106 -14
  21. package/dist/gates/run-gates.d.ts +6 -1
  22. package/dist/gates/run-gates.js +299 -101
  23. package/dist/gates/test-manifest.d.ts +30 -1
  24. package/dist/gates/test-manifest.js +113 -39
  25. package/dist/gates/test-reporter.js +14 -6
  26. package/dist/graph/graph.d.ts +2 -0
  27. package/dist/graph/graph.js +45 -1
  28. package/dist/run/consult.js +5 -4
  29. package/dist/run/daemon.d.ts +2 -0
  30. package/dist/run/daemon.js +480 -99
  31. package/dist/run/execution-budget.d.ts +25 -0
  32. package/dist/run/execution-budget.js +142 -0
  33. package/dist/run/git.d.ts +40 -0
  34. package/dist/run/git.js +89 -4
  35. package/dist/run/journal.d.ts +3 -1
  36. package/dist/run/journal.js +38 -16
  37. package/dist/run/lease.d.ts +44 -0
  38. package/dist/run/lease.js +226 -3
  39. package/dist/run/recovery.d.ts +8 -0
  40. package/dist/run/recovery.js +25 -0
  41. package/dist/run/repair-selection.d.ts +12 -0
  42. package/dist/run/repair-selection.js +56 -0
  43. package/dist/run/stall.d.ts +6 -1
  44. package/dist/run/stall.js +60 -3
  45. package/dist/tui/ink/fleet-app.d.ts +10 -2
  46. package/dist/tui/ink/fleet-app.js +33 -15
  47. package/package.json +2 -2
  48. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -23,7 +23,8 @@ function allowFormFromExclusions(universe, edited) {
23
23
  }
24
24
  // LEG2-T3 round 2 finding 1: every staged entry excludes what it NAMES — a bare model id every
25
25
  // adapter serving it, an identity its alias — never only an adapter id or an adapter:model key.
26
- const entries = [...edited.denyAdapters, ...edited.denyModels];
26
+ // OBS-1046: the staged allow complement is a reason of its own, beside the authored deny lists.
27
+ const entries = [...edited.denyAdapters, ...edited.denyModels, ...(edited.allowOut ?? [])];
27
28
  const adapters = [];
28
29
  const models = [];
29
30
  let excluded = false;
@@ -56,9 +57,12 @@ function residualDeny(universe, entries) {
56
57
  // list keeps its node. A staged entry the allow form cannot express as a membership key (outside the
57
58
  // probe universe, or a bare-model/identity spelling) is written verbatim too; canonical keys the
58
59
  // session added ride the allow form alone.
59
- function flatDenyAfterWrite(doc, path, after, universe) {
60
+ function authoredEntries(doc, path) {
60
61
  const node = doc.getIn(path, true);
61
- const authored = isSeq(node) ? node.items.flatMap((item) => (isScalar(item) ? [String(item.value)] : [])) : [];
62
+ return isSeq(node) ? node.items.flatMap((item) => (isScalar(item) ? [String(item.value)] : [])) : [];
63
+ }
64
+ function flatDenyAfterWrite(doc, path, after, universe) {
65
+ const authored = authoredEntries(doc, path);
62
66
  const canonical = (entry) => universe.some((row) => entry === row.adapter || row.models.some((m) => entry === `${row.adapter}:${m}`));
63
67
  const kept = authored.filter((entry) => after.includes(entry));
64
68
  const verbatim = sortedUnique(after.filter((entry) => !kept.includes(entry)
@@ -183,9 +187,12 @@ export function renderFleetOverlayWrite(priorBytes, write) {
183
187
  if (doc.errors.length)
184
188
  throw doc.errors[0];
185
189
  const { initial, edited } = write;
186
- const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
187
- || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
188
- if (denyChanged) {
190
+ // OBS-1046: each flat scope (and the allow complement) is compared on its own — a scope the
191
+ // session never edited keeps its node byte for byte, whatever its sibling did.
192
+ const changed = (before = [], after = []) => sortedUnique(before).join() !== sortedUnique(after).join();
193
+ const adaptersChanged = changed(initial.denyAdapters, edited.denyAdapters);
194
+ const modelsChanged = changed(initial.denyModels, edited.denyModels);
195
+ if (adaptersChanged || modelsChanged || changed(initial.allowOut, edited.allowOut)) {
189
196
  if (write.universe) {
190
197
  // Membership write: the allow form IS the fleet; deny adapters/models scopes are tombstoned
191
198
  // so a lower layer can never re-exclude behind the operator's back (workers untouched).
@@ -215,18 +222,44 @@ export function renderFleetOverlayWrite(priorBytes, write) {
215
222
  // Whole fleet in: no restriction to express — the allow block goes away entirely.
216
223
  deleteAt(doc, ["routing", "allow"]);
217
224
  }
218
- // Authored and non-canonical entries stay in deny (LEG2-T3 finding 4, round 2 finding 2); a
219
- // list left with nothing is tombstoned so a lower layer can never re-exclude behind the
220
- // operator's back (workers untouched).
221
- for (const [scope, after] of [["adapters", edited.denyAdapters], ["models", edited.denyModels]]) {
225
+ // Authored and non-canonical entries stay in deny (LEG2-T3 finding 4, round 2 finding 2).
226
+ // OBS-1046: only an EDITED scope is rewritten, and only when its bytes must change — an
227
+ // addition the allow form carries leaves the list (an explicit `[]` included) untouched; a
228
+ // scope the press CLEARED down to nothing is tombstoned so a lower layer can never
229
+ // re-exclude behind the operator's back (workers untouched).
230
+ // LEG2-T3 finding 4: an authored entry the edit admits (staged nowhere any more) must go
231
+ // even from a scope whose own set did not change, or the admitted channel stays excluded
232
+ // behind the preserved bytes.
233
+ const stagedAfter = new Set([...edited.denyAdapters, ...edited.denyModels, ...(edited.allowOut ?? [])]);
234
+ const admitted = [...initial.denyAdapters, ...initial.denyModels, ...(initial.allowOut ?? [])]
235
+ .filter((entry) => !stagedAfter.has(entry));
236
+ const scopes = [
237
+ ["adapters", initial.denyAdapters, edited.denyAdapters, adaptersChanged],
238
+ ["models", initial.denyModels, edited.denyModels, modelsChanged],
239
+ ];
240
+ for (const [scope, before, after, touched] of scopes) {
222
241
  const path = ["routing", "deny", scope];
242
+ const authored = authoredEntries(doc, path);
243
+ const stale = authored.some((entry) => admitted.includes(entry));
244
+ if (!touched && !stale)
245
+ continue;
223
246
  const remaining = flatDenyAfterWrite(doc, path, after, write.universe);
224
- setStringSequencePreservingComments(doc, path, remaining.length ? remaining : null);
247
+ if (remaining.length) {
248
+ if (remaining.join("\n") !== authored.join("\n"))
249
+ setStringSequencePreservingComments(doc, path, remaining);
250
+ }
251
+ else if (stale || before.some((entry) => !after.includes(entry))) {
252
+ setStringSequencePreservingComments(doc, path, null);
253
+ }
225
254
  }
226
255
  }
227
256
  else {
228
- setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
229
- setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
257
+ if (adaptersChanged) {
258
+ setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
259
+ }
260
+ if (modelsChanged) {
261
+ setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
262
+ }
230
263
  }
231
264
  }
232
265
  // OBS-994/FL-1: routing.deny.workers is a literal deny list, never a universe-derived
@@ -360,15 +393,19 @@ export function renderFleetOverlayWrite(priorBytes, write) {
360
393
  // operator reviews. commentString has no position context, but this writer owns the document:
361
394
  // scalar-trailing single-line comments (the only inline form fleet emits) are marked with a
362
395
  // private-use sentinel (the FIRST_TOUCH envelope precedent above), everything else renders
363
- // byte-identical to yaml's own stringifyComment.
396
+ // byte-identical to yaml's own stringifyComment. OBS-1046: a scalar parsed from the prior bytes
397
+ // keeps the exact whitespace it had before its hash sign (yaml itself emits one space, so the
398
+ // sentinel carries the rest); only a comment fleet authored gets the two-space style.
364
399
  visit(doc, (_key, node) => {
365
400
  if (isScalar(node) && typeof node.comment === "string" && !node.comment.includes("\n")) {
366
- node.comment = `${INLINE_COMMENT_SENTINEL}${node.comment}`;
401
+ const tail = node.range ? priorBytes.slice(node.range[1], node.range[2]) : "";
402
+ const gap = /^([ \t]+)#/.exec(tail)?.[1] ?? " ";
403
+ node.comment = `${INLINE_COMMENT_SENTINEL}${gap.slice(1)}#${node.comment}`;
367
404
  }
368
405
  });
369
406
  return doc.toString({
370
407
  commentString: (comment) => comment.startsWith(INLINE_COMMENT_SENTINEL)
371
- ? ` #${comment.slice(1)}`
408
+ ? comment.slice(1)
372
409
  : comment.replace(/^(?!$)(?: $)?/gm, "#"),
373
410
  // OBS-518: yaml's default pads flow collections (`[kimi]` → `[ kimi ]`), churning untouched
374
411
  // lines on the one confirmation surface an operator reviews. Hand-written overlays use the
@@ -384,7 +421,8 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
384
421
  const routing = { ...out.routing };
385
422
  let routingTouched = false;
386
423
  const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
387
- || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
424
+ || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join()
425
+ || sortedUnique(initial.allowOut ?? []).join() !== sortedUnique(edited.allowOut ?? []).join();
388
426
  if (denyChanged) {
389
427
  if (universe) {
390
428
  const form = allowFormFromExclusions(universe, edited);
@@ -15,7 +15,8 @@ const hostByDriver = new WeakMap();
15
15
  export function classifyHost(env = process.env) {
16
16
  if (env.HERDR_ENV === "1")
17
17
  return "herdr";
18
- if (env.TERM_PROGRAM === "Orca" && env.ORCA_TERMINAL_HANDLE !== undefined && env.ORCA_TERMINAL_HANDLE !== "")
18
+ // A whitespace-only handle is no handle: the narrator trims it and would refuse the split.
19
+ if (env.TERM_PROGRAM === "Orca" && (env.ORCA_TERMINAL_HANDLE ?? "").trim() !== "")
19
20
  return "orca";
20
21
  return "none";
21
22
  }
@@ -124,19 +124,28 @@ export declare function mapAgentState(term: Record<string, unknown>, tuiIdle: bo
124
124
  * is proven gone. Everything else — a reservation or claim with no bound pane, a failed cleanup, a
125
125
  * record another driver holds with a live observer — refuses and keeps the record exactly as it is.
126
126
  */
127
+ /** `placer` is the pid of the narrator that wrote the reservation: a reserved or claimed record is a
128
+ * normal intermediate state while that pid lives (another driver awaiting listing or bind) and a
129
+ * crash to recover only once it is dead. */
127
130
  type BoardRecord = WatchBoardOwner & {
128
131
  retired?: true;
129
132
  runtimeId?: string;
133
+ placer?: number;
130
134
  };
131
135
  /**
132
136
  * Whole-record compare-and-swap. The canonical path stays readable until commit: a mkdir lock
133
137
  * excludes other writers, then create-only `link`s the new inode (fails if anything exists) or
134
138
  * `rename`s the new file over the live path (POSIX atomic replace — readers see old or new, never
135
139
  * absence). A crash that leaves a `.tmp` or `.lock` does not drop the previous record.
140
+ * Each lock generation publishes its pid and nonce atomically in a symlink target. A holder that died
141
+ * between taking and releasing it is recovered at once (its lock is taken over in place); a live
142
+ * holder bounds the wait on injected time and is then refused with the record untouched. A legacy
143
+ * pid-less or malformed generation is recoverable too: a contender atomically creates the next owner
144
+ * generation, and release removes the directory only while that exact generation is still current.
136
145
  * ponytail: observeNamedRun (supervision.ts) renames without CAS. It cannot interleave with a swap
137
146
  * because the narrator writes nothing between reserve and claim, and no transition here swaps one.
138
147
  */
139
- export declare function casBoard(family: string, path: string, expected: string | undefined, next: BoardRecord): string;
148
+ export declare function casBoard(family: string, path: string, expected: string | undefined, next: BoardRecord, time?: OrcaTimeSource): Promise<string>;
140
149
  /**
141
150
  * The renderer hard-wraps long lines, paints margin chrome, and a cursor page boundary splits a
142
151
  * marker exactly like a wrap does. `parseWorkerResult` (src/adapters/prompt.ts) already de-wraps
@@ -146,6 +155,8 @@ export declare function casBoard(family: string, path: string, expected: string
146
155
  * never depends on this.
147
156
  */
148
157
  export declare function joinWrapped(raw: string): string;
158
+ /** The OBS-1011 add.1 capture: `status:"exited", tail:[], returnedLineCount 0` from a stream read. */
159
+ export declare function isBlindStreamPage(term: Record<string, unknown>): boolean;
149
160
  export interface OrcaDriverOpts {
150
161
  bin?: string;
151
162
  env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
@@ -242,6 +253,15 @@ export declare class OrcaDriver implements ExecutorDriver {
242
253
  private liveShowTerm;
243
254
  private tailText;
244
255
  private readPage;
256
+ /**
257
+ * OBS-1011 add.1 / OBS-1016: the captured incident shape — a stream page answering status exited
258
+ * with an empty tail on a terminal that accepted a send seconds earlier — is BLIND, not dead, when
259
+ * the same handle's show record on the same runtime reports connected and not orphaned (show carries
260
+ * no status field; none is demanded) and its screen read reports running. Then the rendered frame is
261
+ * the terminal's bytes. Anything less — disconnected, orphaned, another handle or runtime, a screen
262
+ * that is unavailable or exited — is refused as unavailable, exactly as the dead record would be.
263
+ */
264
+ private screenBehindBlindStream;
245
265
  /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
246
266
  private readScreen;
247
267
  /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
@@ -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
@@ -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,6 +154,9 @@ 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;
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[];
153
160
  export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;
154
161
  export interface VacuousOracleWarning {
155
162
  kind: "vacuous-oracle";
@@ -161,7 +168,10 @@ export declare function detectVacuousOracles(cwd: string, tasks: ReadonlyArray<{
161
168
  id: string;
162
169
  acceptance: AcceptanceItem[];
163
170
  }>): Promise<VacuousOracleWarning[]>;
164
- export declare function compareToBaseline(cwd: string, commands: Record<string, string>, baseline: Baseline, enabled: string[], opts?: {
171
+ export interface RetryOptions {
172
+ authorizeRetry?: (cause: "infra" | "host-starved") => boolean;
173
+ }
174
+ export declare function compareToBaseline(cwd: string, commands: Record<string, string>, baseline: Baseline, enabled: string[], opts?: RetryOptions & {
165
175
  rerunOf?: HostStarvedRerun;
166
176
  infraRerun?: HostStarvedRerun;
167
177
  selected?: readonly string[];
@@ -185,7 +195,8 @@ interface CalmWindow {
185
195
  }
186
196
  export declare function setCalmWindowForTests(over: Partial<CalmWindow>): void;
187
197
  export declare function resetCalmWindowForTests(): void;
188
- export declare function waitForCalmWindow(): Promise<number>;
198
+ export declare function calmWindowReady(): boolean;
199
+ export declare function waitForCalmWindow(signal?: AbortSignal): Promise<number>;
189
200
  /** Summary totals survive digit-normalized fingerprints and exclude test-owned echoed output. */
190
201
  export declare function runnerFileCount(raw: string): number | null;
191
202
  export declare function fileCountDeficit(entry: BaselineCommand | undefined, raw: string, opts?: {