tickmarkr 2.3.0 → 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 (57) hide show
  1. package/dist/adapters/catalog-remote.d.ts +1 -4
  2. package/dist/adapters/catalog-remote.js +52 -42
  3. package/dist/adapters/catalog.js +5 -3
  4. package/dist/adapters/claude-code.d.ts +1 -1
  5. package/dist/adapters/claude-code.js +8 -5
  6. package/dist/adapters/model-lints.d.ts +9 -5
  7. package/dist/adapters/model-lints.js +56 -15
  8. package/dist/adapters/model-windows.js +11 -0
  9. package/dist/adapters/prompt.js +1 -0
  10. package/dist/adapters/qwen.d.ts +5 -0
  11. package/dist/adapters/qwen.js +153 -0
  12. package/dist/adapters/types.d.ts +1 -0
  13. package/dist/adapters/types.js +1 -0
  14. package/dist/cli/commands/compile.js +32 -6
  15. package/dist/cli/commands/doctor.d.ts +4 -3
  16. package/dist/cli/commands/doctor.js +20 -6
  17. package/dist/cli/commands/fleet.d.ts +4 -0
  18. package/dist/cli/commands/fleet.js +53 -14
  19. package/dist/cli/commands/plan.js +29 -5
  20. package/dist/cli/commands/status.d.ts +1 -0
  21. package/dist/cli/commands/status.js +45 -1
  22. package/dist/cli/commands/verify.d.ts +1 -0
  23. package/dist/cli/commands/verify.js +5 -0
  24. package/dist/cli/index.d.ts +1 -1
  25. package/dist/cli/index.js +2 -2
  26. package/dist/compile/collateral.d.ts +2 -9
  27. package/dist/compile/collateral.js +2 -9
  28. package/dist/compile/index.d.ts +4 -1
  29. package/dist/compile/index.js +41 -7
  30. package/dist/compile/native.d.ts +4 -2
  31. package/dist/compile/native.js +55 -6
  32. package/dist/compile/ownership.js +34 -9
  33. package/dist/config/config.d.ts +1 -0
  34. package/dist/config/config.js +51 -5
  35. package/dist/drivers/herdr.d.ts +1 -0
  36. package/dist/drivers/herdr.js +11 -1
  37. package/dist/drivers/orca.d.ts +14 -1
  38. package/dist/drivers/orca.js +114 -15
  39. package/dist/drivers/types.d.ts +10 -0
  40. package/dist/gates/baseline.js +26 -7
  41. package/dist/gates/review.d.ts +4 -2
  42. package/dist/gates/review.js +24 -13
  43. package/dist/gates/run-gates.d.ts +5 -2
  44. package/dist/gates/run-gates.js +34 -17
  45. package/dist/route/preference.d.ts +4 -0
  46. package/dist/route/preference.js +40 -0
  47. package/dist/route/router.js +15 -2
  48. package/dist/run/consult.d.ts +1 -0
  49. package/dist/run/consult.js +34 -7
  50. package/dist/run/daemon.d.ts +4 -0
  51. package/dist/run/daemon.js +128 -50
  52. package/dist/run/git.d.ts +2 -0
  53. package/dist/run/git.js +36 -5
  54. package/dist/run/journal.js +4 -1
  55. package/dist/tui/ink/fleet-app.d.ts +4 -0
  56. package/dist/tui/ink/fleet-app.js +45 -16
  57. package/package.json +59 -1
@@ -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";
@@ -74,7 +75,7 @@ export function resolveRunMode(repoRoot, opts = {}) {
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"]);
78
79
  const RECHECK_ENACTMENT = "recheck-battery";
79
80
  // The ONE approval that enacts without buying a worker: GATE_SATISFIED_RELEASE resumes from the
80
81
  // persisted task branch after the approved gate (execTask's satisfiedGate branch), whose first act
@@ -137,7 +138,8 @@ export function formatSummary(s) {
137
138
  * run id (cli/commands/status.ts positionalRunId), so naming it here is what stops the board from
138
139
  * following the newest journal in a repo that already carries a second, newer run — a board showing
139
140
  * the wrong run is a recorded incident (skills/tickmarkr-overseer/SKILL.md). */
140
- 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)}`;
141
143
  const MAX_ATTEMPTS = 10; // ponytail: hard cap so a pathological ladder can never loop forever
142
144
  // v1.85 T3 (retry economics): two repairs per engagement, then the fresh ladder. A repair re-uses the
143
145
  // findings and the landed diff instead of re-buying onboarding; when two of them have not closed the
@@ -213,7 +215,7 @@ function narrowRepairBattery(failing) {
213
215
  }
214
216
  // v2.0 T2 (OBS-554): the measurement keys run-gates stamps on a GateResult, lifted verbatim onto the
215
217
  // gate row. One list, one lift — both onGate sites record through the same helper.
216
- const GATE_TELEMETRY_KEYS = ["durationMs", "load1Start", "load1End", "selectedDurationMs", "fullDurationMs", "invocations"];
218
+ const GATE_TELEMETRY_KEYS = ["durationMs", "load1Start", "load1End", "load1Max", "load1Mean", "selectedDurationMs", "fullDurationMs", "invocations"];
217
219
  const gateMeasurement = (meta = {}) => Object.fromEntries(GATE_TELEMETRY_KEYS.filter((k) => meta[k] !== undefined).map((k) => [k, meta[k]]));
218
220
  /**
219
221
  * T4 (OBS-265): the journal with the review objections a round did NOT hinge on removed. Judge and
@@ -289,6 +291,11 @@ function approvedReviewRoundCeiling(events, taskId) {
289
291
  }
290
292
  const BLOCKED_POLL_MS = 30_000; // between trailer-wait slices, check whether the pane is blocked on a prompt
291
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; };
292
299
  export const APPROVAL_POLL_MS = 250;
293
300
  const PROVIDER_DEATH_REQUEUE_CAP = 2; // v1.46 T1: requeue same assignment twice, then fall through to the normal ladder
294
301
  const PROVIDER_DEATH_BACKOFF_MS = 500; // short backoff before provider-death requeue
@@ -339,6 +346,7 @@ export function resetNudgeTimingForTests() {
339
346
  // diff merely quotes "rate limit" keeps working undisturbed).
340
347
  const QUOTA_BANNER_SILENT_MS = 3 * 60_000;
341
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;
342
350
  /** Test seam — shrink the quota-banner silence gate without minute-long sleeps. */
343
351
  export function setQuotaBannerSilentMsForTests(ms) {
344
352
  quotaBannerSilentMs = ms;
@@ -642,7 +650,11 @@ export function countLiveSuites(snapshot, repoRoot, daemonPid = process.pid, cwd
642
650
  }
643
651
  }
644
652
  const root = realpathSync(repoRoot);
645
- const candidates = rows.filter((row) => !ancestors.has(row.pid) && SUITE_COMMAND_RE.test(row.command));
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(" ")));
646
658
  const attributable = new Set(candidates.filter((row) => {
647
659
  if (descendants.has(row.pid))
648
660
  return true;
@@ -1219,6 +1231,8 @@ export async function runDaemon(repoRoot, opts = {}) {
1219
1231
  const trackedDriver = {
1220
1232
  id: driver.id,
1221
1233
  interactive: driver.interactive,
1234
+ ...(driver.readSource ? { readSource: driver.readSource } : {}),
1235
+ ...(driver.describe ? { describe: driver.describe.bind(driver) } : {}),
1222
1236
  slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
1223
1237
  run: (s, cmd) => driver.run(s, cmd),
1224
1238
  waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
@@ -1227,10 +1241,25 @@ export async function runDaemon(repoRoot, opts = {}) {
1227
1241
  read: (s, n) => driver.read(s, n),
1228
1242
  ...(driver.sendKey ? { sendKey: driver.sendKey.bind(driver) } : {}),
1229
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) } : {}),
1230
1251
  notify: (m, o) => driver.notify(m, o),
1231
1252
  close: closeSlot,
1232
1253
  worktree: (r, b, base) => driver.worktree(r, b, base),
1233
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
+ };
1234
1263
  // Termination (SIGINT/SIGTERM): record the daemon-controlled exit before closing every live slot,
1235
1264
  // reconcile owned panes against an EMPTY
1236
1265
  // desired set (herdr panes not in memory; panesToClose spares foreign names, watch panes, and
@@ -1255,6 +1284,9 @@ export async function runDaemon(repoRoot, opts = {}) {
1255
1284
  catch (error) {
1256
1285
  console.error(`tickmarkr ${runId}: deliberate exit cause could not be journalled (${error instanceof Error ? error.message : String(error)})`);
1257
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}`));
1258
1290
  if (cfg.visibility.keepPanes !== "forever") {
1259
1291
  for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
1260
1292
  try {
@@ -1269,13 +1301,15 @@ export async function runDaemon(repoRoot, opts = {}) {
1269
1301
  }
1270
1302
  releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
1271
1303
  }
1272
- abortRun(new Error(`terminated by ${sig}`));
1273
1304
  exit(sig === "SIGINT" ? 130 : 143);
1274
1305
  })();
1275
1306
  };
1276
1307
  process.on("SIGINT", onTermination);
1277
1308
  process.on("SIGTERM", onTermination);
1278
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);
1279
1313
  // Capture this before this observer appends run-resume. A prior run-end belongs to a completed
1280
1314
  // lifecycle and must use the ordinary resume/redispatch rules; only an interrupted live attempt
1281
1315
  // owns reusable measurements or unclean-death residue.
@@ -1305,6 +1339,20 @@ export async function runDaemon(repoRoot, opts = {}) {
1305
1339
  const approved = new Set(approvalStartupEvents.filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
1306
1340
  let approvalSweepCursor = approvalStartupEvents.length;
1307
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
+ };
1308
1356
  let baseRef;
1309
1357
  let baseline;
1310
1358
  // Phase 46 (RES-01/RES-02): the resume-state map is built ONCE here so execTask closes over it.
@@ -1372,9 +1420,12 @@ export async function runDaemon(repoRoot, opts = {}) {
1372
1420
  ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
1373
1421
  ...(opts.retryFailed ? { retryFailed: true } : {}),
1374
1422
  });
1423
+ await openBoard();
1375
1424
  }
1376
1425
  else {
1377
1426
  baseRef = await gitHead(repoRoot);
1427
+ journal.append("baseline-start", undefined, { baseRef, commands });
1428
+ await openBoard();
1378
1429
  baseline = await captureBaseline(repoRoot, commands);
1379
1430
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
1380
1431
  writeFileSync(join(journal.dir, "graph.json"), readFileSync(join(tickmarkrDir(repoRoot), "graph.json")));
@@ -1420,21 +1471,6 @@ export async function runDaemon(repoRoot, opts = {}) {
1420
1471
  const collateral = pinnedCollateral ?? collateralHits(graph.tasks, repoRoot);
1421
1472
  if (!pinnedCollateral)
1422
1473
  writeFileSync(collateralPath, JSON.stringify(Object.fromEntries(collateral), null, 2));
1423
- // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
1424
- // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
1425
- // a failed-to-open or later-dead watch pane never affects the run.
1426
- // OBS-103: hold the returned slot — narrator() returns the run's board under its canonical owned
1427
- // name whichever way it got there (the herdr driver retires a survivor it finds and re-splits, so
1428
- // the live command is run-bound), and the run-end sweep below retires it by that name regardless
1429
- // of which daemon instance split the pane.
1430
- const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
1431
- let watchSlot;
1432
- try {
1433
- watchSlot = await driver.narrator?.(repoRoot, watchCommand(runId), runId);
1434
- }
1435
- catch (error) {
1436
- journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
1437
- }
1438
1474
  const intWt = await ensureIntegration(repoRoot, branch, baseRef);
1439
1475
  // v1.1 visibility: role-named slots; panes persist per keepPanes (attempt = v1 close-after-harvest)
1440
1476
  const keepOpen = cfg.visibility.keepPanes !== "attempt";
@@ -1473,7 +1509,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1473
1509
  if (watchSlot && watchSlot.name === watchName && !desired.has(watchName)) {
1474
1510
  const w = watchSlot;
1475
1511
  watchSlot = undefined;
1476
- await driver.close(w);
1512
+ await trackedDriver.close(w);
1477
1513
  }
1478
1514
  }
1479
1515
  catch {
@@ -1525,10 +1561,17 @@ export async function runDaemon(repoRoot, opts = {}) {
1525
1561
  await previous;
1526
1562
  try {
1527
1563
  let lastCount = -1;
1564
+ const startedAt = Date.now();
1528
1565
  for (;;) {
1529
1566
  const count = await liveSuiteCount(repoRoot);
1530
1567
  if (count === 0)
1531
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
+ }
1532
1575
  if (count !== lastCount)
1533
1576
  journal.append("suite-wait", taskId, { count });
1534
1577
  lastCount = count;
@@ -1799,6 +1842,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1799
1842
  // `test` row whose scope no consumer could recover.
1800
1843
  ...(Array.isArray(g.meta?.selectedTests) ? { selectedTests: g.meta.selectedTests } : {}),
1801
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
+ } : {}),
1802
1853
  // A finding's path is its own evidence path. Do not pass task scope here: a declaration says
1803
1854
  // where work is allowed, not where this verdict found the defect.
1804
1855
  ...(blocking ? {
@@ -1820,9 +1871,9 @@ export async function runDaemon(repoRoot, opts = {}) {
1820
1871
  // T7: the capacity the gate's own command child ran under, lifted verbatim from the result
1821
1872
  // the battery produced — read where the shell built that child's environment, never
1822
1873
  // re-derived from the run's own budget, which would answer a different number than the
1823
- // operator's export did. It is this row's only COMPARABLE identity: the two load samples
1824
- // beside it are endpoint reads of a gate whose interior neither of them saw, so no reader
1825
- // 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
1826
1877
  // no command carries nothing — it divided nothing.
1827
1878
  //
1828
1879
  // `dirtiedBy` is the one hole in that lift: run-gates REPLACES a green battery verdict with
@@ -2167,6 +2218,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2167
2218
  // deterministic-fingerprint occurrence/review round for budget accounting.
2168
2219
  ...(!satisfiedGate && !recheck ? { replayMeasurement: true } : {}),
2169
2220
  };
2221
+ await trackedDriver.project?.(t.id, "in-review");
2170
2222
  journal.phaseStart(t.id, "gates");
2171
2223
  const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runGates(resumedTask, {
2172
2224
  worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
@@ -2184,6 +2236,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2184
2236
  }
2185
2237
  : undefined,
2186
2238
  excludeReviewers: badReviewers,
2239
+ reviewHistory,
2187
2240
  onGate: async (e) => {
2188
2241
  if (e.phase === "start") {
2189
2242
  notePhaseStart(e);
@@ -2260,6 +2313,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2260
2313
  attempts: rs?.attempts ?? 0, assignment: gateAuthor, taskContentDigest: contentDigest,
2261
2314
  });
2262
2315
  journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
2316
+ await trackedDriver.project?.(t.id, "completed");
2263
2317
  journal.telemetry({
2264
2318
  taskId: t.id, shape: t.shape, adapter: gateAuthor.adapter, model: gateAuthor.model,
2265
2319
  channel: gateAuthor.channel, attempts: rs?.attempts ?? 0, outcome: "done",
@@ -2430,11 +2484,13 @@ export async function runDaemon(repoRoot, opts = {}) {
2430
2484
  lastContextTokens = undefined;
2431
2485
  graph = setStatus(graph, t.id, "running");
2432
2486
  saveGraph(repoRoot, graph);
2487
+ await trackedDriver.project?.(t.id, "in-progress");
2433
2488
  // T6: a dispatch that carries an outstanding finding says so, and names it. Without this the
2434
2489
  // ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
2435
2490
  // spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
2436
2491
  journal.append("task-dispatch", t.id, {
2437
2492
  assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
2493
+ excludedChannels: [...demotedChannels].sort(),
2438
2494
  ...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
2439
2495
  ...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
2440
2496
  });
@@ -2546,6 +2602,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2546
2602
  // itself before gates regardless of what the worker does with it.
2547
2603
  writeFileSync(promptFile, `${WORKTREE_LAYOUT_CONTRACT}\n\n${workerContract}\n\n${readFileSync(promptFile, "utf8")}`);
2548
2604
  const adapter = getAdapter(assignment.adapter, adapters);
2605
+ if (adapter.trustDialog && !trackedDriver.sendKey)
2606
+ noteCapabilityAbsent("sendKey");
2549
2607
  // VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
2550
2608
  // the legacy name stays the fallback for drivers without owned handling (subprocess spies).
2551
2609
  const workerSlotOpts = {
@@ -2599,7 +2657,12 @@ export async function runDaemon(repoRoot, opts = {}) {
2599
2657
  // between task-dispatch and this line — worktree recreation, setup, prompt write, slot allocation,
2600
2658
  // the launch itself — can still die with no worker having seen the brief, and `--retry-failed`
2601
2659
  // must then re-send that same brief rather than a fresh prompt on a possibly banned channel.
2602
- 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" });
2603
2666
  let cpuFlat;
2604
2667
  let cpuAccountant;
2605
2668
  let cpuGapCount = 0;
@@ -2731,6 +2794,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2731
2794
  // subprocess tree that REACHED its exit marker, not about what the worker claimed.
2732
2795
  let processExited = false;
2733
2796
  let earlyLaunchDead = false;
2797
+ let startupFailure = false;
2734
2798
  let deadWorkerPark;
2735
2799
  let settleParsed;
2736
2800
  let seedResult;
@@ -2813,7 +2877,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2813
2877
  continue attempts;
2814
2878
  return;
2815
2879
  }
2816
- noteLaunched();
2880
+ await noteLaunched();
2817
2881
  output = seedResult.output;
2818
2882
  }
2819
2883
  else {
@@ -2827,14 +2891,15 @@ export async function runDaemon(repoRoot, opts = {}) {
2827
2891
  continue attempts;
2828
2892
  return;
2829
2893
  }
2830
- noteLaunched();
2894
+ await noteLaunched();
2831
2895
  output = await driver.read(slot, PANE_READ_ROWS);
2832
2896
  }
2833
2897
  // The returning paths report the same fact on the result; both callbacks land on the one
2834
2898
  // latch, and the second is a no-op. A seed that answered is never re-answered by the loop.
2835
2899
  if (seedResult?.trustAnswered)
2836
2900
  noteSeedTrustAnswered();
2837
- if (seedResult?.seedFailed) {
2901
+ startupFailure = WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(output));
2902
+ if (seedResult?.seedFailed || startupFailure) {
2838
2903
  finished = false;
2839
2904
  }
2840
2905
  else {
@@ -2922,7 +2987,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2922
2987
  throw error;
2923
2988
  if (!paneReadHeld) {
2924
2989
  paneReadHeld = true;
2925
- journal.append("worker-dead-held", t.id, {
2990
+ noteWorkerLiveness("worker-dead-held", {
2926
2991
  slot: slot.name, attempt, reason: "pane-read-unreadable",
2927
2992
  error: error instanceof Error ? error.message : String(error),
2928
2993
  });
@@ -2935,6 +3000,11 @@ export async function runDaemon(repoRoot, opts = {}) {
2935
3000
  }
2936
3001
  if (paneText.length > 0)
2937
3002
  everHadOutput = true;
3003
+ if (WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(paneText))) {
3004
+ startupFailure = true;
3005
+ output = paneText;
3006
+ break;
3007
+ }
2938
3008
  // OBS-117 (v1.71 T6): zero raw output by the early-launch deadline is a dead channel now.
2939
3009
  if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
2940
3010
  earlyLaunchDead = true;
@@ -3008,7 +3078,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3008
3078
  catch (error) {
3009
3079
  if (!paneStatusHeld) {
3010
3080
  paneStatusHeld = true;
3011
- journal.append("worker-dead-held", t.id, {
3081
+ noteWorkerLiveness("worker-dead-held", {
3012
3082
  slot: slot.name, attempt, reason: "pane-status-unreadable",
3013
3083
  error: error instanceof Error ? error.message : String(error),
3014
3084
  });
@@ -3085,7 +3155,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3085
3155
  paneAbsent = false;
3086
3156
  if (!paneReadHeld) {
3087
3157
  paneReadHeld = true;
3088
- journal.append("worker-dead-held", t.id, {
3158
+ noteWorkerLiveness("worker-dead-held", {
3089
3159
  slot: slot.name, attempt, reason: "pane-read-unreadable",
3090
3160
  error: error instanceof Error ? error.message : String(error),
3091
3161
  });
@@ -3109,7 +3179,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3109
3179
  paneAbsent = false;
3110
3180
  if (!paneReadHeld) {
3111
3181
  paneReadHeld = true;
3112
- journal.append("worker-dead-held", t.id, {
3182
+ noteWorkerLiveness("worker-dead-held", {
3113
3183
  slot: slot.name, attempt, reason: "pane-read-unreadable",
3114
3184
  error: error instanceof Error ? error.message : String(error),
3115
3185
  });
@@ -3133,7 +3203,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3133
3203
  if (deathCertain) {
3134
3204
  const preservation = await preserveDeadWorker(wt, taskBase);
3135
3205
  if (!preservation.ref) {
3136
- journal.append("worker-dead-held", t.id, {
3206
+ noteWorkerLiveness("worker-dead-held", {
3137
3207
  slot: slot.name, attempt, reason: `worktree-${preservation.state}`,
3138
3208
  });
3139
3209
  continue;
@@ -3142,7 +3212,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3142
3212
  const reason = `worker is unambiguously dead: pane absent, process tree empty, and worktree unchanged; preserved at ${ref}`;
3143
3213
  deadWorkerPark = { ref, reason };
3144
3214
  journal.append("worktree-preserved", t.id, { ref });
3145
- journal.append("worker-dead-held", t.id, {
3215
+ noteWorkerLiveness("worker-dead-held", {
3146
3216
  slot: slot.name, attempt, reason: "unambiguous-worker-death", ref,
3147
3217
  });
3148
3218
  break;
@@ -3158,8 +3228,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3158
3228
  // Everything the fast-kill can decide from the CHANNEL, evaluated before the CPU leg is
3159
3229
  // asked for anything: the kill's own probe costs a `ps` every 100ms, so an ineligible
3160
3230
  // slice must not pay for it. The CPU reading is consulted at the kill itself, below.
3161
- const fastKillEligible = !stallProgress.rowSignalSaturated
3162
- && !nudgePending && !nudgeFailed
3231
+ const fastKillEligible = !nudgePending && !nudgeFailed
3163
3232
  && sliceNow - lastOutputGrowthAt >= deadChannelFastKillMs
3164
3233
  && worktreeSinceLaunch === "unchanged";
3165
3234
  const harvestCpuEligible = !nudgePending && !nudgeFailed
@@ -3200,14 +3269,10 @@ export async function runDaemon(repoRoot, opts = {}) {
3200
3269
  // (an elapsed "9s"→"10s" lengthens the read and would hide a frozen pane). But the
3201
3270
  // tracker SHARES the read window's ceiling: its row signal saturates once a sample
3202
3271
  // FILLS a PANE_READ_ROWS read on raw lines (blanks/chrome included — see stall.ts),
3203
- // and past that point a flat tracker means "unmeasurable", not "dead". For unmetered
3204
- // adapters (codex, cursor-agent, grok, opencode — no contextUsage, so the token signal
3205
- // never fires) rows are the ONLY
3206
- // signal, and those are exactly the non-nudgeable adapters this kill governs — a live
3207
- // worker past the ceiling would be concluded dead mid-work. So the kill STANDS DOWN on
3208
- // a saturated row signal (journaled once per attempt); the rolling window still owns
3209
- // that pane, exactly as pre-T1. Token growth counts as life either way, so a metered
3210
- // 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.
3211
3276
  // The triad has NO status exemption: a pane that herdr reports as blocked, idle, working
3212
3277
  // or unknown dies alike once it holds no trailer, an unchanged tree and no growth — waiting the
3213
3278
  // rolling window out on a status reading is exactly the blindness T1 removes. A matched
@@ -3221,7 +3286,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3221
3286
  // filesystem/git observation answer that clause.
3222
3287
  if (stallProgress.rowSignalSaturated && !rowSaturationHeld) {
3223
3288
  rowSaturationHeld = true;
3224
- 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
+ });
3225
3292
  }
3226
3293
  // OBS-548: the FOURTH leg, and the one the daemon already measured. The three legs above
3227
3294
  // are all channel-side: they say a pane stopped talking. The tree's CPU says whether
@@ -3238,12 +3305,13 @@ export async function runDaemon(repoRoot, opts = {}) {
3238
3305
  journal.append("worker-dead", t.id, {
3239
3306
  slot: slot.name, attempt, silentMs: sliceNow - lastOutputGrowthAt,
3240
3307
  cpuMs: leg.cpu.ms, cpuResolutionMs: leg.cpu.resolutionMs,
3308
+ source: trackedDriver.readSource ?? "driver.read",
3241
3309
  });
3242
3310
  break;
3243
3311
  }
3244
3312
  if (!cpuHeld) {
3245
3313
  cpuHeld = true;
3246
- journal.append("worker-dead-held", t.id, {
3314
+ noteWorkerLiveness("worker-dead-held", {
3247
3315
  slot: slot.name, attempt, reason: leg.state === "accruing" ? "cpu-accruing" : "cpu-unmeasurable",
3248
3316
  });
3249
3317
  }
@@ -3371,11 +3439,12 @@ export async function runDaemon(repoRoot, opts = {}) {
3371
3439
  continue attempts;
3372
3440
  return;
3373
3441
  }
3374
- noteLaunched();
3442
+ await noteLaunched();
3375
3443
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
3376
3444
  // v1.76: same monotonic-progress measure as the interactive site; harvest stays raw.
3377
3445
  const stallWindowMs = taskTimeoutMinutes * 60_000;
3378
3446
  const initialPane = await driver.read(slot, 500);
3447
+ startupFailure = WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(initialPane));
3379
3448
  let everHadOutput = initialPane.length > 0;
3380
3449
  const stallProgress = new StallProgressTracker();
3381
3450
  stallProgress.observe({ paneText: initialPane, seedSubmitted: true, contextTokens });
@@ -3388,7 +3457,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3388
3457
  // worker-result-harvested row. The interactive site has always kept them apart (`finished`
3389
3458
  // there is the trailer regex; the exit marker only sets exitCode), and the cause taxonomy
3390
3459
  // already names this shape "clean-exit-no-trailer" — unreachable in print mode until now.
3391
- while (Date.now() - lastProgressAt < stallWindowMs) {
3460
+ while (!startupFailure && Date.now() - lastProgressAt < stallWindowMs) {
3392
3461
  const remaining = stallWindowMs - (Date.now() - lastProgressAt);
3393
3462
  let slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
3394
3463
  if (!everHadOutput) {
@@ -3405,6 +3474,10 @@ export async function runDaemon(repoRoot, opts = {}) {
3405
3474
  const paneText = await driver.read(slot, 500);
3406
3475
  if (paneText.length > 0)
3407
3476
  everHadOutput = true;
3477
+ if (WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(paneText))) {
3478
+ startupFailure = true;
3479
+ break;
3480
+ }
3408
3481
  if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
3409
3482
  earlyLaunchDead = true;
3410
3483
  break;
@@ -3466,7 +3539,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3466
3539
  }
3467
3540
  let result = settleParsed ?? adapter.parse(output, nonce);
3468
3541
  const workerFinished = finished;
3469
- 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 });
3470
3545
  journal.append("worker-result", t.id, {
3471
3546
  ok: result.ok, summary: result.summary, deviations: result.deviations, finished: workerFinished, exitCode,
3472
3547
  mode: interactive ? "interactive" : "print", ...(workerCause ? { cause: workerCause } : {}),
@@ -3583,7 +3658,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3583
3658
  // T2 review: classify the PRE-HARVEST parse — classifyDeadChannel bails on any ok:true
3584
3659
  // result, so reading the synthesized harvest result would swallow auth-required /
3585
3660
  // setup-required / provider-outage for every committed-but-walled attempt (in both modes).
3586
- const dead = classifyDeadChannel(preHarvestResult) ?? (earlyLaunchDead ? "setup-required" : undefined);
3661
+ const dead = classifyDeadChannel(preHarvestResult) ?? (earlyLaunchDead || startupFailure ? "setup-required" : undefined);
3587
3662
  if (dead) {
3588
3663
  const from = channelKey(assignment);
3589
3664
  demotedChannels.add(from); // excluded for later attempts AND later tasks in this run
@@ -3690,6 +3765,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3690
3765
  gateLoop: while (true) {
3691
3766
  const gated = await gitHead(wt);
3692
3767
  gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
3768
+ await trackedDriver.project?.(t.id, "in-review");
3693
3769
  journal.phaseStart(t.id, "gates");
3694
3770
  ({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runGates(t, {
3695
3771
  worktree: wt, baseRef: taskBase, result, author: assignment,
@@ -3713,6 +3789,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3713
3789
  }
3714
3790
  : undefined,
3715
3791
  excludeReviewers: badReviewers,
3792
+ reviewHistory,
3716
3793
  onGate,
3717
3794
  })));
3718
3795
  results.forEach(classifySignalOnlyTest);
@@ -3751,6 +3828,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3751
3828
  attempts: attempt + 1, assignment, taskContentDigest: contentDigest,
3752
3829
  });
3753
3830
  journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
3831
+ await trackedDriver.project?.(t.id, "completed");
3754
3832
  // firstAttemptOk/gateFails/consults are recorded FACTS, not policy — a parkKind:"stall" row is
3755
3833
  // recorded but NOT quality-negative in v1.6; Phase 12 owns reward policy, so flipping it later needs zero data migration.
3756
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 });
package/dist/run/git.d.ts CHANGED
@@ -90,6 +90,8 @@ export interface ShResult {
90
90
  stdout: string;
91
91
  stderr: string;
92
92
  timedOut?: boolean;
93
+ /** The shell exited, but descendants still held its process group and pipes past the grace. */
94
+ reapedGroup?: boolean;
93
95
  durationMs?: number;
94
96
  /** T7: the capacity THIS child ran under, stamped where its environment was built (see `shell`). */
95
97
  capacity?: RunCapacity;
package/dist/run/git.js CHANGED
@@ -138,6 +138,9 @@ export const resetSpawnForTests = () => { spawnChild = undefined; };
138
138
  const RETRYABLE_SPAWN_CODE = "EAGAIN";
139
139
  export const SPAWN_ATTEMPT_LIMIT = 4;
140
140
  export const SPAWN_RETRY_BACKOFF_MS = 50;
141
+ // RULING-P99-28: SHIP — every gate user can hit the inherited-pipe hang at this product seam.
142
+ /** Give a normally-exited shell's descendants this long to close their inherited pipes themselves. */
143
+ const SHELL_REAP_GRACE_MS = 2000;
141
144
  // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
142
145
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
143
146
  function shell(cmd, cwd, timeoutMs, login) {
@@ -170,15 +173,25 @@ function shell(cmd, cwd, timeoutMs, login) {
170
173
  let stdout = "", stderr = "";
171
174
  const stdoutDecoder = new StringDecoder("utf8");
172
175
  const stderrDecoder = new StringDecoder("utf8");
173
- let timedOut = false, done = false, started = false, outputSeen = false;
176
+ let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
177
+ let reapTimer;
174
178
  const finish = (code, err) => {
175
179
  if (done)
176
180
  return;
177
181
  done = true;
178
182
  clearTimeout(timer);
183
+ clearTimeout(reapTimer);
179
184
  stdout += stdoutDecoder.end();
180
185
  stderr += stderrDecoder.end();
181
- resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt, capacity });
186
+ resolve({
187
+ code,
188
+ stdout,
189
+ stderr: err ?? stderr,
190
+ timedOut,
191
+ durationMs: Date.now() - startedAt,
192
+ capacity,
193
+ ...(reapedGroup ? { reapedGroup: true } : {}),
194
+ });
182
195
  };
183
196
  const timer = setTimeout(() => {
184
197
  timedOut = true;
@@ -215,9 +228,27 @@ function shell(cmd, cwd, timeoutMs, login) {
215
228
  finish(127, String(e));
216
229
  });
217
230
  p.on("close", (code) => finish(code ?? 1));
218
- // "close" waits for stdio to drain; a surviving pipe-holder must not outlive the timeout
219
- p.on("exit", (code) => { if (timedOut)
220
- finish(code ?? 1); });
231
+ // "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
232
+ // to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
233
+ // cannot hold this promise until the command ceiling. A real timeout wins first and is never
234
+ // reclassified as a grace reap.
235
+ p.on("exit", (code) => {
236
+ if (timedOut) {
237
+ finish(code ?? 1);
238
+ return;
239
+ }
240
+ reapTimer = setTimeout(() => {
241
+ if (done)
242
+ return;
243
+ try {
244
+ process.kill(-p.pid, "SIGKILL");
245
+ reapedGroup = true;
246
+ }
247
+ catch {
248
+ // The group closed between the shell's exit and the grace boundary; "close" owns finish.
249
+ }
250
+ }, SHELL_REAP_GRACE_MS);
251
+ });
221
252
  });
222
253
  return (async () => {
223
254
  const startedAt = Date.now();
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
6
6
  import { stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
7
7
  import { GATE_NAMES, TIERS } from "../graph/schema.js";
8
+ import { channelRouteIdentity } from "../route/preference.js";
8
9
  import { buildProfile, classify } from "../route/profile.js";
9
10
  import { DecisionEventSchema, trackJournalRows, } from "./protocol.js";
10
11
  import { normalizeGateOutcome } from "./outcome.js";
@@ -752,7 +753,9 @@ export function activeRetryBan(events, taskId, channel) {
752
753
  || (e.event === "task-approved" && e.data.release === RECHECK_RELEASE))
753
754
  pending = undefined;
754
755
  }
755
- return pending && pending.data.channel === channel && typeof pending.data.gate === "string"
756
+ return pending && typeof pending.data.channel === "string"
757
+ && channelRouteIdentity(pending.data.channel) === channelRouteIdentity(channel)
758
+ && typeof pending.data.gate === "string"
756
759
  ? pending.data.gate
757
760
  : undefined;
758
761
  }