tickmarkr 2.5.0 → 2.5.1

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.
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { stringify } from "yaml";
9
9
  import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
10
10
  import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
11
- import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
11
+ import { SettledTrailerTracker, addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
12
12
  import { bannerShell, paneDispatchCommand } from "../brand.js";
13
13
  import { collateralHits } from "../compile/collateral.js";
14
14
  import { DEFAULT_DIFF_CAP, globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
@@ -27,14 +27,25 @@ import { runEnvironment } from "./environment.js";
27
27
  import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, SUITE_PARENT_ENV, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
28
28
  import { runInteractiveSeed } from "./interactive-seed.js";
29
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";
30
- import { isDiffCapPark } from "../gates/review.js";
30
+ import { isDiffCapPark, pickReviewer } from "../gates/review.js";
31
31
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
32
32
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
33
33
  import { nextChannel, route } from "../route/router.js";
34
34
  import { desiredPanes } from "./reconcile.js";
35
35
  import { harvestCpuFlatWindowMs, NUDGEABLE_ADAPTERS, PANE_READ_ROWS, StallProgressTracker, stallSnapshotBannerRows, WorkerTreeCpuAccountant, } from "./stall.js";
36
- // Compatibility exports for the daemon liveness tests and existing consumers. The implementation
37
- // lives in stall.ts so gate dispatch can depend on it without importing the daemon.
36
+ // The live set is also the ownership claim shared by task cleanup and termination.
37
+ export async function closeLiveSlot(liveSlots, driver, slot) {
38
+ if (!liveSlots.delete(slot))
39
+ return;
40
+ try {
41
+ await driver.close(slot);
42
+ }
43
+ catch (error) {
44
+ liveSlots.add(slot);
45
+ throw error;
46
+ }
47
+ }
48
+ // Compatibility exports: gate dispatch can import stall.ts without importing the daemon.
38
49
  export { harvestCpuFlatWindowMs, resetHarvestCpuFlatMsForTests, setHarvestCpuFlatMsForTests, workerTreeCpuMs, } from "./stall.js";
39
50
  const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
40
51
  // An override (flag/spec) re-resolves through loadConfigWithMode itself, via a synthesized repo overlay
@@ -1264,12 +1275,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1264
1275
  // opens/closes keep the ledger exact. Termination then closes exactly what is still live — and a
1265
1276
  // slot closed once (task-done, quota reroute, gate self-clean) can never be closed twice.
1266
1277
  const liveSlots = new Set();
1267
- const closeSlot = async (s) => {
1268
- if (!liveSlots.has(s))
1269
- return; // already closed — never twice
1270
- await driver.close(s);
1271
- liveSlots.delete(s);
1272
- };
1278
+ const closeSlot = (s) => closeLiveSlot(liveSlots, driver, s);
1273
1279
  const trackedDriver = {
1274
1280
  id: driver.id,
1275
1281
  interactive: driver.interactive,
@@ -1331,7 +1337,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1331
1337
  // new attempt while this reaper is still closing the old ones.
1332
1338
  abortRun(new Error(`terminated by ${sig}`));
1333
1339
  if (cfg.visibility.keepPanes !== "forever") {
1334
- for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
1340
+ for (const s of Array.from(liveSlots)) { // snapshot: failed closes restore membership for the next sweep
1335
1341
  try {
1336
1342
  await closeSlot(s);
1337
1343
  }
@@ -1350,6 +1356,9 @@ export async function runDaemon(repoRoot, opts = {}) {
1350
1356
  process.on("SIGINT", onTermination);
1351
1357
  process.on("SIGTERM", onTermination);
1352
1358
  journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
1359
+ const demotedReviewers = new Set(journal.read()
1360
+ .filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string")
1361
+ .map((event) => event.data.reviewer));
1353
1362
  const reviewHistory = journal.read()
1354
1363
  .filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
1355
1364
  .map((event) => event.data.reviewer);
@@ -1816,6 +1825,35 @@ export async function runDaemon(repoRoot, opts = {}) {
1816
1825
  if (rs)
1817
1826
  journal.append("resume-restore", t.id, { attempts: rs.attempts, tried: [...tried], assignment });
1818
1827
  const badReviewers = []; // v1.1: reviewer channels that produced unparseable output for this task
1828
+ const noteReviewEvent = (e) => {
1829
+ journal.append(e.name, t.id, e.payload);
1830
+ if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
1831
+ if (!badReviewers.includes(e.payload.reviewer))
1832
+ badReviewers.push(e.payload.reviewer);
1833
+ }
1834
+ };
1835
+ // Keep the measured siblings, including a red judge, while replacing only a missing review.
1836
+ // Every dispatched seat is excluded for this task, so the loop is bounded by the eligible pool.
1837
+ const runReviewRecovery = async (task, ctx) => {
1838
+ const round = await runGates(task, ctx);
1839
+ let review = round.results.find((g) => g.gate === "review");
1840
+ while (review?.meta?.noVerdict === true) {
1841
+ const next = pickReviewer(ctx.author, ctx.channels, badReviewers, cfg.review.prefer ?? [], task.routingHints?.floor, reviewHistory, undefined, demotedReviewers);
1842
+ if (!next)
1843
+ break;
1844
+ journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
1845
+ const retried = await runGates({ ...task, gates: ["review"] }, ctx);
1846
+ const replacement = retried.results.find((g) => g.gate === "review");
1847
+ // A dirty tree or another pre-dispatch refusal must still fail closed.
1848
+ if (!replacement) {
1849
+ round.results.push(...retried.results.filter((g) => !gateSatisfied(g)));
1850
+ break;
1851
+ }
1852
+ round.results = round.results.map((g) => g.gate === "review" ? replacement : g);
1853
+ review = replacement;
1854
+ }
1855
+ return round;
1856
+ };
1819
1857
  // v1.70 T5 (review-convergence): failed review rounds this task has drawn, counted from the per-task
1820
1858
  // review history already in the journal — the SAME review gate-result stream onGate reads to grow the
1821
1859
  // reviewer-exclusion list (badReviewers), never a second parallel counter. OBS-189: scoped to the
@@ -1840,7 +1878,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1840
1878
  // One helper, both onGate sites (satisfied-gate resume + main attempt loop).
1841
1879
  let gateSubject;
1842
1880
  const journalGateResult = (g) => {
1843
- const blocking = gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
1881
+ const blocking = g.meta?.infra !== true && gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
1844
1882
  // T2: a review that PASSED while DEFERRING a concern still recorded a defect — the prompt
1845
1883
  // promises the deferral is recorded and never dropped, and a details string is not a record a
1846
1884
  // later round can match. The blocking projection above is the only writer of structured
@@ -1861,12 +1899,17 @@ export async function runDaemon(repoRoot, opts = {}) {
1861
1899
  // The legacy baseline declines (a build command the repo never configured) have always written
1862
1900
  // `pass: true` beside `skipped: true` and every consumer already reads them right, so their row
1863
1901
  // is untouched: only a decline that would otherwise be recorded RED changes shape here.
1864
- const unverdicted = g.meta?.skipped === true && !g.pass;
1902
+ // A no-verdict review is journaled `infra` + `skipped` with NO `pass`: the canonical reader
1903
+ // (outcome.ts) files it as infra regardless, the `pass === false` readers (rounds, briefs,
1904
+ // park kind) never charge it, and outstandingReviewFindings (journal.ts:816) skips a `skipped`
1905
+ // row instead of reading a missing `pass` as a PASS that settles every open blocking finding.
1906
+ const noVerdictReview = g.gate === "review" && g.meta?.noVerdict === true;
1907
+ const unverdicted = (g.meta?.skipped === true && !g.pass) || noVerdictReview;
1865
1908
  journal.append("gate-result", t.id, {
1866
1909
  gate: g.gate, ...(unverdicted ? {} : { pass: g.pass }), details: g.details,
1867
1910
  ...(gateSubject ? { commit: gateSubject.commit, attempt: gateSubject.attempt } : {}),
1868
1911
  ...(gateSubject?.replayMeasurement ? { replayMeasurement: true } : {}),
1869
- ...(g.meta?.skipped === true ? { skipped: true } : {}),
1912
+ ...(g.meta?.skipped === true || noVerdictReview ? { skipped: true } : {}),
1870
1913
  // T9: an infra-only exit is journaled AS one. The operator reading a red `test` row has to
1871
1914
  // be able to tell "the suite found a defect" from "the runner never ran", and the merge
1872
1915
  // predicate's reason for refusing has to be legible in the ledger it refused from.
@@ -1892,6 +1935,8 @@ export async function runDaemon(repoRoot, opts = {}) {
1892
1935
  ...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
1893
1936
  ...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
1894
1937
  reviewer: g.meta.reviewer,
1938
+ ...Object.fromEntries(["cause", "seatAuthoredBytes", "bytes", "rawPath", "briefPath", "timeoutMs", "unparseable", "noVerdict", "resolved", "reraised"]
1939
+ .filter((key) => g.meta?.[key] !== undefined).map((key) => [key, g.meta[key]])),
1895
1940
  ...(typeof g.meta.vendor === "string" ? { vendor: g.meta.vendor } : {}),
1896
1941
  ...(typeof g.meta.provider === "string" ? { provider: g.meta.provider } : {}),
1897
1942
  ...(typeof g.meta.rotationSeat === "number" ? { rotationSeat: g.meta.rotationSeat } : {}),
@@ -1901,7 +1946,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1901
1946
  // where work is allowed, not where this verdict found the defect.
1902
1947
  ...(blocking ? {
1903
1948
  taskContentDigest: contentDigest,
1904
- findings: structuredFindings(g.gate, g.details),
1949
+ findings: Array.isArray(g.meta?.findings) ? g.meta.findings : structuredFindings(g.gate, g.details),
1905
1950
  } : deferred.length > 0 ? {
1906
1951
  taskContentDigest: contentDigest,
1907
1952
  findings: deferred,
@@ -2267,7 +2312,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2267
2312
  };
2268
2313
  await trackedDriver.project?.(t.id, "in-review");
2269
2314
  journal.phaseStart(t.id, "gates");
2270
- const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runGates(resumedTask, {
2315
+ const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runReviewRecovery(resumedTask, {
2316
+ carriedFindings: outstandingReviewFindings(journal.read(), t.id),
2271
2317
  worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
2272
2318
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
2273
2319
  collateral: collateral.get(t.id) ?? [],
@@ -2283,7 +2329,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2283
2329
  }
2284
2330
  : undefined,
2285
2331
  excludeReviewers: badReviewers,
2286
- reviewHistory,
2332
+ reviewHistory, demotedReviewers,
2287
2333
  onGate: async (e) => {
2288
2334
  if (e.phase === "start") {
2289
2335
  notePhaseStart(e);
@@ -2291,7 +2337,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2291
2337
  return;
2292
2338
  }
2293
2339
  if (e.phase === "note") {
2294
- journal.append(e.name, t.id, e.payload);
2340
+ noteReviewEvent(e);
2295
2341
  return;
2296
2342
  }
2297
2343
  const g = e.result;
@@ -2318,6 +2364,11 @@ export async function runDaemon(repoRoot, opts = {}) {
2318
2364
  graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
2319
2365
  saveGraph(repoRoot, graph);
2320
2366
  if (!results.every(gateSatisfied)) {
2367
+ const infra = results.find((g) => g.meta?.infra === true);
2368
+ if (infra) {
2369
+ await park(t, `${infra.gate}: ${infra.details}`, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
2370
+ return;
2371
+ }
2321
2372
  // OBS-547: disposition FIRST, and through the same helper the ordinary attempt path uses. A
2322
2373
  // crash between a journaled scope red and its classification must not turn a predicted red
2323
2374
  // into a charged one just because a resume is what observed it.
@@ -2871,6 +2922,12 @@ export async function runDaemon(repoRoot, opts = {}) {
2871
2922
  let startupFailure = false;
2872
2923
  let deadWorkerPark;
2873
2924
  let settleParsed;
2925
+ const trailerFrames = interactive && adapter.busyFrameMarkers
2926
+ ? new SettledTrailerTracker(adapter.busyFrameMarkers) : undefined;
2927
+ const sampleTrailer = (frame) => {
2928
+ const trailer = new RegExp(trailerPattern(nonce)).test(frame);
2929
+ return trailerFrames ? trailerFrames.sample(frame, trailer) : trailer;
2930
+ };
2874
2931
  let seedResult;
2875
2932
  // v1.22 T5 / OBS-19: auto-answer a fingerprint-matched trust dialog exactly once per slot.
2876
2933
  // Any other blocked/idle dialog pages the operator (unlatched since T1 — see below).
@@ -3036,14 +3093,22 @@ export async function runDaemon(repoRoot, opts = {}) {
3036
3093
  // own source, where "TICKMARKR_EXIT:" is a string literal) must not end the wait. Only a
3037
3094
  // parseable trailer or a digit-suffixed exit marker in the harvest is completion.
3038
3095
  output = await driver.read(slot, PANE_READ_ROWS); // TUI transcripts carry chrome — read deeper than print's 500
3039
- finished = new RegExp(trailerPattern(nonce)).test(output);
3096
+ finished = sampleTrailer(output);
3040
3097
  const exit = exitRe.exec(output);
3041
- if (finished || exit) {
3098
+ if (finished || (exit && !(trailerFrames && new RegExp(trailerPattern(nonce)).test(output)))) {
3042
3099
  exitCode = exit ? Number(exit[1]) : null; // null ⇔ the TUI is still alive
3043
3100
  processExited = exit !== null;
3044
3101
  await sampleContext(); // final poll-seam sample before leaving the wait
3045
3102
  break;
3046
3103
  }
3104
+ if (trailerFrames && new RegExp(trailerPattern(nonce)).test(output)) {
3105
+ // A matching busy frame is still a live turn. Preserve the rolling progress budget.
3106
+ await sampleContext();
3107
+ if (stallProgress.observe({ paneText: output, contextTokens }))
3108
+ lastProgressAt = Date.now();
3109
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250, remaining)));
3110
+ continue;
3111
+ }
3047
3112
  }
3048
3113
  // A failed pane read is absence of evidence, never evidence of an absent pane. Keep the
3049
3114
  // rolling taskTimeoutMinutes window as the backstop and name the held probe once.
@@ -3078,6 +3143,16 @@ export async function runDaemon(repoRoot, opts = {}) {
3078
3143
  await new Promise((r) => setTimeout(r, Math.min(slice - spent, 1_000)));
3079
3144
  continue;
3080
3145
  }
3146
+ // waitOutput is only a wake hint; a driver may miss a trailer already painted.
3147
+ if (trailerFrames && sampleTrailer(paneText)) {
3148
+ output = paneText;
3149
+ finished = true;
3150
+ const exit = exitRe.exec(output);
3151
+ exitCode = exit ? Number(exit[1]) : null;
3152
+ processExited = exit !== null;
3153
+ await sampleContext();
3154
+ break;
3155
+ }
3081
3156
  if (paneText.length > 0)
3082
3157
  everHadOutput = true;
3083
3158
  if (startupFailureInWindow(paneText, workerLaunchedAt)) {
@@ -3429,9 +3504,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3429
3504
  // un-answered nudge instead of the remainder of the window.
3430
3505
  nudgeDeadline = undefined;
3431
3506
  output = await driver.read(slot, PANE_READ_ROWS);
3432
- finished = new RegExp(trailerPattern(nonce)).test(output);
3507
+ finished = sampleTrailer(output);
3433
3508
  const exit = exitRe.exec(output);
3434
- if (finished || exit) {
3509
+ if (finished || (exit && !(trailerFrames && new RegExp(trailerPattern(nonce)).test(output)))) {
3435
3510
  exitCode = exit ? Number(exit[1]) : null;
3436
3511
  processExited = exit !== null;
3437
3512
  await sampleContext();
@@ -3476,12 +3551,12 @@ export async function runDaemon(repoRoot, opts = {}) {
3476
3551
  // The poll loop already recorded the unreadable pane. Retain the last readable bytes
3477
3552
  // so this ambiguous path still reaches the ordinary timeout/consult backstop.
3478
3553
  }
3479
- finished = new RegExp(trailerPattern(nonce)).test(output);
3554
+ finished = trailerFrames ? trailerFrames.settled : new RegExp(trailerPattern(nonce)).test(output);
3480
3555
  const exit = exitRe.exec(output);
3481
3556
  exitCode = exit ? Number(exit[1]) : null;
3482
3557
  processExited = exit !== null;
3483
3558
  }
3484
- if (finished) {
3559
+ if (finished && !trailerFrames) {
3485
3560
  await driver.waitAgentStatus(slot, "idle", 5_000); // settle, then re-harvest the final render
3486
3561
  output = await driver.read(slot, PANE_READ_ROWS);
3487
3562
  }
@@ -3501,11 +3576,12 @@ export async function runDaemon(repoRoot, opts = {}) {
3501
3576
  break;
3502
3577
  await new Promise((r) => setTimeout(r, Math.min(settleDelayMs, remaining)));
3503
3578
  output = await driver.read(slot, PANE_READ_ROWS);
3579
+ trailerFrames?.sample(output, new RegExp(trailerPattern(nonce)).test(output));
3504
3580
  settleParsed = adapter.parse(output, nonce);
3505
3581
  settleTries++;
3506
3582
  }
3507
3583
  if (settleParsed.summary !== UNPARSEABLE_TRAILER_SUMMARY) {
3508
- finished = settleParsed.summary !== NO_TRAILER_SUMMARY;
3584
+ finished = settleParsed.summary !== NO_TRAILER_SUMMARY && (!trailerFrames || trailerFrames.settled);
3509
3585
  }
3510
3586
  }
3511
3587
  }
@@ -3613,13 +3689,18 @@ export async function runDaemon(repoRoot, opts = {}) {
3613
3689
  // encoded and therefore invisible to the raw wait regex. Keep `output` bounded: every text
3614
3690
  // classifier below retains its pre-artifact tail surface.
3615
3691
  let result = settleParsed ?? adapter.parse(capturedStream.output, nonce);
3692
+ const unsettledTrailer = trailerFrames && !trailerFrames.settled
3693
+ && (trailerFrames.busyMarkersSeen.size > 0 || new RegExp(trailerPattern(nonce)).test(capturedStream.output));
3694
+ if (unsettledTrailer) {
3695
+ result = { ok: false, summary: `premature-idle: trailer did not settle; busy markers: ${[...trailerFrames.busyMarkersSeen].join(", ") || "awaiting two idle samples"}`, deviations: [], raw: output };
3696
+ }
3616
3697
  const adapterCause = result.cause;
3617
3698
  // Law 46: there is one completion fact. A classified adapter result with no parse-failure cause
3618
3699
  // means its decoder found the nonce-bound trailer even when the raw JSON envelope escaped it.
3619
- const workerFinished = finished || adapterCause === undefined;
3700
+ const workerFinished = !unsettledTrailer && (finished || adapterCause === undefined);
3620
3701
  finished = workerFinished;
3621
3702
  const adapterStartupFailure = adapterCause === "startup-failure";
3622
- const workerCause = adapterStartupFailure
3703
+ const workerCause = unsettledTrailer ? "premature-idle" : adapterStartupFailure
3623
3704
  ? "startup-failure"
3624
3705
  : startupFailure
3625
3706
  ? "startup-failure"
@@ -3708,9 +3789,24 @@ export async function runDaemon(repoRoot, opts = {}) {
3708
3789
  // never certifies the channel. Reading the synthesized `finished`/`ok` here reset the streak
3709
3790
  // on every harvest, so a CLI that produces commits and swallows every trailer was immune to
3710
3791
  // the two-window demotion and stayed first pick for the rest of the run.
3792
+ // Q-1 (OBS-926): BOTH branches read the same tail — a print stream tested whole failed a
3793
+ // pinned codex worker over for a diff line number (`429:`) 41 985 bytes before EOF and for
3794
+ // this repository's own fixture prose. The matched bytes and their stream offset ride the
3795
+ // row so a false positive is legible from the record alone.
3796
+ const quotaMatch = (interactive ? !workerFinished : exitCode !== 0)
3797
+ ? QUOTA_RE.exec(stallSnapshotBannerRows(output))
3798
+ : null;
3799
+ // Q-1: a graph pin is an operator instruction — a quota match ALONE never overrides it; only a
3800
+ // channel-attributed error (auth/setup/outage/timeout, the typed dead-channel classes) may.
3801
+ const pin = t.routingHints?.pin;
3802
+ const onPin = !!pin && assignment.adapter === pin.via && assignment.model === pin.model;
3803
+ const channelError = quotaMatch && onPin ? classifyDeadChannel({ ...preHarvestResult, raw: output }) : undefined;
3804
+ const pinRefusesQuota = !!quotaMatch && onPin && !channelError;
3805
+ // a refused quota window is not a no-trailer verdict on the pinned channel: counting it would
3806
+ // demote the pin two tails in and the demotion re-dispatch would move the task off it.
3711
3807
  if (preHarvestResult.ok && workerFinished)
3712
3808
  noTrailerStreak.set(channelKey(assignment), 0);
3713
- else if (!workerFinished && cause !== "provider-death") {
3809
+ else if (!workerFinished && cause !== "provider-death" && !pinRefusesQuota) {
3714
3810
  const ck = channelKey(assignment);
3715
3811
  const streak = (noTrailerStreak.get(ck) ?? 0) + 1;
3716
3812
  noTrailerStreak.set(ck, streak);
@@ -3740,12 +3836,29 @@ export async function runDaemon(repoRoot, opts = {}) {
3740
3836
  // T2 review: the gate is `workerFinished`, not the harvest-synthesized `finished` — a
3741
3837
  // committed-but-quota-walled attempt routes here FIRST (its commits ride the carry-forward
3742
3838
  // into the next attempt's recreated worktree), it never buys a gate run on throttled work.
3743
- const quotaHit = interactive
3744
- ? !workerFinished && QUOTA_RE.test(stallSnapshotBannerRows(output))
3745
- : exitCode !== 0 && QUOTA_RE.test(output);
3746
- if (quotaHit) {
3839
+ // Q-1 review: NO cap and NO fall-through to the consult ladder (failoverOrRecycle ignores the
3840
+ // pin) — the attempt is counted, so a pin that never clears parks at MAX_ATTEMPTS on its own
3841
+ // channel; only a channel-attributed error below may leave it.
3842
+ if (pinRefusesQuota) {
3843
+ journal.append("quota-failover-refused", t.id, {
3844
+ pin: `${pin.via}:${pin.model}`, channel: channelKey(assignment), matched: quotaMatch[0],
3845
+ reason: "pinned channel; no channel-attributed error", attempt,
3846
+ });
3847
+ await new Promise((r) => setTimeout(r, PROVIDER_DEATH_BACKOFF_MS));
3848
+ continue;
3849
+ }
3850
+ if (quotaMatch && (!onPin || channelError)) {
3747
3851
  const next = failover("quota-failover");
3748
- journal.append("quota-failover", t.id, { from: channelKey(assignment), to: next ? channelKey(next) : null });
3852
+ // byte offset inside the PERSISTED stream (the journaled `stream` file) — the capture keeps
3853
+ // only its last MAX_BUF bytes, so the file's origin may sit past the buffer's.
3854
+ const streamBytes = Buffer.byteLength(capturedStream.output);
3855
+ const idx = capturedStream.output.lastIndexOf(quotaMatch[0]);
3856
+ const offset = idx >= 0 ? Buffer.byteLength(capturedStream.output.slice(0, idx)) - Math.max(0, streamBytes - MAX_BUF) : null;
3857
+ journal.append("quota-failover", t.id, {
3858
+ from: channelKey(assignment), to: next ? channelKey(next) : null,
3859
+ matched: quotaMatch[0], offset, stream: capturedStream.path, streamBytes: Math.min(streamBytes, MAX_BUF),
3860
+ ...(onPin ? { pin: `${pin.via}:${pin.model}`, channelError } : {}),
3861
+ });
3749
3862
  if (next) {
3750
3863
  await driver.notify(`tickmarkr ${runId}: ${t.id} quota failover`, { tier: "attention" });
3751
3864
  // OBS-17 T2: the superseded slot's pane closes AT REROUTE TIME — it holds a throttled
@@ -3866,7 +3979,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3866
3979
  return;
3867
3980
  }
3868
3981
  if (e.phase === "note") {
3869
- journal.append(e.name, t.id, e.payload);
3982
+ noteReviewEvent(e);
3870
3983
  return;
3871
3984
  }
3872
3985
  const g = e.result;
@@ -3904,7 +4017,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3904
4017
  gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
3905
4018
  await trackedDriver.project?.(t.id, "in-review");
3906
4019
  journal.phaseStart(t.id, "gates");
3907
- ({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runGates(t, {
4020
+ ({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runReviewRecovery(t, {
4021
+ carriedFindings: outstandingFindings,
3908
4022
  worktree: wt, baseRef: taskBase, result, author: assignment,
3909
4023
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
3910
4024
  collateral: collateral.get(t.id) ?? [],
@@ -3926,7 +4040,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3926
4040
  }
3927
4041
  : undefined,
3928
4042
  excludeReviewers: badReviewers,
3929
- reviewHistory,
4043
+ reviewHistory, demotedReviewers,
3930
4044
  onGate,
3931
4045
  })));
3932
4046
  results.forEach(classifySignalOnlyTest);
@@ -1,12 +1,16 @@
1
1
  import type { InitConfigOverlay } from "../../config/config.js";
2
2
  type Driver = NonNullable<InitConfigOverlay["driver"]>;
3
3
  type VisibilityLlm = NonNullable<NonNullable<InitConfigOverlay["visibility"]>["llm"]>;
4
+ export type DetectedHost = "herdr" | "orca" | "subprocess";
5
+ export declare function detectedHost(env?: NodeJS.ProcessEnv): DetectedHost;
6
+ export declare function driverDescription(driver: Driver, host?: DetectedHost): string;
4
7
  export type InitWizardFields = {
5
8
  driver: Driver;
6
9
  concurrency: number;
7
10
  visibilityLlm: VisibilityLlm;
8
11
  offerSkills: boolean;
9
12
  skillsDefault: boolean;
13
+ detectedHost?: DetectedHost;
10
14
  };
11
15
  export type InitWizardResult = {
12
16
  kind: "continue";
@@ -1,23 +1,33 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, render, Text, useApp, useInput } from "ink";
3
3
  import { useRef, useState } from "react";
4
+ import { orcaHostDetected } from "../../drivers/index.js";
4
5
  import { ToggleMark } from "./components.js";
5
6
  import { clip, INK, inkInput, inkOutput, KeyBar, padCell, Pointer } from "./frame.js";
6
7
  const DRIVERS = ["auto", "herdr", "subprocess", "orca"];
7
- // One line per driver, shown for the value currently selected: four environments no longer fit on
8
- // a single description row at 80 columns (the shipped three already clipped). Auto identifies its
9
- // innermost host from environment markers only; outside an Orca terminal, Orca is named explicitly.
8
+ export function detectedHost(env = process.env) {
9
+ if (env.HERDR_ENV === "1")
10
+ return "herdr";
11
+ if (orcaHostDetected(env))
12
+ return "orca";
13
+ return "subprocess";
14
+ }
10
15
  const DRIVER_DESC = {
11
- auto: "auto: herdr when HERDR_ENV=1, else orca inside an Orca terminal, else subprocess",
16
+ auto: "auto — resolves to subprocess here",
12
17
  herdr: "herdr: every worker runs in a visible pane you can watch and unblock",
13
18
  subprocess: "subprocess: headless child processes — no cockpit, same fail-closed gates",
14
19
  orca: "orca: visible terminals in the Orca app — picked by auto inside one; name it elsewhere",
15
20
  };
21
+ export function driverDescription(driver, host = detectedHost()) {
22
+ if (driver === "auto")
23
+ return `auto — resolves to ${host} here`;
24
+ return DRIVER_DESC[driver];
25
+ }
16
26
  const VISIBILITY = ["pane", "headless"];
17
27
  // Three fields, one toggle, one action — a descriptor array, deliberately not a forms framework.
18
- function buildRows(offerSkills, driver) {
28
+ function buildRows(offerSkills, driver, host) {
19
29
  const rows = [
20
- { id: "driver", section: "Run", label: "Driver", desc: DRIVER_DESC[driver] },
30
+ { id: "driver", section: "Run", label: "Driver", desc: driverDescription(driver, host) },
21
31
  { id: "concurrency", section: "Run", label: "Concurrency", desc: "parallel task batteries per run — min 1; an empty or zero entry reverts on leave" },
22
32
  {
23
33
  id: "visibility",
@@ -45,6 +55,7 @@ function buildRows(offerSkills, driver) {
45
55
  }
46
56
  export function InitWizardApp({ fields, frameColumns = 74 }) {
47
57
  const { exit } = useApp();
58
+ const host = fields.detectedHost ?? detectedHost();
48
59
  const [cursor, setCursor] = useState(0);
49
60
  const cursorRef = useRef(0);
50
61
  const driverRef = useRef(fields.driver);
@@ -56,7 +67,7 @@ export function InitWizardApp({ fields, frameColumns = 74 }) {
56
67
  const typedRef = useRef(false);
57
68
  const doneRef = useRef(false);
58
69
  // Built per render so the description row follows the driver the cursor last cycled to.
59
- const rows = buildRows(fields.offerSkills, driverRef.current);
70
+ const rows = buildRows(fields.offerSkills, driverRef.current, host);
60
71
  const [, setRevision] = useState(0);
61
72
  const bump = () => setRevision((n) => n + 1);
62
73
  // Leaving the row (or pressing Enter on it) is the commit point: a parse ≥ 1 becomes the new
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -212,19 +212,30 @@ banner_pct() {
212
212
  # The rule glyph is passed IN, never written as an escape: this host's awk (BWK 20200816) does not
213
213
  # honour \xNN, and an unsupported escape matches nothing — the selector would find no rule line and
214
214
  # report UNREADABLE on every real pane while staying green against any fixture that skipped awk.
215
+ # OBS-964 (2): a codex footer has NO rule line above it, so the rule selector is UNREADABLE for the whole
216
+ # life of a codex seat. Fall back to the self-labelled `Context N% used` row — the label IS the selector.
215
217
  banner=$(printf '%s\n' "$screen" | awk -v rule='─' '
216
218
  { line[NR] = $0
217
219
  bare = $0; gsub(/[[:space:]]/, "", bare)
218
220
  stripped = bare; gsub(rule, "", stripped)
219
221
  if (bare != "" && stripped == "") last = NR }
220
- END { if (last) for (i = last + 1; i <= NR; i++) if (line[i] ~ /[0-9]+%/) print line[i] }
222
+ END { labelled = 0
223
+ for (i = 1; i <= NR; i++) if (line[i] ~ /Context [0-9]+% used/) { print line[i]; labelled = 1 }
224
+ if (!labelled && last) for (i = last + 1; i <= NR; i++) if (line[i] ~ /[0-9]+%/) print line[i] }
221
225
  ' | tail -1)
222
226
  # No rule line in the window, or no percentage below it: say so out loud rather than guess.
223
227
  [ -n "$banner" ] || { printf 'UNREADABLE\n'; return 0; }
224
228
  printf '%s\n' "$banner" |
225
229
  grep -Eqi '(^|[^[:alnum:]])(claude|opus|sonnet|haiku|fable|gpt|gemini|glm|kimi|grok|composer|openai|zai)[[:alnum:]_./-]*([[:space:]]|$)' ||
226
230
  { printf 'UNREADABLE\n'; return 0; }
227
- pct=$(printf '%s\n' "$banner" | grep -oE '[0-9]+%' | tail -1 | tr -d '%')
231
+ # OBS-964: a codex banner carries TWO percentages ("Context 16% used · weekly 96% left"); the LAST one is the
232
+ # quota, not the fill. Prefer the labelled context figure; fall back to the last bare % (claude-code banner).
233
+ pct=$(printf '%s\n' "$banner" | grep -oE 'Context [0-9]+% used' | grep -oE '[0-9]+' | head -1)
234
+ # OBS-964 add.3: a codex footer while WORKING drops the labelled figure but keeps `weekly N% left`; strip the
235
+ # quota phrase before the bare-% fallback so a quota can never be read as fill.
236
+ [ -n "$pct" ] || pct=$(printf '%s\n' "$banner" | sed -E 's/weekly [0-9]+% left//g' | grep -oE '[0-9]+%' | tail -1 | tr -d '%')
237
+ # OBS-964 add.4: keep the row the number came from, so a false reading can be diagnosed from bytes.
238
+ printf '%s\t%s\n' "$(date -u +%H:%M:%SZ)" "$banner" >> "${TMPDIR:-/tmp}/tkr-watch-context-banner-$(printf '%s' "$TARGET" | tr -c 'A-Za-z0-9' _).log" 2>/dev/null || true
228
239
  [ -n "$pct" ] && printf '%s\n' "$pct" || printf 'UNREADABLE\n'
229
240
  }
230
241