tickmarkr 2.5.3 → 2.5.4

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.
@@ -31,8 +31,9 @@ import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferre
31
31
  import { isDiffCapPark, pickReviewer } from "../gates/review.js";
32
32
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
33
33
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
34
- import { nextChannel, route } from "../route/router.js";
34
+ import { climbChannel, marginalCostRank, nextChannel, route } from "../route/router.js";
35
35
  import { desiredPanes } from "./reconcile.js";
36
+ import { readTierLiveness, readWatchBoard, supervisionBeatPath } from "./supervision.js";
36
37
  import { harvestCpuFlatWindowMs, NUDGEABLE_ADAPTERS, PANE_READ_ROWS, StallProgressTracker, stallSnapshotBannerRows, WorkerTreeCpuAccountant, } from "./stall.js";
37
38
  // The live set is also the ownership claim shared by task cleanup and termination.
38
39
  export async function closeLiveSlot(liveSlots, driver, slot) {
@@ -1358,7 +1359,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1358
1359
  return await runWithVerificationBudget(occupancyCapacity, async () => {
1359
1360
  // v1.51 T4: every dispatch provenance line begins with the mode and its source; when a pin won
1360
1361
  // the route (the final "→ " segment is a pin, not a degraded-to-auto tail) it names the mode it bypassed.
1361
- const dispatchProvenance = (p) => `mode ${rm.mode.mode} (${rm.source})${p.split("→ ").pop().startsWith("pin ") ? ` — pin bypasses mode ${rm.mode.mode}` : ""} · ${p}`;
1362
+ const dispatchProvenance = (p) => `mode ${rm.mode.mode} (${rm.source})${p.split("→ ").pop().startsWith("pin ") && !p.includes("not re-tried") ? ` — pin bypasses mode ${rm.mode.mode}` : ""} · ${p}`;
1362
1363
  // v1.87 T2: one pool per seat role, built once. `channels` stays the WORKER pool — every routing
1363
1364
  // call below reads it exactly as before — while the judge, review and consult seats each receive
1364
1365
  // the pool their own deny scope allows, so routing.deny.workers benches a channel for dispatch
@@ -1395,6 +1396,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1395
1396
  liveSlots.add(s);
1396
1397
  return s;
1397
1398
  } } : {}),
1399
+ ...(driver.retireLostWatch ? { retireLostWatch: driver.retireLostWatch.bind(driver) } : {}),
1398
1400
  ...(driver.project ? { project: driver.project.bind(driver) } : {}),
1399
1401
  ...(driver.reconcile ? { reconcile: driver.reconcile.bind(driver) } : {}),
1400
1402
  notify: (m, o) => driver.notify(m, o),
@@ -1468,6 +1470,15 @@ export async function runDaemon(repoRoot, opts = {}) {
1468
1470
  const reviewHistory = journal.read()
1469
1471
  .filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
1470
1472
  .map((event) => event.data.reviewer);
1473
+ // RF-1 (OBS-922 add.3): the seats THIS task's review rows name — the prior reviewers a later round's
1474
+ // floor holds. Task-scoped on purpose: reviewHistory rotates seats run-wide and is never floor evidence.
1475
+ // Each row's journaled dispatch tier travels with it, so a seat that left the pool on resume still counts.
1476
+ // Leg-2 T4 M2: a `review-no-verdict` note is dispatch evidence too — it lands BEFORE the in-gate retry's
1477
+ // gate-result, so a daemon killed between the two leaves it as the task's only record of that seat.
1478
+ const taskReviewers = (taskId) => journal.read()
1479
+ .filter((event) => event.taskId === taskId && typeof event.data.reviewer === "string"
1480
+ && ((event.event === "gate-result" && event.data.gate === "review") || event.event === "review-no-verdict"))
1481
+ .map((event) => ({ reviewer: event.data.reviewer, tier: event.data.reviewerTier }));
1471
1482
  // Capture this before this observer appends run-resume. A prior run-end belongs to a completed
1472
1483
  // lifecycle and must use the ordinary resume/redispatch rules; only an interrupted live attempt
1473
1484
  // owns reusable measurements or unclean-death residue.
@@ -1499,19 +1510,99 @@ export async function runDaemon(repoRoot, opts = {}) {
1499
1510
  const commands = detectGateCommands(repoRoot, cfg);
1500
1511
  const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
1501
1512
  let watchSlot;
1502
- const openBoard = async () => {
1513
+ let boardOpened = false; // WB-1: only a board this daemon placed is watched below
1514
+ // Leg-2 T7 M1 (OBS-988): a reopen first retires the ghost through the driver's seam so the narrator
1515
+ // launches a NEW pane; a driver that answers with the lost pane anyway (no seam, or a cache it
1516
+ // would not drop) has not reopened anything, and that answer is a FAILED reopen, never a success.
1517
+ const openBoard = async (ghost) => {
1518
+ try {
1519
+ if (ghost?.slot)
1520
+ await trackedDriver.retireLostWatch?.(ghost.slot);
1521
+ const slot = await trackedDriver.narrator(repoRoot, watchCommand(runId), runId);
1522
+ if (ghost && slot.id === ghost.pane) {
1523
+ liveSlots.delete(slot); // the ghost again: no close is ever aimed at it
1524
+ return { ok: false, error: `narrator answered with the lost pane ${ghost.pane}` };
1525
+ }
1526
+ watchSlot = slot;
1527
+ boardOpened = true;
1528
+ return { ok: true, slot };
1529
+ }
1530
+ catch (error) {
1531
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1532
+ }
1533
+ };
1534
+ const placeBoard = async () => {
1503
1535
  if (!trackedDriver.narrator) {
1504
1536
  noteCapabilityAbsent("narrator");
1537
+ return undefined;
1538
+ }
1539
+ const placed = await openBoard();
1540
+ if (placed.ok)
1541
+ return placed.slot;
1542
+ journal.append("watch-placement-failed", undefined, { error: placed.error });
1543
+ console.error(`tickmarkr: narrator not opened: ${placed.error}`);
1544
+ return undefined;
1545
+ };
1546
+ // WB-1 (OBS-988): the daemon watches its own cockpit. A board is lost when the watch tier's beat
1547
+ // aged past the supervision stale bound, the recorded arm has no presence, or the owner pid is dead.
1548
+ // The beat and presence checks wait for the UI to have armed (armId recorded) — a freshly reopened
1549
+ // board that has not armed yet is not a second loss. Reopens are bounded per run; past the bound
1550
+ // the loss is journaled `boardless` and the narrator is never called again.
1551
+ // Leg-2 T7 M2: one `watch-board-lost` row per LOSS. A loss is identified by the pane and pid the
1552
+ // owner record names; a reopen that fails leaves that record in place, so the next poll sees the
1553
+ // same loss, journals only its own reopen attempt, and the bound retires the run boardless from the
1554
+ // last failed attempt rather than from a repeated lost row.
1555
+ const MAX_BOARD_REOPENS = 3;
1556
+ let boardReopens = journal.read().filter((e) => e.event === "watch-board-reopened" || e.event === "watch-board-reopen-failed").length;
1557
+ let boardless = false;
1558
+ let journaledLoss;
1559
+ const boardLoss = () => {
1560
+ const owner = readWatchBoard(repoRoot, runId);
1561
+ if (!owner)
1562
+ return undefined;
1563
+ const lost = { pane: owner.pane, ...(owner.pid !== undefined ? { pid: owner.pid } : {}) };
1564
+ if (owner.armId !== undefined) {
1565
+ const beat = readTierLiveness(repoRoot, "watch");
1566
+ if (beat.state === "STALE")
1567
+ return { ...lost, beatAgeMs: beat.beatAgeMs };
1568
+ // ponytail: presence path math mirrors supervision.ts's private helper (`<tier>.live.<armId>`)
1569
+ if (!existsSync(join(dirname(supervisionBeatPath(repoRoot, "watch")), `watch.live.${owner.armId}`)))
1570
+ return lost;
1571
+ }
1572
+ if (owner.pid !== undefined && !isPidLive(owner.pid))
1573
+ return lost;
1574
+ return undefined;
1575
+ };
1576
+ const watchBoard = async () => {
1577
+ if (!boardOpened || boardless || !trackedDriver.narrator)
1578
+ return;
1579
+ const loss = boardLoss();
1580
+ if (!loss) {
1581
+ journaledLoss = undefined;
1505
1582
  return;
1506
1583
  }
1507
- try {
1508
- watchSlot = await trackedDriver.narrator(repoRoot, watchCommand(runId), runId);
1584
+ const identity = `${loss.pane}:${loss.pid ?? ""}`;
1585
+ if (identity !== journaledLoss) {
1586
+ journaledLoss = identity;
1587
+ boardless = boardReopens >= MAX_BOARD_REOPENS;
1588
+ journal.append("watch-board-lost", undefined, { ...loss, ...(boardless ? { boardless: true } : {}) });
1589
+ if (boardless)
1590
+ return;
1509
1591
  }
1510
- catch (error) {
1511
- const reason = error instanceof Error ? error.message : String(error);
1512
- journal.append("watch-placement-failed", undefined, { error: reason });
1513
- console.error(`tickmarkr: narrator not opened: ${reason}`);
1592
+ const attempt = ++boardReopens;
1593
+ // The ghost pane is gone: drop it from this run's live set so no close is ever aimed at it.
1594
+ const ghost = watchSlot;
1595
+ if (ghost)
1596
+ liveSlots.delete(ghost);
1597
+ watchSlot = undefined;
1598
+ const reopened = await openBoard({ slot: ghost, pane: loss.pane });
1599
+ if (reopened.ok) {
1600
+ journal.append("watch-board-reopened", undefined, { pane: readWatchBoard(repoRoot, runId)?.pane ?? reopened.slot.id, attempt });
1601
+ return;
1514
1602
  }
1603
+ boardless = attempt >= MAX_BOARD_REOPENS;
1604
+ journal.append("watch-board-reopen-failed", undefined, { pane: loss.pane, attempt, error: reopened.error, ...(boardless ? { boardless: true } : {}) });
1605
+ console.error(`tickmarkr: board not reopened (attempt ${attempt}): ${reopened.error}`);
1515
1606
  };
1516
1607
  // OBS-829/OBS-854: one full-suite verdict round at a time in this run, and do not begin beside an
1517
1608
  // externally live suite attributable to this repository. The process scan catches nested scratch
@@ -1632,12 +1723,12 @@ export async function runDaemon(repoRoot, opts = {}) {
1632
1723
  ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
1633
1724
  ...(opts.retryFailed ? { retryFailed: true } : {}),
1634
1725
  });
1635
- await openBoard();
1726
+ await placeBoard();
1636
1727
  }
1637
1728
  else {
1638
1729
  baseRef = await gitHead(repoRoot);
1639
1730
  journal.append("baseline-start", undefined, { baseRef, commands, capacity: resolvedCapacity() });
1640
- await openBoard();
1731
+ await placeBoard();
1641
1732
  baselinePending = true;
1642
1733
  // Workers can run beside capture, but no gate may observe an absent or partial baseline.
1643
1734
  // Keep publication and warnings inside the same barrier as the suite's final verdict.
@@ -1773,15 +1864,17 @@ export async function runDaemon(repoRoot, opts = {}) {
1773
1864
  ];
1774
1865
  return `${identity} — release with ${commands.map((command) => `\`${command}\``).join(" or ")}`;
1775
1866
  };
1867
+ const namedPaths = (text) => [...text.matchAll(/(?:^|[\s`'"(])((?:[A-Za-z0-9_@.()[\]-]+\/)+[A-Za-z0-9_@.[\]-]+|[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9_-]+)+)(?=$|[\s`'"),:;.!?])/g)]
1868
+ .map((match) => match[1].replace(/^\.\//, "").replace(/\.$/, ""))
1869
+ .filter((path) => !path.split("/").includes("..")
1870
+ && (/\.[A-Za-z][A-Za-z0-9_-]*$/.test(basename(path)) || existsSync(join(repoRoot, path))));
1776
1871
  const park = async (t, reason, kind, assignment, attempts, startMs, gateFails = 0, consults = 0, tokens, metered = 0, retryMode = "fresh", details = {}) => {
1777
1872
  // OBS-979: a worker refusal can identify the missing authoring scope even when gates
1778
1873
  // subsequently supply the park's disposition. Keep that actionable path on the park itself.
1779
1874
  const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
1780
1875
  if (t.files.length > 0 && worker?.data.ok === false && typeof worker.data.summary === "string") {
1781
1876
  const allowed = filesGlob(t.files);
1782
- const paths = [...worker.data.summary.matchAll(/(?:^|[\s`'"(])((?:[A-Za-z0-9_@.()[\]-]+\/)+[A-Za-z0-9_@.[\]-]+|[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9_-]+)+)(?=$|[\s`'"),:;.!?])/g)]
1783
- .map((match) => match[1].replace(/^\.\//, "").replace(/\.$/, ""))
1784
- .filter((path) => !path.split("/").includes("..") && !allowed(path));
1877
+ const paths = namedPaths(worker.data.summary).filter((path) => !allowed(path));
1785
1878
  if (paths.length)
1786
1879
  reason += ` — files[] repair hint: ${[...new Set(paths)].join(", ")}`;
1787
1880
  }
@@ -1814,6 +1907,30 @@ export async function runDaemon(repoRoot, opts = {}) {
1814
1907
  const dispositionScopeRed = async (t, results, assignment, attempts, startMs, gateFails, consults, tokens, metered, retryMode) => {
1815
1908
  const scopeRed = results.find((g) => g.gate === "scope" && gateFailed(g));
1816
1909
  const verdict = scopeRed?.meta?.collateral;
1910
+ if (!verdict && t.files.length > 0) {
1911
+ const allowed = filesGlob(t.files);
1912
+ const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
1913
+ const refusal = worker?.data.ok === false && typeof worker.data.summary === "string"
1914
+ && /outside|out.of.scope|allowlist|scope expansion|not (?:in|own)|unowned/i.test(worker.data.summary)
1915
+ ? namedPaths(worker.data.summary).filter((path) => !allowed(path)) : [];
1916
+ const reds = results.filter(gateFailed);
1917
+ // A scope verdict already attributes actual out-of-scope EDITS using the collateral map.
1918
+ // Path-only diagnostics from the other gates name code that needs fixing, not an edit the
1919
+ // worker made. Do not replace the scope gate's stronger attribution with that inference.
1920
+ const paths = reds.filter((g) => g.gate !== "scope" || !g.meta?.collateral).flatMap((g) => namedPaths(g.details));
1921
+ if (refusal.length || (paths.length > 0 && paths.every((path) => !allowed(path)))) {
1922
+ journal.append("scope-authoring", t.id, {
1923
+ gate: refusal.length ? "worker" : reds[0]?.gate,
1924
+ paths: [...new Set(refusal.length ? refusal : paths)],
1925
+ repair: `files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}`,
1926
+ attempt: attempts + 1, chargeable: false, source: "diagnostic",
1927
+ });
1928
+ // OBS-547 (Leg-2 T3 material): unchargeable ⇒ metered 0, exactly as the predicted path below —
1929
+ // passing the physical count would write `meteredAttempts: 1` beside `attempts: 0`.
1930
+ await park(t, `authoring defect — files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}; current files[]: ${t.files.join(", ")}`, "authoring", assignment, attempts, startMs, gateFails, consults, tokens, 0, retryMode);
1931
+ return true;
1932
+ }
1933
+ }
1817
1934
  if (!verdict)
1818
1935
  return false;
1819
1936
  if (verdict.authoring) {
@@ -1932,12 +2049,28 @@ export async function runDaemon(repoRoot, opts = {}) {
1932
2049
  // not re-tried first (consult bans / prior failovers survive the release).
1933
2050
  const rs = resume.get(t.id);
1934
2051
  const contentDigest = taskContentDigest(t);
1935
- if (rs?.lastAssignment && rs.attempts > 0
2052
+ const previousDispatch = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "task-dispatch");
2053
+ const recordedGraphPath = join(journal.dir, "graph.json");
2054
+ const recordedTask = existsSync(recordedGraphPath)
2055
+ ? JSON.parse(readFileSync(recordedGraphPath, "utf8")).tasks.find((task) => task.id === t.id)
2056
+ : undefined;
2057
+ const previousHints = previousDispatch && "routingHints" in previousDispatch.data
2058
+ ? previousDispatch.data.routingHints : recordedTask?.routingHints;
2059
+ const hintsChanged = !!rs && (!!recordedTask || !!previousDispatch && "routingHints" in previousDispatch.data)
2060
+ && (JSON.stringify(previousHints?.pin) !== JSON.stringify(t.routingHints?.pin)
2061
+ || previousHints?.floor !== t.routingHints?.floor);
2062
+ if (hintsChanged)
2063
+ journal.append("restore-rerouted", t.id, {
2064
+ from: rs.lastAssignment ? channelKey(rs.lastAssignment) : rs.tried.at(-1) ?? null,
2065
+ to: channelKey(assignment),
2066
+ reason: JSON.stringify(previousHints?.pin) !== JSON.stringify(t.routingHints?.pin) ? "pin changed" : "floor changed",
2067
+ });
2068
+ if (!hintsChanged && rs?.lastAssignment && rs.attempts > 0
1936
2069
  && channels.some((c) => channelKey(c) === channelKey(rs.lastAssignment))
1937
2070
  && !demotedChannels.has(channelKey(rs.lastAssignment))) {
1938
2071
  assignment = rs.lastAssignment; // restore the consult-chosen assignment (bypasses route()'s static re-pick)
1939
2072
  }
1940
- else if (rs && rs.tried.length) {
2073
+ else if (!hintsChanged && rs && rs.tried.length) {
1941
2074
  // trailing-reroute edge (kill between verdict and dispatch), a stale fleet, OR a fresh-budget
1942
2075
  // release (attempts 0 + non-empty tried): pick a failover over the replayed exclusions via the
1943
2076
  // EXISTING nextChannel `tried` parameter — zero router changes (D-03).
@@ -1948,6 +2081,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1948
2081
  // assignment and proceed. Dispatching on a previously-tried channel beats deadlocking a resumed
1949
2082
  // run; a park-instead policy can come later if it ever bites.
1950
2083
  }
2084
+ const taskHistory = journal.read().filter((e) => e.taskId === t.id);
2085
+ const lastApproval = taskHistory.map((e) => e.event).lastIndexOf("task-approved");
2086
+ const priorClimb = taskHistory.slice(lastApproval + 1).reverse().find((e) => e.event === "tier-escalated");
2087
+ if (!hintsChanged && priorClimb && taskHistory.indexOf(priorClimb) > taskHistory.map((e) => e.event).lastIndexOf("task-dispatch")) {
2088
+ const target = channels.find((c) => channelKey(c) === priorClimb.data.to && !demotedChannels.has(channelKey(c)));
2089
+ if (target)
2090
+ assignment = { adapter: target.adapter, model: target.model, channel: target.channel, tier: target.tier };
2091
+ }
1951
2092
  // pre-kill invariant: tried always contains the current assignment. Spread, never alias the
1952
2093
  // journal-derived array (no hidden mutation of replayed state).
1953
2094
  const tried = rs?.tried.length ? [...rs.tried] : [channelKey(assignment)];
@@ -2070,7 +2211,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2070
2211
  ...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
2071
2212
  ...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
2072
2213
  reviewer: g.meta.reviewer,
2073
- ...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised"]
2214
+ ...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised", "reviewerFloor", "reviewerFloorCause", "reviewerTier"]
2074
2215
  .filter((key) => g.meta?.[key] !== undefined).map((key) => [key, g.meta[key]])),
2075
2216
  ...(typeof g.meta.vendor === "string" ? { vendor: g.meta.vendor } : {}),
2076
2217
  ...(typeof g.meta.provider === "string" ? { provider: g.meta.provider } : {}),
@@ -2170,6 +2311,43 @@ export async function runDaemon(repoRoot, opts = {}) {
2170
2311
  ? `The operator UPHELD the reviewer's findings — address them without discarding landed work.\nreview: ${upheldFeedback}`
2171
2312
  : "");
2172
2313
  let ladderIdx = 0;
2314
+ const engagementRows = () => {
2315
+ const rows = journal.read().filter((e) => e.taskId === t.id);
2316
+ const approval = rows.map((e) => e.event).lastIndexOf("task-approved");
2317
+ return rows.slice(approval + 1);
2318
+ };
2319
+ let ownedGateFailures = engagementRows().filter((e) => e.event === "escalation" && e.data.workerAttributed === true).length;
2320
+ let climbProvenance = !hintsChanged && priorClimb
2321
+ ? `tier-escalated ${priorClimb.data.from} → ${priorClimb.data.to} (${priorClimb.data.cause})` : "";
2322
+ const climbSkips = new Set(!hintsChanged && priorClimb
2323
+ ? channels.filter((c) => c.tier === priorClimb.data.fromTier).map(channelKey) : []);
2324
+ const climb = async (cause, gate, attempt) => {
2325
+ if (cfg.routing.escalateTier === "off" || engagementRows().some((e) => e.event === "tier-escalated"))
2326
+ return;
2327
+ const pin = t.routingHints?.pin;
2328
+ if (pin) {
2329
+ await park(t, `pin ${pin.via}:${pin.model} held: ${cause}`, "gate-fail", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
2330
+ return "parked";
2331
+ }
2332
+ const next = climbChannel(assignment, t, cfg, channels, tried, profile, demotedChannels);
2333
+ if (!next?.climbed)
2334
+ return;
2335
+ const from = channelKey(assignment);
2336
+ const to = channelKey(next);
2337
+ const poolBefore = channels.filter((c) => !tried.includes(channelKey(c)) && !demotedChannels.has(channelKey(c))).map(channelKey);
2338
+ journal.append("tier-escalated", t.id, {
2339
+ attempt, cause, gate: gate.gate, fingerprint: normalizeGateFailure(gate.details).slice(0, 500),
2340
+ from, to, fromTier: assignment.tier, toTier: next.tier, poolBefore,
2341
+ costDelta: marginalCostRank(channels.find((c) => channelKey(c) === to)) - marginalCostRank(channels.find((c) => channelKey(c) === from)),
2342
+ });
2343
+ for (const c of channels)
2344
+ if (c.tier === assignment.tier && channelKey(c) !== to)
2345
+ climbSkips.add(channelKey(c));
2346
+ climbProvenance = `tier-escalated ${from} → ${to} (${cause})`;
2347
+ assignment = { adapter: next.adapter, model: next.model, channel: next.channel, tier: next.tier };
2348
+ tried.push(to);
2349
+ return "climbed";
2350
+ };
2173
2351
  let modeFallbackNoted = false; // v1.2: journal the interactive→print fallback once per task, not per attempt
2174
2352
  let gateFails = 0; // TEL-02: incremented ONLY where feedback is built from failing gates — never derived from attempts (quota failovers bump attempts too, Pitfall 6)
2175
2353
  let consults = 0; // TEL-02: bumped in the runConsult wrapper so one counter covers all three trigger sites, across the attempt loop
@@ -2465,7 +2643,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2465
2643
  }
2466
2644
  : undefined,
2467
2645
  excludeReviewers: badReviewers,
2468
- reviewHistory, demotedReviewers,
2646
+ reviewHistory, demotedReviewers, priorReviewers: taskReviewers(t.id),
2469
2647
  onGate: async (e) => {
2470
2648
  if (e.phase === "start") {
2471
2649
  notePhaseStart(e);
@@ -2741,8 +2919,17 @@ export async function runDaemon(repoRoot, opts = {}) {
2741
2919
  // ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
2742
2920
  // spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
2743
2921
  journal.append("task-dispatch", t.id, {
2744
- assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
2745
- excludedChannels: [...demotedChannels].sort(),
2922
+ assignment, attempt, provenance: dispatchProvenance([
2923
+ channelKey(assignment) === channelKey(r.assignment) ? r.provenance
2924
+ : t.routingHints?.pin ? `pin ${t.routingHints.pin.via}:${t.routingHints.pin.model} not re-tried` : "ladder assignment",
2925
+ `dispatch ${channelKey(assignment)}`, climbProvenance,
2926
+ ].filter(Boolean).join(" · ")), retryMode,
2927
+ routingHints: t.routingHints ?? {},
2928
+ excludedChannels: [...new Set([...demotedChannels, ...tried, ...climbSkips])]
2929
+ .filter((key) => key !== channelKey(assignment)).sort(),
2930
+ exclusionReasons: Object.fromEntries([...new Set([...demotedChannels, ...tried, ...climbSkips])]
2931
+ .filter((key) => key !== channelKey(assignment))
2932
+ .map((key) => [key, demotedChannels.has(key) ? "demoted" : tried.includes(key) ? "already tried" : "tier climb skipped"])),
2746
2933
  ...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
2747
2934
  ...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
2748
2935
  });
@@ -3896,6 +4083,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3896
4083
  // (provider outage, quota banner, dead CLI) must still route; the harvest synthesis only
3897
4084
  // decides whether THIS attempt's worktree goes to gates, and when routing wins instead, the
3898
4085
  // commits survive via the existing commitsToCarry/cherryPickCommits carry-forward.
4086
+ if (workerFinished && !result.ok && await dispositionScopeRed(t, [], assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode))
4087
+ return;
3899
4088
  const preHarvestResult = result;
3900
4089
  // T2 (OBS-264): recognize committed no-trailer work BEFORE any no-trailer streak, provider,
3901
4090
  // quota or dead-channel routing. Gates never trusted the trailer, so this successful synthesis
@@ -4208,7 +4397,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4208
4397
  }
4209
4398
  : undefined,
4210
4399
  excludeReviewers: badReviewers,
4211
- reviewHistory, demotedReviewers,
4400
+ reviewHistory, demotedReviewers, priorReviewers: taskReviewers(t.id),
4212
4401
  onGate,
4213
4402
  })));
4214
4403
  }
@@ -4300,6 +4489,9 @@ export async function runDaemon(repoRoot, opts = {}) {
4300
4489
  && !isDiffCapPark(reviewFail)
4301
4490
  && results.every((g) => gateSatisfied(g) || g.gate === "review");
4302
4491
  const failing = results.filter(gateFailed);
4492
+ const deterministic = failing.find(isDeterministicFailure);
4493
+ if (deterministic)
4494
+ ownedGateFailures++;
4303
4495
  // Decided before the cap, because the cap's question is whether the NEXT move would re-buy a
4304
4496
  // measurement already made — and a funded repair is one of the moves that would.
4305
4497
  const landed = await commitsAheadOf(taskBase, wt);
@@ -4341,13 +4533,18 @@ export async function runDaemon(repoRoot, opts = {}) {
4341
4533
  channel: channelKey(assignment), // the ban is bound to the channel that produced the repeat
4342
4534
  attempt: attempt + 1,
4343
4535
  });
4344
- await driver.notify(`tickmarkr ${runId}: ${t.id} ${repeated.gate} failed identically twice — consulting, identical retry banned`, { tier: "attention" });
4536
+ await driver.notify(`tickmarkr ${runId}: ${t.id} ${repeated.gate} failed identically twice — identical retry banned`, { tier: "attention" });
4345
4537
  // The cap takes the ladder's MOVE, never its accounting: this failure still spends the rung it
4346
4538
  // would have spent, so a task that cannot converge still reaches ladder exhaustion on exactly
4347
4539
  // the budget it always had and the cap can never hand a stuck task extra rounds.
4348
4540
  capStep = r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)];
4349
- journal.append("escalation", t.id, { step: capStep, attempt: attempt + 1, fingerprintCap: true });
4541
+ journal.append("escalation", t.id, { step: capStep, attempt: attempt + 1, fingerprintCap: true, workerAttributed: !!deterministic });
4350
4542
  await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${capStep}`, { tier: "attention" });
4543
+ const climbed = await climb("gate-fingerprint-cap", repeated, attempt + 1);
4544
+ if (climbed === "parked")
4545
+ return;
4546
+ if (climbed === "climbed")
4547
+ continue;
4351
4548
  const v = await runConsult("gate-fail-repeat", output, feedback, results);
4352
4549
  // Rule: a terminal cap consult vetoes a same-channel retry, but cannot veto an `escalate`
4353
4550
  // rung that already satisfies retry-same-banned by changing channel. Thus terminal+retry
@@ -4397,12 +4594,21 @@ export async function runDaemon(repoRoot, opts = {}) {
4397
4594
  : r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)]);
4398
4595
  if (!capStep) { // a capped failure already journaled and announced the rung it spent
4399
4596
  journal.append("escalation", t.id, {
4400
- step, attempt: attempt + 1,
4597
+ step, attempt: attempt + 1, workerAttributed: !!deterministic,
4401
4598
  ...(reviewFixRetry && !repairExhausted ? { reviewFix: true } : {}),
4402
4599
  ...(repair ? { repair: repairHistory.length + 1, repairCharge: repairsDrawn + 1 } : {}),
4403
4600
  });
4404
4601
  await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
4405
4602
  }
4603
+ const climbCause = repairExhausted ? "repair-exhausted"
4604
+ : deterministic && ownedGateFailures === 2 ? "gate-fail" : undefined;
4605
+ if (climbCause) {
4606
+ const climbed = await climb(climbCause, deterministic ?? failing[0], attempt + 1);
4607
+ if (climbed === "parked")
4608
+ return;
4609
+ if (climbed === "climbed")
4610
+ continue;
4611
+ }
4406
4612
  if (step === "retry")
4407
4613
  continue;
4408
4614
  if (step === "escalate") {
@@ -4428,14 +4634,18 @@ export async function runDaemon(repoRoot, opts = {}) {
4428
4634
  const inflight = new Map();
4429
4635
  // A settled worker can release a dependency or an approval in the same poll tick.
4430
4636
  // Audit the current graph at every empty-flight boundary, never a prior ready snapshot.
4431
- const holdEndCondition = () => {
4637
+ // WB-1 rider: `end-condition-held` names a CLOSE the daemon refused, so only a close attempt journals
4638
+ // it — the post-race sweep still dispatches what it finds but says nothing.
4639
+ const holdEndCondition = (closing = true) => {
4432
4640
  sweepLiveApprovals();
4433
4641
  const freeSlots = Math.max(0, concurrency - inflight.size);
4434
4642
  const dispatchable = readyTasks(graph).filter((t) => !inflight.has(t.id));
4435
4643
  if (freeSlots === 0 || dispatchable.length === 0)
4436
4644
  return false;
4437
- for (const task of dispatchable) {
4438
- journal.append("end-condition-held", task.id, { deps: task.deps, freeSlots });
4645
+ if (closing) {
4646
+ for (const task of dispatchable) {
4647
+ journal.append("end-condition-held", task.id, { deps: task.deps, freeSlots });
4648
+ }
4439
4649
  }
4440
4650
  return true;
4441
4651
  };
@@ -4446,6 +4656,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4446
4656
  // must still stop the run before it can dispatch more work or write run-end.
4447
4657
  if (termSignal)
4448
4658
  throw new Error(`terminated by ${termSignal}`);
4659
+ await watchBoard();
4449
4660
  sweepLiveApprovals();
4450
4661
  const ready = readyTasks(graph)
4451
4662
  .filter((t) => !inflight.has(t.id))
@@ -4500,12 +4711,13 @@ export async function runDaemon(repoRoot, opts = {}) {
4500
4711
  const waiters = [...inflight.values(), aborted];
4501
4712
  // A free slot is itself a scheduling boundary: poll the append-only approval stream instead of
4502
4713
  // sleeping until an unrelated long-running task settles.
4503
- if (inflight.size < concurrency) {
4714
+ // An open board is polled on the same cadence, so a dead cockpit is noticed mid-task.
4715
+ if (inflight.size < concurrency || boardOpened) {
4504
4716
  waiters.push(new Promise((wake) => setTimeout(wake, APPROVAL_POLL_MS)));
4505
4717
  }
4506
4718
  await Promise.race(waiters); // aborted rejects on termination — unwinds the run
4507
4719
  if (inflight.size === 0)
4508
- holdEndCondition();
4720
+ holdEndCondition(false);
4509
4721
  }
4510
4722
  // D-07: the sweep now closes only what's LEFT in keptSlots — done-closed worker slots were removed
4511
4723
  // (no double-close) and self-cleaned LLM/consult panes were never added under keepLlm:false. This
package/dist/run/git.d.ts CHANGED
@@ -179,3 +179,28 @@ export type BaseContainment = {
179
179
  */
180
180
  export declare function declaredBaseContainment(cwd: string, declaredRef: string, targetRef?: string): Promise<BaseContainment>;
181
181
  export declare function removeWorktree(repo: string, dir: string): Promise<void>;
182
+ export declare const REFS_PREFLIGHT_PREFIX = "refs/tickmarkr/preflight";
183
+ export declare const REFS_PROBE_REMEDY = "run from the main repository or a full clone; a sandbox that denies writes under that path cannot host a run";
184
+ export interface RefsProbeOk {
185
+ ok: true;
186
+ path: string;
187
+ refsDir: string;
188
+ }
189
+ export interface RefsProbeRefusal {
190
+ ok: false;
191
+ path: string;
192
+ refsDir: string;
193
+ error: string;
194
+ }
195
+ export type RefsProbeResult = RefsProbeOk | RefsProbeRefusal;
196
+ export declare function refsRefusalMessage(action: "run" | "resume", path: string, error: string): string;
197
+ /**
198
+ * OBS-983/984: prove the repository's common git directory accepts a ref write before starting
199
+ * the daemon. In a linked worktree, git resolves refs to the main repository's git directory.
200
+ * Creates and deletes a real ref under refs/tickmarkr/preflight/ through git itself, leaving
201
+ * nothing behind on success.
202
+ */
203
+ export declare function probeRefsWritable(cwd?: string): Promise<RefsProbeResult>;
204
+ export declare const probeRefs: typeof probeRefsWritable;
205
+ export declare const refsProbe: typeof probeRefsWritable;
206
+ export declare function assertRefsWritable(cwd?: string, action?: "run" | "resume"): Promise<RefsProbeResult>;
package/dist/run/git.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { availableParallelism, tmpdir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { StringDecoder } from "node:string_decoder";
@@ -565,3 +565,70 @@ export async function removeWorktree(repo, dir) {
565
565
  await shGit(`rm -rf ${shq(dir)}`, repo);
566
566
  await shGit("git worktree prune", repo);
567
567
  }
568
+ export const REFS_PREFLIGHT_PREFIX = "refs/tickmarkr/preflight";
569
+ export const REFS_PROBE_REMEDY = "run from the main repository or a full clone; a sandbox that denies writes under that path cannot host a run";
570
+ export function refsRefusalMessage(action, path, error) {
571
+ return `refusing to ${action}: repository refs directory ${path} is not writable (${error}). `
572
+ + `Remedy: ${REFS_PROBE_REMEDY}.`;
573
+ }
574
+ /**
575
+ * OBS-983/984: prove the repository's common git directory accepts a ref write before starting
576
+ * the daemon. In a linked worktree, git resolves refs to the main repository's git directory.
577
+ * Creates and deletes a real ref under refs/tickmarkr/preflight/ through git itself, leaving
578
+ * nothing behind on success.
579
+ */
580
+ export async function probeRefsWritable(cwd = process.cwd()) {
581
+ const refsPathRes = await shGit("git rev-parse --git-path refs", cwd);
582
+ if (refsPathRes.code !== 0) {
583
+ const error = (refsPathRes.stderr || refsPathRes.stdout).trim() || "git rev-parse --git-path refs failed";
584
+ return { ok: false, path: "", refsDir: "", error };
585
+ }
586
+ const raw = refsPathRes.stdout.trim();
587
+ let refsDir = resolve(cwd, raw);
588
+ try {
589
+ refsDir = realpathSync(refsDir);
590
+ }
591
+ catch {
592
+ // keep resolved path if realpath fails
593
+ }
594
+ const headRes = await shGit("git rev-parse HEAD", cwd);
595
+ const target = headRes.code === 0 && headRes.stdout.trim()
596
+ ? headRes.stdout.trim()
597
+ : "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
598
+ const probeId = `probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
599
+ const ref = `${REFS_PREFLIGHT_PREFIX}/${probeId}`;
600
+ const createRes = await shGit(`git update-ref ${shq(ref)} ${shq(target)}`, cwd);
601
+ if (createRes.code !== 0) {
602
+ const error = (createRes.stderr || createRes.stdout).trim() || `git update-ref failed (${createRes.code})`;
603
+ return { ok: false, path: refsDir, refsDir, error };
604
+ }
605
+ const delRes = await shGit(`git update-ref -d ${shq(ref)}`, cwd);
606
+ if (delRes.code !== 0) {
607
+ const error = (delRes.stderr || delRes.stdout).trim() || `git update-ref -d failed (${delRes.code})`;
608
+ return { ok: false, path: refsDir, refsDir, error };
609
+ }
610
+ // Clean up empty directories left behind by git
611
+ try {
612
+ const preflightDir = join(refsDir, "tickmarkr", "preflight");
613
+ if (existsSync(preflightDir) && readdirSync(preflightDir).length === 0) {
614
+ rmSync(preflightDir, { recursive: true, force: true });
615
+ }
616
+ const tmDir = join(refsDir, "tickmarkr");
617
+ if (existsSync(tmDir) && readdirSync(tmDir).length === 0) {
618
+ rmSync(tmDir, { recursive: true, force: true });
619
+ }
620
+ }
621
+ catch {
622
+ // best-effort cleanup of empty dirs
623
+ }
624
+ return { ok: true, path: refsDir, refsDir };
625
+ }
626
+ export const probeRefs = probeRefsWritable;
627
+ export const refsProbe = probeRefsWritable;
628
+ export async function assertRefsWritable(cwd = process.cwd(), action = "run") {
629
+ const probe = await probeRefsWritable(cwd);
630
+ if (!probe.ok) {
631
+ throw new Error(refsRefusalMessage(action, probe.path, probe.error));
632
+ }
633
+ return probe;
634
+ }
@@ -467,18 +467,20 @@ export async function runConsolidatedCockpit(options) {
467
467
  const reader = createPointerReportReader();
468
468
  const stdin = new Proxy(input, { get(target, property) {
469
469
  if (property === "read")
470
- return (...args) => {
470
+ return () => {
471
471
  try {
472
- const chunk = target.read(...args);
473
- if (chunk == null)
474
- return null;
475
- const result = reader(String(chunk));
476
472
  let keys = "";
477
- for (const token of result.tokens) {
478
- if (token.type === "pointer")
479
- pointer(token.report);
480
- else
481
- keys += token.bytes;
473
+ // OBS-965: drain until the stream itself says empty. A real tty (highWaterMark 0) stops its
474
+ // handle after every chunk and only a read() that finds the buffer EMPTY restarts it; Ink's
475
+ // loop stops at the first null, so returning null after ONE pointer-only chunk left that
476
+ // empty read unmade and every later key queued in the kernel — the deaf board.
477
+ for (let chunk = target.read(); chunk != null; chunk = target.read()) {
478
+ for (const token of reader(String(chunk)).tokens) {
479
+ if (token.type === "pointer")
480
+ pointer(token.report);
481
+ else
482
+ keys += token.bytes;
483
+ }
482
484
  }
483
485
  // A standalone Escape is unambiguous after Ink's own input grace.
484
486
  if (reader.pending() === "\x1b")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.5.3",
3
+ "version": "2.5.4",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",