tickmarkr 2.2.1 → 2.4.0

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 (81) hide show
  1. package/README.md +10 -9
  2. package/dist/adapters/catalog-remote.d.ts +1 -4
  3. package/dist/adapters/catalog-remote.js +52 -42
  4. package/dist/adapters/catalog.js +5 -3
  5. package/dist/adapters/claude-code.d.ts +1 -1
  6. package/dist/adapters/claude-code.js +8 -5
  7. package/dist/adapters/model-lints.d.ts +9 -5
  8. package/dist/adapters/model-lints.js +56 -15
  9. package/dist/adapters/model-windows.js +11 -0
  10. package/dist/adapters/prompt.js +1 -0
  11. package/dist/adapters/qwen.d.ts +5 -0
  12. package/dist/adapters/qwen.js +153 -0
  13. package/dist/adapters/types.d.ts +21 -1
  14. package/dist/adapters/types.js +43 -2
  15. package/dist/cli/commands/approve.js +5 -4
  16. package/dist/cli/commands/beat.js +7 -4
  17. package/dist/cli/commands/compile.js +32 -6
  18. package/dist/cli/commands/doctor.d.ts +9 -4
  19. package/dist/cli/commands/doctor.js +87 -13
  20. package/dist/cli/commands/fleet.d.ts +4 -0
  21. package/dist/cli/commands/fleet.js +53 -14
  22. package/dist/cli/commands/init.js +36 -21
  23. package/dist/cli/commands/plan.js +45 -7
  24. package/dist/cli/commands/report.js +37 -1
  25. package/dist/cli/commands/status.d.ts +1 -0
  26. package/dist/cli/commands/status.js +45 -1
  27. package/dist/cli/commands/verify.d.ts +6 -0
  28. package/dist/cli/commands/verify.js +145 -25
  29. package/dist/cli/index.d.ts +1 -1
  30. package/dist/cli/index.js +2 -2
  31. package/dist/compile/collateral.d.ts +2 -9
  32. package/dist/compile/collateral.js +17 -18
  33. package/dist/compile/index.d.ts +4 -1
  34. package/dist/compile/index.js +41 -7
  35. package/dist/compile/native.d.ts +4 -2
  36. package/dist/compile/native.js +58 -9
  37. package/dist/compile/ownership.js +34 -9
  38. package/dist/config/config.d.ts +1 -0
  39. package/dist/config/config.js +52 -6
  40. package/dist/drivers/herdr.d.ts +1 -0
  41. package/dist/drivers/herdr.js +11 -1
  42. package/dist/drivers/index.d.ts +6 -0
  43. package/dist/drivers/index.js +19 -4
  44. package/dist/drivers/orca.d.ts +35 -1
  45. package/dist/drivers/orca.js +260 -20
  46. package/dist/drivers/subprocess.d.ts +3 -3
  47. package/dist/drivers/subprocess.js +16 -9
  48. package/dist/drivers/types.d.ts +12 -0
  49. package/dist/gates/baseline.d.ts +2 -0
  50. package/dist/gates/baseline.js +47 -11
  51. package/dist/gates/llm.d.ts +6 -0
  52. package/dist/gates/llm.js +25 -9
  53. package/dist/gates/review.d.ts +7 -3
  54. package/dist/gates/review.js +61 -22
  55. package/dist/gates/run-gates.d.ts +5 -2
  56. package/dist/gates/run-gates.js +50 -26
  57. package/dist/gates/verdict-cause.d.ts +6 -2
  58. package/dist/gates/verdict-cause.js +8 -4
  59. package/dist/route/preference.d.ts +4 -0
  60. package/dist/route/preference.js +40 -0
  61. package/dist/route/router.js +15 -2
  62. package/dist/run/consult.d.ts +1 -0
  63. package/dist/run/consult.js +39 -8
  64. package/dist/run/daemon.d.ts +16 -0
  65. package/dist/run/daemon.js +345 -74
  66. package/dist/run/git.d.ts +3 -0
  67. package/dist/run/git.js +40 -5
  68. package/dist/run/journal.d.ts +15 -2
  69. package/dist/run/journal.js +73 -12
  70. package/dist/run/supervision.d.ts +6 -0
  71. package/dist/run/supervision.js +29 -1
  72. package/dist/tui/ink/fleet-app.d.ts +4 -0
  73. package/dist/tui/ink/fleet-app.js +45 -16
  74. package/dist/tui/ink/init-app.js +4 -4
  75. package/package.json +59 -1
  76. package/skills/tickmarkr-overseer/SKILL.md +77 -18
  77. package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
  78. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
  79. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
  80. package/skills/tickmarkr-overseer/scripts/watch-context.sh +33 -7
  81. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
@@ -4,6 +4,7 @@ import { shq } from "../adapters/types.js";
4
4
  import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { stringify } from "yaml";
8
9
  import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
9
10
  import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
@@ -23,9 +24,9 @@ import { GATE_NAMES } from "../graph/schema.js";
23
24
  import { distFingerprint } from "../cli/commands/version.js";
24
25
  import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
25
26
  import { runEnvironment } from "./environment.js";
26
- import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
27
+ import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, SUITE_PARENT_ENV, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
27
28
  import { runInteractiveSeed } from "./interactive-seed.js";
28
- import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, renderStructuredReviewFinding, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
29
+ import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
29
30
  import { isDiffCapPark } from "../gates/review.js";
30
31
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
31
32
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
@@ -69,12 +70,13 @@ export function resolveRunMode(repoRoot, opts = {}) {
69
70
  return { cfg: resolved.cfg, mode: resolved.mode, source, ...(conflict ? { conflict } : {}) };
70
71
  }
71
72
  // T14: the events that prove an approval was ENACTED — narrowly causal, never merely subsequent.
72
- // An ordinary approval (no release, attempt-cap, recheck, review-upheld) all buy a WORKER, so the
73
- // proof is a dispatch. Generic terminal events are deliberately NOT proof: an approved human-gate
73
+ // Ordinary, attempt-cap and review-upheld approvals buy a WORKER, so their proof is a dispatch;
74
+ // recheck has its own battery enactment below. Generic terminal events are deliberately NOT proof: an approved human-gate
74
75
  // task can fail in routing before task-dispatch, and the catch appends task-failed — treating that
75
76
  // as enactment reports "complete" over an approval that never ran (the exact silent-completion this
76
77
  // task exists to kill).
77
- const DISPATCH_ENACTMENT = new Set(["task-dispatch", "repair-dispatch"]);
78
+ const DISPATCH_ENACTMENT = new Set(["task-dispatch", "repair-dispatch", "resume-restore"]);
79
+ const RECHECK_ENACTMENT = "recheck-battery";
78
80
  // The ONE approval that enacts without buying a worker: GATE_SATISFIED_RELEASE resumes from the
79
81
  // persisted task branch after the approved gate (execTask's satisfiedGate branch), whose first act
80
82
  // for the task is worktree-recreation. That event is causal for this path, not incidental.
@@ -97,9 +99,13 @@ export function outstandingApprovals(events) {
97
99
  newest.set(e.taskId, i); });
98
100
  return [...newest]
99
101
  .filter(([taskId, i]) => {
100
- const noWorker = events[i].data.release === GATE_SATISFIED_RELEASE;
102
+ const release = events[i].data.release;
103
+ const noWorker = release === GATE_SATISFIED_RELEASE;
104
+ const recheck = release === RECHECK_RELEASE;
101
105
  return !events.slice(i + 1).some((e) => e.taskId === taskId
102
- && (DISPATCH_ENACTMENT.has(e.event) || (noWorker && e.event === GATE_SATISFIED_ENACTMENT)));
106
+ && (DISPATCH_ENACTMENT.has(e.event)
107
+ || (noWorker && e.event === GATE_SATISFIED_ENACTMENT)
108
+ || (recheck && e.event === RECHECK_ENACTMENT)));
103
109
  })
104
110
  .map(([taskId]) => taskId)
105
111
  .sort();
@@ -132,7 +138,8 @@ export function formatSummary(s) {
132
138
  * run id (cli/commands/status.ts positionalRunId), so naming it here is what stops the board from
133
139
  * following the newest journal in a repo that already carries a second, newer run — a board showing
134
140
  * the wrong run is a recorded incident (skills/tickmarkr-overseer/SKILL.md). */
135
- export const watchCommand = (runId) => `tickmarkr status --watch ${runId}`;
141
+ export const daemonEntrypoint = fileURLToPath(new URL("../cli/index.js", import.meta.url));
142
+ export const watchCommand = (runId) => `${shq(process.execPath)} ${shq(daemonEntrypoint)} status --watch ${shq(runId)}`;
136
143
  const MAX_ATTEMPTS = 10; // ponytail: hard cap so a pathological ladder can never loop forever
137
144
  // v1.85 T3 (retry economics): two repairs per engagement, then the fresh ladder. A repair re-uses the
138
145
  // findings and the landed diff instead of re-buying onboarding; when two of them have not closed the
@@ -165,6 +172,19 @@ const isOracleFailure = (g) => g.details.startsWith("oracle failed:");
165
172
  */
166
173
  export const gateSatisfied = (g) => (g.pass || g.meta?.skipped === true) && g.meta?.infra !== true;
167
174
  const gateFailed = (g) => !gateSatisfied(g);
175
+ const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+code)?\s+(?:137|143))\b/i;
176
+ const FAILURE_IDENTITY_RE = /\b(?:AssertionError|FAIL\s+\S|Tests?\s+\d+\s+failed|expected\s+.+\s+to\s+)\b/i;
177
+ /** A signalled test runner with no failure identity produced no verdict about HEAD. Baseline owns the
178
+ * ordinary infra vocabulary; this daemon-only rider handles the signal-shaped non-verdict before its
179
+ * journal row and repair accounting are written. */
180
+ function classifySignalOnlyTest(g) {
181
+ if (g.gate !== "test" || g.pass || g.meta?.infra === true || !SIGNAL_EXIT_RE.test(g.details))
182
+ return;
183
+ const named = Array.isArray(g.meta?.failingTests) && g.meta.failingTests.length > 0;
184
+ if (named || FAILURE_IDENTITY_RE.test(g.details))
185
+ return;
186
+ g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false, kind: "signal-exit" };
187
+ }
168
188
  // v1.85 T3: the gates whose failure IS a deterministic measurement — a machine re-ran a command over a
169
189
  // tree and printed the same bytes. Those are the failures the fingerprint cap governs (the ruling names
170
190
  // it a "deterministic-gate" cap): a third identical answer to a question already answered twice is the
@@ -195,7 +215,7 @@ function narrowRepairBattery(failing) {
195
215
  }
196
216
  // v2.0 T2 (OBS-554): the measurement keys run-gates stamps on a GateResult, lifted verbatim onto the
197
217
  // gate row. One list, one lift — both onGate sites record through the same helper.
198
- const GATE_TELEMETRY_KEYS = ["durationMs", "load1Start", "load1End", "selectedDurationMs", "fullDurationMs", "invocations"];
218
+ const GATE_TELEMETRY_KEYS = ["durationMs", "load1Start", "load1End", "load1Max", "load1Mean", "selectedDurationMs", "fullDurationMs", "invocations"];
199
219
  const gateMeasurement = (meta = {}) => Object.fromEntries(GATE_TELEMETRY_KEYS.filter((k) => meta[k] !== undefined).map((k) => [k, meta[k]]));
200
220
  /**
201
221
  * T4 (OBS-265): the journal with the review objections a round did NOT hinge on removed. Judge and
@@ -270,6 +290,13 @@ function approvedReviewRoundCeiling(events, taskId) {
270
290
  return undefined;
271
291
  }
272
292
  const BLOCKED_POLL_MS = 30_000; // between trailer-wait slices, check whether the pane is blocked on a prompt
293
+ export const SUITE_POLL_MS = 250;
294
+ // ponytail: one fixed ceiling on the live-suite wait; the baseline's test ceilingMs is the upgrade path
295
+ export const SUITE_WAIT_CEILING_MS = 600_000;
296
+ let suiteWaitCeilingMs = SUITE_WAIT_CEILING_MS;
297
+ export const setSuiteWaitCeilingForTests = (ms) => { suiteWaitCeilingMs = ms; };
298
+ export const resetSuiteWaitCeilingForTests = () => { suiteWaitCeilingMs = SUITE_WAIT_CEILING_MS; };
299
+ export const APPROVAL_POLL_MS = 250;
273
300
  const PROVIDER_DEATH_REQUEUE_CAP = 2; // v1.46 T1: requeue same assignment twice, then fall through to the normal ladder
274
301
  const PROVIDER_DEATH_BACKOFF_MS = 500; // short backoff before provider-death requeue
275
302
  const NO_TRAILER_DEMOTION_STREAK = 2; // OBS-57: consecutive no-trailer windows demote a channel for the rest of the run
@@ -319,6 +346,7 @@ export function resetNudgeTimingForTests() {
319
346
  // diff merely quotes "rate limit" keeps working undisturbed).
320
347
  const QUOTA_BANNER_SILENT_MS = 3 * 60_000;
321
348
  let quotaBannerSilentMs = QUOTA_BANNER_SILENT_MS;
349
+ const WORKER_STARTUP_FAILURE_RE = /(?:not logged in|authentication(?:[ _-](?:required|failed|error))|401 unauthorized|model[_ -]?not[_ -]?found|(?:model|deployment)[^\n]{0,80}(?:not found|does not exist|is unavailable)|(?:404|not[_ -]?found)[^\n]{0,80}(?:model|deployment))/i;
322
350
  /** Test seam — shrink the quota-banner silence gate without minute-long sleeps. */
323
351
  export function setQuotaBannerSilentMsForTests(ms) {
324
352
  quotaBannerSilentMs = ms;
@@ -558,6 +586,110 @@ async function observeWorkerProcessTree(marker, cwd) {
558
586
  }
559
587
  return tree.size === 0 ? "empty" : "running";
560
588
  }
589
+ const SUITE_COMMAND_RE = /(?:^|[\s/])(vitest(?:\.mjs)?|jest|mocha)(?:[\s/]|$)|\bnpm(?:\s+run)?\s+test\b/i;
590
+ function processCwd(pid) {
591
+ try {
592
+ return realpathSync(readlinkSync(`/proc/${pid}/cwd`));
593
+ }
594
+ catch { /* Darwin has no /proc */ }
595
+ try {
596
+ const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
597
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
598
+ });
599
+ const path = out.split("\n").find((line) => line.startsWith("n"))?.slice(1);
600
+ return path ? realpathSync(path) : undefined;
601
+ }
602
+ catch {
603
+ return undefined;
604
+ }
605
+ }
606
+ function processSuiteParent(pid) {
607
+ try {
608
+ const env = readFileSync(`/proc/${pid}/environ`, "utf8").split("\0");
609
+ const value = env.find((entry) => entry.startsWith(`${SUITE_PARENT_ENV}=`))?.slice(SUITE_PARENT_ENV.length + 1);
610
+ return value && /^\d+$/.test(value) ? Number(value) : undefined;
611
+ }
612
+ catch { /* Darwin has no /proc process environments */ }
613
+ try {
614
+ const out = execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], {
615
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
616
+ });
617
+ const value = new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(out)?.[1];
618
+ return value ? Number(value) : undefined;
619
+ }
620
+ catch {
621
+ return undefined;
622
+ }
623
+ }
624
+ const pathAtOrBelow = (root, candidate) => {
625
+ const rel = relative(root, candidate);
626
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
627
+ };
628
+ /** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership
629
+ * rules remain testable on hosts that forbid process inspection; production supplies cwd and the
630
+ * inherited TICKMARKR_SUITE_PARENT marker from the process itself. */
631
+ export function countLiveSuites(snapshot, repoRoot, daemonPid = process.pid, cwdForPid = processCwd, suiteParentForPid = processSuiteParent) {
632
+ const rows = [];
633
+ for (const line of snapshot.split("\n")) {
634
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
635
+ if (match && !match[3].startsWith("Z")) {
636
+ rows.push({ pid: Number(match[1]), ppid: Number(match[2]), command: match[4] });
637
+ }
638
+ }
639
+ const byPid = new Map(rows.map((row) => [row.pid, row]));
640
+ const ancestors = new Set();
641
+ for (let pid = daemonPid; pid && !ancestors.has(pid); pid = byPid.get(pid)?.ppid ?? 0)
642
+ ancestors.add(pid);
643
+ const descendants = new Set([daemonPid]);
644
+ for (let grew = true; grew;) {
645
+ grew = false;
646
+ for (const row of rows)
647
+ if (!descendants.has(row.pid) && descendants.has(row.ppid)) {
648
+ descendants.add(row.pid);
649
+ grew = true;
650
+ }
651
+ }
652
+ const root = realpathSync(repoRoot);
653
+ // OBS-889 (run 3372): a bare-codex worker carries its whole prompt in argv and the prompt names the
654
+ // runner ("…as a vitest test whose…"), so a finished interactive worker counted as a live suite and
655
+ // held a task's gates for 9 min 46 s. A runner is named in a command's HEAD — `node <bin>`,
656
+ // `npm test`, `npx vitest`, `sh -c npm test` — never 140 KB into it: read the first four tokens only.
657
+ const candidates = rows.filter((row) => !ancestors.has(row.pid) && SUITE_COMMAND_RE.test(row.command.split(/\s+/, 4).join(" ")));
658
+ const attributable = new Set(candidates.filter((row) => {
659
+ if (descendants.has(row.pid))
660
+ return true;
661
+ const cwd = cwdForPid(row.pid);
662
+ if (cwd !== undefined && pathAtOrBelow(root, cwd))
663
+ return true;
664
+ const suiteParent = suiteParentForPid(row.pid);
665
+ if (suiteParent === daemonPid)
666
+ return true;
667
+ const parentCwd = suiteParent === undefined ? undefined : cwdForPid(suiteParent);
668
+ return parentCwd !== undefined && pathAtOrBelow(root, parentCwd);
669
+ }).map((row) => row.pid));
670
+ // npm/npx + vitest + pool workers are one suite. Count only attributable suite processes with no
671
+ // attributable suite ancestor, while still following ordinary non-suite parents between them.
672
+ return [...attributable].filter((pid) => {
673
+ for (let parent = byPid.get(pid)?.ppid; parent; parent = byPid.get(parent)?.ppid) {
674
+ if (attributable.has(parent))
675
+ return false;
676
+ }
677
+ return true;
678
+ }).length;
679
+ }
680
+ let liveSuiteCountForTests;
681
+ export const setLiveSuiteCountForTests = (probe) => {
682
+ liveSuiteCountForTests = probe;
683
+ };
684
+ export const resetLiveSuiteCountForTests = () => { liveSuiteCountForTests = undefined; };
685
+ /** Live full-suite roots attributable to this repository or this daemon. Ancestors are excluded so
686
+ * a daemon invoked by vitest does not wait on its own test harness forever. */
687
+ export async function liveSuiteCount(repoRoot) {
688
+ if (liveSuiteCountForTests)
689
+ return liveSuiteCountForTests(repoRoot);
690
+ const snapshot = await shGit("ps -Aww -o pid=,ppid=,state=,command=", repoRoot, 15_000);
691
+ return snapshot.code === 0 ? countLiveSuites(snapshot.stdout, repoRoot) : 0;
692
+ }
561
693
  const OBSERVE_CHUNK_BYTES = 64 * 1024;
562
694
  const OBSERVE_BUDGET_BYTES = 256 * 1024 * 1024;
563
695
  let observeBudgetBytes = OBSERVE_BUDGET_BYTES;
@@ -1099,6 +1231,8 @@ export async function runDaemon(repoRoot, opts = {}) {
1099
1231
  const trackedDriver = {
1100
1232
  id: driver.id,
1101
1233
  interactive: driver.interactive,
1234
+ ...(driver.readSource ? { readSource: driver.readSource } : {}),
1235
+ ...(driver.describe ? { describe: driver.describe.bind(driver) } : {}),
1102
1236
  slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
1103
1237
  run: (s, cmd) => driver.run(s, cmd),
1104
1238
  waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
@@ -1107,10 +1241,25 @@ export async function runDaemon(repoRoot, opts = {}) {
1107
1241
  read: (s, n) => driver.read(s, n),
1108
1242
  ...(driver.sendKey ? { sendKey: driver.sendKey.bind(driver) } : {}),
1109
1243
  ...(driver.nudge ? { nudge: driver.nudge.bind(driver) } : {}),
1244
+ ...(driver.narrator ? { narrator: async (cwd, command, id) => {
1245
+ const s = await driver.narrator(cwd, command, id);
1246
+ liveSlots.add(s);
1247
+ return s;
1248
+ } } : {}),
1249
+ ...(driver.project ? { project: driver.project.bind(driver) } : {}),
1250
+ ...(driver.reconcile ? { reconcile: driver.reconcile.bind(driver) } : {}),
1110
1251
  notify: (m, o) => driver.notify(m, o),
1111
1252
  close: closeSlot,
1112
1253
  worktree: (r, b, base) => driver.worktree(r, b, base),
1113
1254
  };
1255
+ const absentCapabilities = new Set();
1256
+ const noteCapabilityAbsent = (capability) => {
1257
+ if (absentCapabilities.has(capability)
1258
+ || journal.read().some((event) => event.event === "driver-capability-absent" && event.data.capability === capability))
1259
+ return;
1260
+ absentCapabilities.add(capability);
1261
+ journal.append("driver-capability-absent", undefined, { driver: driver.id, capability });
1262
+ };
1114
1263
  // Termination (SIGINT/SIGTERM): record the daemon-controlled exit before closing every live slot,
1115
1264
  // reconcile owned panes against an EMPTY
1116
1265
  // desired set (herdr panes not in memory; panesToClose spares foreign names, watch panes, and
@@ -1135,6 +1284,9 @@ export async function runDaemon(repoRoot, opts = {}) {
1135
1284
  catch (error) {
1136
1285
  console.error(`tickmarkr ${runId}: deliberate exit cause could not be journalled (${error instanceof Error ? error.message : String(error)})`);
1137
1286
  }
1287
+ // Stop the scheduler before the first awaited retirement. Otherwise a freed slot can fund a
1288
+ // new attempt while this reaper is still closing the old ones.
1289
+ abortRun(new Error(`terminated by ${sig}`));
1138
1290
  if (cfg.visibility.keepPanes !== "forever") {
1139
1291
  for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
1140
1292
  try {
@@ -1149,13 +1301,15 @@ export async function runDaemon(repoRoot, opts = {}) {
1149
1301
  }
1150
1302
  releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
1151
1303
  }
1152
- abortRun(new Error(`terminated by ${sig}`));
1153
1304
  exit(sig === "SIGINT" ? 130 : 143);
1154
1305
  })();
1155
1306
  };
1156
1307
  process.on("SIGINT", onTermination);
1157
1308
  process.on("SIGTERM", onTermination);
1158
1309
  journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
1310
+ const reviewHistory = journal.read()
1311
+ .filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
1312
+ .map((event) => event.data.reviewer);
1159
1313
  // Capture this before this observer appends run-resume. A prior run-end belongs to a completed
1160
1314
  // lifecycle and must use the ordinary resume/redispatch rules; only an interrupted live attempt
1161
1315
  // owns reusable measurements or unclean-death residue.
@@ -1185,6 +1339,20 @@ export async function runDaemon(repoRoot, opts = {}) {
1185
1339
  const approved = new Set(approvalStartupEvents.filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
1186
1340
  let approvalSweepCursor = approvalStartupEvents.length;
1187
1341
  const commands = detectGateCommands(repoRoot, cfg);
1342
+ const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
1343
+ let watchSlot;
1344
+ const openBoard = async () => {
1345
+ if (!trackedDriver.narrator) {
1346
+ noteCapabilityAbsent("narrator");
1347
+ return;
1348
+ }
1349
+ try {
1350
+ watchSlot = await trackedDriver.narrator(repoRoot, watchCommand(runId), runId);
1351
+ }
1352
+ catch (error) {
1353
+ journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
1354
+ }
1355
+ };
1188
1356
  let baseRef;
1189
1357
  let baseline;
1190
1358
  // Phase 46 (RES-01/RES-02): the resume-state map is built ONCE here so execTask closes over it.
@@ -1252,9 +1420,12 @@ export async function runDaemon(repoRoot, opts = {}) {
1252
1420
  ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
1253
1421
  ...(opts.retryFailed ? { retryFailed: true } : {}),
1254
1422
  });
1423
+ await openBoard();
1255
1424
  }
1256
1425
  else {
1257
1426
  baseRef = await gitHead(repoRoot);
1427
+ journal.append("baseline-start", undefined, { baseRef, commands });
1428
+ await openBoard();
1258
1429
  baseline = await captureBaseline(repoRoot, commands);
1259
1430
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
1260
1431
  writeFileSync(join(journal.dir, "graph.json"), readFileSync(join(tickmarkrDir(repoRoot), "graph.json")));
@@ -1300,21 +1471,6 @@ export async function runDaemon(repoRoot, opts = {}) {
1300
1471
  const collateral = pinnedCollateral ?? collateralHits(graph.tasks, repoRoot);
1301
1472
  if (!pinnedCollateral)
1302
1473
  writeFileSync(collateralPath, JSON.stringify(Object.fromEntries(collateral), null, 2));
1303
- // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
1304
- // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
1305
- // a failed-to-open or later-dead watch pane never affects the run.
1306
- // OBS-103: hold the returned slot — narrator() returns the run's board under its canonical owned
1307
- // name whichever way it got there (the herdr driver retires a survivor it finds and re-splits, so
1308
- // the live command is run-bound), and the run-end sweep below retires it by that name regardless
1309
- // of which daemon instance split the pane.
1310
- const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
1311
- let watchSlot;
1312
- try {
1313
- watchSlot = await driver.narrator?.(repoRoot, watchCommand(runId), runId);
1314
- }
1315
- catch (error) {
1316
- journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
1317
- }
1318
1474
  const intWt = await ensureIntegration(repoRoot, branch, baseRef);
1319
1475
  // v1.1 visibility: role-named slots; panes persist per keepPanes (attempt = v1 close-after-harvest)
1320
1476
  const keepOpen = cfg.visibility.keepPanes !== "attempt";
@@ -1353,7 +1509,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1353
1509
  if (watchSlot && watchSlot.name === watchName && !desired.has(watchName)) {
1354
1510
  const w = watchSlot;
1355
1511
  watchSlot = undefined;
1356
- await driver.close(w);
1512
+ await trackedDriver.close(w);
1357
1513
  }
1358
1514
  }
1359
1515
  catch {
@@ -1386,6 +1542,48 @@ export async function runDaemon(repoRoot, opts = {}) {
1386
1542
  mergeChain = next.catch(() => undefined);
1387
1543
  return next;
1388
1544
  };
1545
+ // OBS-829/OBS-854: one full-suite verdict round at a time in this run, and do not begin beside an
1546
+ // externally live suite attributable to this repository. The process scan catches nested scratch
1547
+ // suites through daemon parentage even after cwd stops naming a worktree.
1548
+ let suiteChain = Promise.resolve();
1549
+ let suitePending = 0;
1550
+ const withSuiteWindow = async (taskId, enabled, run) => {
1551
+ if (!enabled)
1552
+ return run();
1553
+ const previous = suiteChain;
1554
+ let release;
1555
+ suiteChain = new Promise((resolve) => { release = resolve; });
1556
+ const queued = suitePending++ > 0;
1557
+ if (queued) {
1558
+ const count = Math.max(1, await liveSuiteCount(repoRoot));
1559
+ journal.append("suite-wait", taskId, { count });
1560
+ }
1561
+ await previous;
1562
+ try {
1563
+ let lastCount = -1;
1564
+ const startedAt = Date.now();
1565
+ for (;;) {
1566
+ const count = await liveSuiteCount(repoRoot);
1567
+ if (count === 0)
1568
+ break;
1569
+ // OBS-889: a census that never reaches zero held T5's gates with no row and no end. Proceed at
1570
+ // the ceiling, flagged: the verdict that follows was produced beside whatever is still counted.
1571
+ if (Date.now() - startedAt >= suiteWaitCeilingMs) {
1572
+ journal.append("suite-wait-ceiling", taskId, { count, waitedMs: Date.now() - startedAt });
1573
+ break;
1574
+ }
1575
+ if (count !== lastCount)
1576
+ journal.append("suite-wait", taskId, { count });
1577
+ lastCount = count;
1578
+ await new Promise((wake) => setTimeout(wake, SUITE_POLL_MS));
1579
+ }
1580
+ return await run();
1581
+ }
1582
+ finally {
1583
+ suitePending--;
1584
+ release();
1585
+ }
1586
+ };
1389
1587
  // gateFails/consults are execTask-scoped counters passed in so a park row is a rich verified-failure
1390
1588
  // observation (e.g. ladder-exhausted + gateFails:4); every task-human row has a closed kind, never prose alone.
1391
1589
  const gateFailApprovalReason = (taskId, identity, includeUphold = false) => {
@@ -1644,6 +1842,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1644
1842
  // `test` row whose scope no consumer could recover.
1645
1843
  ...(Array.isArray(g.meta?.selectedTests) ? { selectedTests: g.meta.selectedTests } : {}),
1646
1844
  ...(g.meta?.fullSuite === true ? { fullSuite: true } : {}),
1845
+ ...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
1846
+ ...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
1847
+ reviewer: g.meta.reviewer,
1848
+ ...(typeof g.meta.vendor === "string" ? { vendor: g.meta.vendor } : {}),
1849
+ ...(typeof g.meta.provider === "string" ? { provider: g.meta.provider } : {}),
1850
+ ...(typeof g.meta.rotationSeat === "number" ? { rotationSeat: g.meta.rotationSeat } : {}),
1851
+ ...(typeof g.meta.timeoutMs === "number" ? { timeoutMs: g.meta.timeoutMs } : {}),
1852
+ } : {}),
1647
1853
  // A finding's path is its own evidence path. Do not pass task scope here: a declaration says
1648
1854
  // where work is allowed, not where this verdict found the defect.
1649
1855
  ...(blocking ? {
@@ -1665,9 +1871,9 @@ export async function runDaemon(repoRoot, opts = {}) {
1665
1871
  // T7: the capacity the gate's own command child ran under, lifted verbatim from the result
1666
1872
  // the battery produced — read where the shell built that child's environment, never
1667
1873
  // re-derived from the run's own budget, which would answer a different number than the
1668
- // operator's export did. It is this row's only COMPARABLE identity: the two load samples
1669
- // beside it are endpoint reads of a gate whose interior neither of them saw, so no reader
1670
- // compares them, and matching capacity never claims the machine was calm. A gate that ran
1874
+ // operator's export did. It is this row's only COMPARABLE identity: the load samples describe
1875
+ // contention while capacity describes how the command divided the machine, and matching
1876
+ // capacity never claims the machine was calm. A gate that ran
1671
1877
  // no command carries nothing — it divided nothing.
1672
1878
  //
1673
1879
  // `dirtiedBy` is the one hole in that lift: run-gates REPLACES a green battery verdict with
@@ -1853,21 +2059,24 @@ export async function runDaemon(repoRoot, opts = {}) {
1853
2059
  // contiguous green prefix whose recorded commit is still the task branch tip.
1854
2060
  const satisfiedGate = satisfiedGates.get(t.id);
1855
2061
  const replayedGates = replayedGateResults.get(t.id);
1856
- resumeGateReplay: if (satisfiedGate || replayedGates) {
2062
+ const recheck = pendingRechecks(journal.read()).has(t.id);
2063
+ resumeGateReplay: if (satisfiedGate || replayedGates || recheck) {
1857
2064
  const taskBase = await integrationHead(intWt);
1858
2065
  const taskBranch = `${branch}--${t.id}`;
1859
2066
  const priorWt = worktreePath(repoRoot, taskBranch);
1860
2067
  if (!existsSync(priorWt)) {
1861
- if (satisfiedGate)
1862
- throw new Error(`approved gate ${satisfiedGate} cannot resume: task worktree is missing`);
2068
+ if (satisfiedGate || recheck)
2069
+ throw new Error(`${recheck ? "recheck" : `approved gate ${satisfiedGate}`} cannot resume: task worktree is missing`);
1863
2070
  // Observed passes are an optimization, never authority: without the task worktree there is no
1864
2071
  // commit to compare and no landed work to gate, so fall through to the ordinary worker path.
1865
2072
  replayedGateResults.delete(t.id);
1866
2073
  break resumeGateReplay;
1867
2074
  }
1868
- const resumeReason = satisfiedGate
1869
- ? `approved gate ${satisfiedGate}`
1870
- : `recorded gates on ${replayedGates.commit.slice(0, 10)}`;
2075
+ const resumeReason = recheck
2076
+ ? "operator recheck"
2077
+ : satisfiedGate
2078
+ ? `approved gate ${satisfiedGate}`
2079
+ : `recorded gates on ${replayedGates.commit.slice(0, 10)}`;
1871
2080
  const priorTaskTip = await gitHead(priorWt);
1872
2081
  const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, priorWt);
1873
2082
  const commitsToCarry = await commitsAheadOf(taskBase, priorWt);
@@ -1919,7 +2128,10 @@ export async function runDaemon(repoRoot, opts = {}) {
1919
2128
  : [],
1920
2129
  raw: "",
1921
2130
  };
1922
- const gateAuthor = rs?.lastAssignment ?? assignment;
2131
+ const parkedAuthor = recheck
2132
+ ? [...journal.read()].reverse().find((e) => e.event === "task-dispatch" && e.taskId === t.id)?.data.assignment
2133
+ : undefined;
2134
+ const gateAuthor = parkedAuthor ?? rs?.lastAssignment ?? assignment;
1923
2135
  const satisfiedIndex = satisfiedGate ? GATE_NAMES.indexOf(satisfiedGate) : -1;
1924
2136
  // The serial pipeline could have at most one blocking result, so "everything after the
1925
2137
  // approved gate" was enough. v1.85 can record both verdict siblings red in one round, and a
@@ -1943,7 +2155,10 @@ export async function runDaemon(repoRoot, opts = {}) {
1943
2155
  priorResults.set(e.data.gate, e);
1944
2156
  }
1945
2157
  let remainingGates;
1946
- if (satisfiedGate) {
2158
+ if (recheck) {
2159
+ remainingGates = GATE_NAMES.filter((gate) => t.gates.includes(gate));
2160
+ }
2161
+ else if (satisfiedGate) {
1947
2162
  remainingGates = t.gates.filter((gate) => {
1948
2163
  if (gate === satisfiedGate)
1949
2164
  return false;
@@ -2001,10 +2216,11 @@ export async function runDaemon(repoRoot, opts = {}) {
2001
2216
  // This suffix is re-measured to decide whether resume may advance, but the interrupted
2002
2217
  // attempt already paid for its red result. The next worker-backed round remains the next
2003
2218
  // deterministic-fingerprint occurrence/review round for budget accounting.
2004
- ...(!satisfiedGate ? { replayMeasurement: true } : {}),
2219
+ ...(!satisfiedGate && !recheck ? { replayMeasurement: true } : {}),
2005
2220
  };
2221
+ await trackedDriver.project?.(t.id, "in-review");
2006
2222
  journal.phaseStart(t.id, "gates");
2007
- const { results } = await runGates(resumedTask, {
2223
+ const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runGates(resumedTask, {
2008
2224
  worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
2009
2225
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
2010
2226
  collateral: collateral.get(t.id) ?? [],
@@ -2020,6 +2236,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2020
2236
  }
2021
2237
  : undefined,
2022
2238
  excludeReviewers: badReviewers,
2239
+ reviewHistory,
2023
2240
  onGate: async (e) => {
2024
2241
  if (e.phase === "start") {
2025
2242
  notePhaseStart(e);
@@ -2031,6 +2248,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2031
2248
  return;
2032
2249
  }
2033
2250
  const g = e.result;
2251
+ classifySignalOnlyTest(g);
2034
2252
  inParallelOrder(g.gate, () => {
2035
2253
  journalGateResult(g);
2036
2254
  noteReviewRetry(g);
@@ -2040,7 +2258,15 @@ export async function runDaemon(repoRoot, opts = {}) {
2040
2258
  }
2041
2259
  });
2042
2260
  },
2043
- });
2261
+ }));
2262
+ results.forEach(classifySignalOnlyTest);
2263
+ if (pendingRechecks(journal.read()).has(t.id)) {
2264
+ journal.append("recheck-battery", t.id, {
2265
+ commit: gateSubject.commit,
2266
+ gates: resumedTask.gates,
2267
+ pass: results.every(gateSatisfied),
2268
+ });
2269
+ }
2044
2270
  const approvedCommits = await commitsAheadOf(taskBase, wt);
2045
2271
  graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
2046
2272
  saveGraph(repoRoot, graph);
@@ -2087,6 +2313,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2087
2313
  attempts: rs?.attempts ?? 0, assignment: gateAuthor, taskContentDigest: contentDigest,
2088
2314
  });
2089
2315
  journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
2316
+ await trackedDriver.project?.(t.id, "completed");
2090
2317
  journal.telemetry({
2091
2318
  taskId: t.id, shape: t.shape, adapter: gateAuthor.adapter, model: gateAuthor.model,
2092
2319
  channel: gateAuthor.channel, attempts: rs?.attempts ?? 0, outcome: "done",
@@ -2257,11 +2484,13 @@ export async function runDaemon(repoRoot, opts = {}) {
2257
2484
  lastContextTokens = undefined;
2258
2485
  graph = setStatus(graph, t.id, "running");
2259
2486
  saveGraph(repoRoot, graph);
2487
+ await trackedDriver.project?.(t.id, "in-progress");
2260
2488
  // T6: a dispatch that carries an outstanding finding says so, and names it. Without this the
2261
2489
  // ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
2262
2490
  // spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
2263
2491
  journal.append("task-dispatch", t.id, {
2264
2492
  assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
2493
+ excludedChannels: [...demotedChannels].sort(),
2265
2494
  ...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
2266
2495
  ...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
2267
2496
  });
@@ -2373,9 +2602,18 @@ export async function runDaemon(repoRoot, opts = {}) {
2373
2602
  // itself before gates regardless of what the worker does with it.
2374
2603
  writeFileSync(promptFile, `${WORKTREE_LAYOUT_CONTRACT}\n\n${workerContract}\n\n${readFileSync(promptFile, "utf8")}`);
2375
2604
  const adapter = getAdapter(assignment.adapter, adapters);
2605
+ if (adapter.trustDialog && !trackedDriver.sendKey)
2606
+ noteCapabilityAbsent("sendKey");
2376
2607
  // VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
2377
2608
  // the legacy name stays the fallback for drivers without owned handling (subprocess spies).
2378
- const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, { group: "workers", owned: { role: "worker", taskId: t.id, attempt, runId } });
2609
+ const workerSlotOpts = {
2610
+ group: "workers",
2611
+ owned: { role: "worker", taskId: t.id, attempt, runId },
2612
+ };
2613
+ // Keep the established enumerable slot-options shape consumed by legacy drivers while making
2614
+ // the adapter hook available as an own request field to execution surfaces such as Orca.
2615
+ Object.defineProperty(workerSlotOpts, "agent", { value: assignment.adapter });
2616
+ const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, workerSlotOpts);
2379
2617
  const sessionId = retryMode === "resume" ? priorSession.id : slot.name;
2380
2618
  const icmd = retryMode === "resume"
2381
2619
  ? adapter.resumeCommand(sessionId, promptFile, assignment.model)
@@ -2419,7 +2657,12 @@ export async function runDaemon(repoRoot, opts = {}) {
2419
2657
  // between task-dispatch and this line — worktree recreation, setup, prompt write, slot allocation,
2420
2658
  // the launch itself — can still die with no worker having seen the brief, and `--retry-failed`
2421
2659
  // must then re-send that same brief rather than a fresh prompt on a possibly banned channel.
2422
- const noteLaunched = () => journal.append("worker-launch", t.id, { attempt, retryMode });
2660
+ const noteLaunched = async () => journal.append("worker-launch", t.id, {
2661
+ attempt,
2662
+ retryMode,
2663
+ ...(trackedDriver.describe ? await trackedDriver.describe(slot) : {}),
2664
+ });
2665
+ const noteWorkerLiveness = (event, data) => journal.append(event, t.id, { ...data, source: trackedDriver.readSource ?? "driver.read" });
2423
2666
  let cpuFlat;
2424
2667
  let cpuAccountant;
2425
2668
  let cpuGapCount = 0;
@@ -2551,6 +2794,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2551
2794
  // subprocess tree that REACHED its exit marker, not about what the worker claimed.
2552
2795
  let processExited = false;
2553
2796
  let earlyLaunchDead = false;
2797
+ let startupFailure = false;
2554
2798
  let deadWorkerPark;
2555
2799
  let settleParsed;
2556
2800
  let seedResult;
@@ -2633,7 +2877,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2633
2877
  continue attempts;
2634
2878
  return;
2635
2879
  }
2636
- noteLaunched();
2880
+ await noteLaunched();
2637
2881
  output = seedResult.output;
2638
2882
  }
2639
2883
  else {
@@ -2647,14 +2891,15 @@ export async function runDaemon(repoRoot, opts = {}) {
2647
2891
  continue attempts;
2648
2892
  return;
2649
2893
  }
2650
- noteLaunched();
2894
+ await noteLaunched();
2651
2895
  output = await driver.read(slot, PANE_READ_ROWS);
2652
2896
  }
2653
2897
  // The returning paths report the same fact on the result; both callbacks land on the one
2654
2898
  // latch, and the second is a no-op. A seed that answered is never re-answered by the loop.
2655
2899
  if (seedResult?.trustAnswered)
2656
2900
  noteSeedTrustAnswered();
2657
- if (seedResult?.seedFailed) {
2901
+ startupFailure = WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(output));
2902
+ if (seedResult?.seedFailed || startupFailure) {
2658
2903
  finished = false;
2659
2904
  }
2660
2905
  else {
@@ -2742,7 +2987,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2742
2987
  throw error;
2743
2988
  if (!paneReadHeld) {
2744
2989
  paneReadHeld = true;
2745
- journal.append("worker-dead-held", t.id, {
2990
+ noteWorkerLiveness("worker-dead-held", {
2746
2991
  slot: slot.name, attempt, reason: "pane-read-unreadable",
2747
2992
  error: error instanceof Error ? error.message : String(error),
2748
2993
  });
@@ -2755,6 +3000,11 @@ export async function runDaemon(repoRoot, opts = {}) {
2755
3000
  }
2756
3001
  if (paneText.length > 0)
2757
3002
  everHadOutput = true;
3003
+ if (WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(paneText))) {
3004
+ startupFailure = true;
3005
+ output = paneText;
3006
+ break;
3007
+ }
2758
3008
  // OBS-117 (v1.71 T6): zero raw output by the early-launch deadline is a dead channel now.
2759
3009
  if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
2760
3010
  earlyLaunchDead = true;
@@ -2828,7 +3078,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2828
3078
  catch (error) {
2829
3079
  if (!paneStatusHeld) {
2830
3080
  paneStatusHeld = true;
2831
- journal.append("worker-dead-held", t.id, {
3081
+ noteWorkerLiveness("worker-dead-held", {
2832
3082
  slot: slot.name, attempt, reason: "pane-status-unreadable",
2833
3083
  error: error instanceof Error ? error.message : String(error),
2834
3084
  });
@@ -2905,7 +3155,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2905
3155
  paneAbsent = false;
2906
3156
  if (!paneReadHeld) {
2907
3157
  paneReadHeld = true;
2908
- journal.append("worker-dead-held", t.id, {
3158
+ noteWorkerLiveness("worker-dead-held", {
2909
3159
  slot: slot.name, attempt, reason: "pane-read-unreadable",
2910
3160
  error: error instanceof Error ? error.message : String(error),
2911
3161
  });
@@ -2929,7 +3179,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2929
3179
  paneAbsent = false;
2930
3180
  if (!paneReadHeld) {
2931
3181
  paneReadHeld = true;
2932
- journal.append("worker-dead-held", t.id, {
3182
+ noteWorkerLiveness("worker-dead-held", {
2933
3183
  slot: slot.name, attempt, reason: "pane-read-unreadable",
2934
3184
  error: error instanceof Error ? error.message : String(error),
2935
3185
  });
@@ -2953,7 +3203,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2953
3203
  if (deathCertain) {
2954
3204
  const preservation = await preserveDeadWorker(wt, taskBase);
2955
3205
  if (!preservation.ref) {
2956
- journal.append("worker-dead-held", t.id, {
3206
+ noteWorkerLiveness("worker-dead-held", {
2957
3207
  slot: slot.name, attempt, reason: `worktree-${preservation.state}`,
2958
3208
  });
2959
3209
  continue;
@@ -2962,7 +3212,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2962
3212
  const reason = `worker is unambiguously dead: pane absent, process tree empty, and worktree unchanged; preserved at ${ref}`;
2963
3213
  deadWorkerPark = { ref, reason };
2964
3214
  journal.append("worktree-preserved", t.id, { ref });
2965
- journal.append("worker-dead-held", t.id, {
3215
+ noteWorkerLiveness("worker-dead-held", {
2966
3216
  slot: slot.name, attempt, reason: "unambiguous-worker-death", ref,
2967
3217
  });
2968
3218
  break;
@@ -2978,8 +3228,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2978
3228
  // Everything the fast-kill can decide from the CHANNEL, evaluated before the CPU leg is
2979
3229
  // asked for anything: the kill's own probe costs a `ps` every 100ms, so an ineligible
2980
3230
  // slice must not pay for it. The CPU reading is consulted at the kill itself, below.
2981
- const fastKillEligible = !stallProgress.rowSignalSaturated
2982
- && !nudgePending && !nudgeFailed
3231
+ const fastKillEligible = !nudgePending && !nudgeFailed
2983
3232
  && sliceNow - lastOutputGrowthAt >= deadChannelFastKillMs
2984
3233
  && worktreeSinceLaunch === "unchanged";
2985
3234
  const harvestCpuEligible = !nudgePending && !nudgeFailed
@@ -3020,14 +3269,10 @@ export async function runDaemon(repoRoot, opts = {}) {
3020
3269
  // (an elapsed "9s"→"10s" lengthens the read and would hide a frozen pane). But the
3021
3270
  // tracker SHARES the read window's ceiling: its row signal saturates once a sample
3022
3271
  // FILLS a PANE_READ_ROWS read on raw lines (blanks/chrome included — see stall.ts),
3023
- // and past that point a flat tracker means "unmeasurable", not "dead". For unmetered
3024
- // adapters (codex, cursor-agent, grok, opencode — no contextUsage, so the token signal
3025
- // never fires) rows are the ONLY
3026
- // signal, and those are exactly the non-nudgeable adapters this kill governs — a live
3027
- // worker past the ceiling would be concluded dead mid-work. So the kill STANDS DOWN on
3028
- // a saturated row signal (journaled once per attempt); the rolling window still owns
3029
- // that pane, exactly as pre-T1. Token growth counts as life either way, so a metered
3030
- // worker thinking through a long tool run survives.
3272
+ // and past that point a flat tracker means "unmeasurable", not "dead". Journal that
3273
+ // unreadability once, then let the independent CPU and worktree legs decide: accruing or
3274
+ // unreadable CPU preserves the worker; flat CPU beside an unchanged tree may conclude.
3275
+ // A saturated row alone is never a death verdict.
3031
3276
  // The triad has NO status exemption: a pane that herdr reports as blocked, idle, working
3032
3277
  // or unknown dies alike once it holds no trailer, an unchanged tree and no growth — waiting the
3033
3278
  // rolling window out on a status reading is exactly the blindness T1 removes. A matched
@@ -3041,7 +3286,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3041
3286
  // filesystem/git observation answer that clause.
3042
3287
  if (stallProgress.rowSignalSaturated && !rowSaturationHeld) {
3043
3288
  rowSaturationHeld = true;
3044
- journal.append("worker-dead-held", t.id, { slot: slot.name, attempt, reason: "row-signal-saturated" });
3289
+ journal.append("contact-unreadable", t.id, {
3290
+ slot: slot.name, attempt, source: "pane-rows", reason: "row-signal-saturated", concludes: false,
3291
+ });
3045
3292
  }
3046
3293
  // OBS-548: the FOURTH leg, and the one the daemon already measured. The three legs above
3047
3294
  // are all channel-side: they say a pane stopped talking. The tree's CPU says whether
@@ -3058,12 +3305,13 @@ export async function runDaemon(repoRoot, opts = {}) {
3058
3305
  journal.append("worker-dead", t.id, {
3059
3306
  slot: slot.name, attempt, silentMs: sliceNow - lastOutputGrowthAt,
3060
3307
  cpuMs: leg.cpu.ms, cpuResolutionMs: leg.cpu.resolutionMs,
3308
+ source: trackedDriver.readSource ?? "driver.read",
3061
3309
  });
3062
3310
  break;
3063
3311
  }
3064
3312
  if (!cpuHeld) {
3065
3313
  cpuHeld = true;
3066
- journal.append("worker-dead-held", t.id, {
3314
+ noteWorkerLiveness("worker-dead-held", {
3067
3315
  slot: slot.name, attempt, reason: leg.state === "accruing" ? "cpu-accruing" : "cpu-unmeasurable",
3068
3316
  });
3069
3317
  }
@@ -3191,11 +3439,12 @@ export async function runDaemon(repoRoot, opts = {}) {
3191
3439
  continue attempts;
3192
3440
  return;
3193
3441
  }
3194
- noteLaunched();
3442
+ await noteLaunched();
3195
3443
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
3196
3444
  // v1.76: same monotonic-progress measure as the interactive site; harvest stays raw.
3197
3445
  const stallWindowMs = taskTimeoutMinutes * 60_000;
3198
3446
  const initialPane = await driver.read(slot, 500);
3447
+ startupFailure = WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(initialPane));
3199
3448
  let everHadOutput = initialPane.length > 0;
3200
3449
  const stallProgress = new StallProgressTracker();
3201
3450
  stallProgress.observe({ paneText: initialPane, seedSubmitted: true, contextTokens });
@@ -3208,7 +3457,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3208
3457
  // worker-result-harvested row. The interactive site has always kept them apart (`finished`
3209
3458
  // there is the trailer regex; the exit marker only sets exitCode), and the cause taxonomy
3210
3459
  // already names this shape "clean-exit-no-trailer" — unreachable in print mode until now.
3211
- while (Date.now() - lastProgressAt < stallWindowMs) {
3460
+ while (!startupFailure && Date.now() - lastProgressAt < stallWindowMs) {
3212
3461
  const remaining = stallWindowMs - (Date.now() - lastProgressAt);
3213
3462
  let slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
3214
3463
  if (!everHadOutput) {
@@ -3225,6 +3474,10 @@ export async function runDaemon(repoRoot, opts = {}) {
3225
3474
  const paneText = await driver.read(slot, 500);
3226
3475
  if (paneText.length > 0)
3227
3476
  everHadOutput = true;
3477
+ if (WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(paneText))) {
3478
+ startupFailure = true;
3479
+ break;
3480
+ }
3228
3481
  if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
3229
3482
  earlyLaunchDead = true;
3230
3483
  break;
@@ -3286,7 +3539,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3286
3539
  }
3287
3540
  let result = settleParsed ?? adapter.parse(output, nonce);
3288
3541
  const workerFinished = finished;
3289
- const workerCause = classifyWorkerResultCause({ output, ok: result.ok, finished, exitCode, summary: result.summary, timedOut, deadChannel: deadChannelKilled });
3542
+ const workerCause = startupFailure
3543
+ ? "startup-failure"
3544
+ : classifyWorkerResultCause({ output, ok: result.ok, finished, exitCode, summary: result.summary, timedOut, deadChannel: deadChannelKilled });
3290
3545
  journal.append("worker-result", t.id, {
3291
3546
  ok: result.ok, summary: result.summary, deviations: result.deviations, finished: workerFinished, exitCode,
3292
3547
  mode: interactive ? "interactive" : "print", ...(workerCause ? { cause: workerCause } : {}),
@@ -3403,7 +3658,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3403
3658
  // T2 review: classify the PRE-HARVEST parse — classifyDeadChannel bails on any ok:true
3404
3659
  // result, so reading the synthesized harvest result would swallow auth-required /
3405
3660
  // setup-required / provider-outage for every committed-but-walled attempt (in both modes).
3406
- const dead = classifyDeadChannel(preHarvestResult) ?? (earlyLaunchDead ? "setup-required" : undefined);
3661
+ const dead = classifyDeadChannel(preHarvestResult) ?? (earlyLaunchDead || startupFailure ? "setup-required" : undefined);
3407
3662
  if (dead) {
3408
3663
  const from = channelKey(assignment);
3409
3664
  demotedChannels.add(from); // excluded for later attempts AND later tasks in this run
@@ -3478,6 +3733,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3478
3733
  return;
3479
3734
  }
3480
3735
  const g = e.result;
3736
+ classifySignalOnlyTest(g);
3481
3737
  inParallelOrder(g.gate, () => {
3482
3738
  // GATE-09 (ROADMAP SC-4): journal every judge retry as an attributable event — which gate flaked,
3483
3739
  // which channel flaked, which channel retried — so `tickmarkr journal`/report can distinguish "judge
@@ -3509,8 +3765,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3509
3765
  gateLoop: while (true) {
3510
3766
  const gated = await gitHead(wt);
3511
3767
  gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
3768
+ await trackedDriver.project?.(t.id, "in-review");
3512
3769
  journal.phaseStart(t.id, "gates");
3513
- ({ results, commits } = await runGates(t, {
3770
+ ({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runGates(t, {
3514
3771
  worktree: wt, baseRef: taskBase, result, author: assignment,
3515
3772
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
3516
3773
  collateral: collateral.get(t.id) ?? [],
@@ -3532,8 +3789,10 @@ export async function runDaemon(repoRoot, opts = {}) {
3532
3789
  }
3533
3790
  : undefined,
3534
3791
  excludeReviewers: badReviewers,
3792
+ reviewHistory,
3535
3793
  onGate,
3536
- }));
3794
+ })));
3795
+ results.forEach(classifySignalOnlyTest);
3537
3796
  graph = addEvidence(graph, t.id, { commits, gateResults: results, artifacts: [promptFile] });
3538
3797
  saveGraph(repoRoot, graph);
3539
3798
  if (results.some((g) => g.gate === "test" && !g.pass))
@@ -3569,6 +3828,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3569
3828
  attempts: attempt + 1, assignment, taskContentDigest: contentDigest,
3570
3829
  });
3571
3830
  journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
3831
+ await trackedDriver.project?.(t.id, "completed");
3572
3832
  // firstAttemptOk/gateFails/consults are recorded FACTS, not policy — a parkKind:"stall" row is
3573
3833
  // recorded but NOT quality-negative in v1.6; Phase 12 owns reward policy, so flipping it later needs zero data migration.
3574
3834
  journal.telemetry({ taskId: t.id, shape: t.shape, adapter: assignment.adapter, model: assignment.model, channel: assignment.channel, attempts: attempt + 1, outcome: "done", durationMs: Date.now() - startMs, firstAttemptOk: attempt === 0, gateFails, consults, tokens, meteredAttempts: tokens ? metered : undefined, retryMode });
@@ -3630,6 +3890,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3630
3890
  // failing gate, and `feedback` still carries every one of them to the next attempt.
3631
3891
  const repairBattery = failing.some((g) => g.gate !== "review") ? failing.filter((g) => g.gate !== "review") : failing;
3632
3892
  const repairable = narrowRepairBattery(repairBattery) && lostCommits.length === 0 && landed.length > 0;
3893
+ const repairHistory = repairReachSinceApproval(journal.read(), t.id);
3633
3894
  const repairsDrawn = repairsSinceApproval(journal.read(), t.id);
3634
3895
  const repair = repairable && repairsDrawn < MAX_REPAIRS;
3635
3896
  // v1.85 T3: the fingerprint cap. Two normalized-identical failures of one DETERMINISTIC gate on
@@ -3699,13 +3960,17 @@ export async function runDaemon(repoRoot, opts = {}) {
3699
3960
  const repairExhausted = repairable && !repair;
3700
3961
  if (repair && !capStep) {
3701
3962
  journal.append("repair-attempt", t.id, {
3702
- repair: repairsDrawn + 1, of: MAX_REPAIRS, gates: failing.map((g) => g.gate),
3963
+ repair: repairHistory.length + 1, charge: repairsDrawn + 1, of: MAX_REPAIRS,
3964
+ gates: failing.map((g) => g.gate),
3703
3965
  commits: landed.length,
3704
3966
  findings: feedback, // the failure bytes this repair must carry, replayable across a resume
3705
3967
  });
3706
3968
  }
3707
3969
  else if (repairExhausted && !capStep) {
3708
- journal.append("repair-exhausted", t.id, { repairs: repairsDrawn, of: MAX_REPAIRS, gates: failing.map((g) => g.gate) });
3970
+ journal.append("repair-exhausted", t.id, {
3971
+ repairs: repairsDrawn, of: MAX_REPAIRS, gates: failing.map((g) => g.gate),
3972
+ reached: repairHistory,
3973
+ });
3709
3974
  }
3710
3975
  const step = capStep ?? (repair || (reviewFixRetry && !repairExhausted)
3711
3976
  ? "retry"
@@ -3714,7 +3979,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3714
3979
  journal.append("escalation", t.id, {
3715
3980
  step, attempt: attempt + 1,
3716
3981
  ...(reviewFixRetry && !repairExhausted ? { reviewFix: true } : {}),
3717
- ...(repair ? { repair: repairsDrawn + 1 } : {}),
3982
+ ...(repair ? { repair: repairHistory.length + 1, repairCharge: repairsDrawn + 1 } : {}),
3718
3983
  });
3719
3984
  await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
3720
3985
  }
@@ -3769,7 +4034,13 @@ export async function runDaemon(repoRoot, opts = {}) {
3769
4034
  }
3770
4035
  if (inflight.size === 0)
3771
4036
  break;
3772
- await Promise.race([...inflight.values(), aborted]); // aborted rejects on termination — unwinds the run
4037
+ const waiters = [...inflight.values(), aborted];
4038
+ // A free slot is itself a scheduling boundary: poll the append-only approval stream instead of
4039
+ // sleeping until an unrelated long-running task settles.
4040
+ if (inflight.size < concurrency) {
4041
+ waiters.push(new Promise((wake) => setTimeout(wake, APPROVAL_POLL_MS)));
4042
+ }
4043
+ await Promise.race(waiters); // aborted rejects on termination — unwinds the run
3773
4044
  }
3774
4045
  // D-07: the sweep now closes only what's LEFT in keptSlots — done-closed worker slots were removed
3775
4046
  // (no double-close) and self-cleaned LLM/consult panes were never added under keepLlm:false. This
@@ -3794,7 +4065,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3794
4065
  // OBS-34: post-merge integration-tip verify — strict exit codes, no baseline forgiveness.
3795
4066
  const lastMergedTask = [...journal.read()].reverse().find((e) => e.event === "merge" && e.taskId)?.taskId;
3796
4067
  if (summary.done.length > 0 && Object.keys(commands).length > 0) {
3797
- const tipFailed = await verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline });
4068
+ const tipFailed = await withSuiteWindow(undefined, commands.test !== undefined, () => verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline }));
3798
4069
  summary.tipVerify = tipFailed ? "failed" : "passed";
3799
4070
  if (tipFailed && lastMergedTask)
3800
4071
  summary.lastMergedTask = lastMergedTask;