tickmarkr 2.6.2 → 2.6.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.
Files changed (79) hide show
  1. package/README.md +12 -2
  2. package/dist/adapters/codex.d.ts +2 -1
  3. package/dist/adapters/codex.js +26 -5
  4. package/dist/adapters/model-lints.d.ts +1 -0
  5. package/dist/adapters/model-lints.js +48 -1
  6. package/dist/adapters/model-windows.d.ts +2 -1
  7. package/dist/adapters/model-windows.js +18 -7
  8. package/dist/adapters/types.d.ts +6 -0
  9. package/dist/cli/commands/approve.d.ts +6 -3
  10. package/dist/cli/commands/approve.js +43 -6
  11. package/dist/cli/commands/doctor.d.ts +8 -2
  12. package/dist/cli/commands/doctor.js +6 -1
  13. package/dist/cli/commands/fleet.js +264 -59
  14. package/dist/cli/commands/init.js +5 -3
  15. package/dist/cli/commands/plan.js +13 -8
  16. package/dist/cli/commands/report.d.ts +40 -2
  17. package/dist/cli/commands/report.js +278 -11
  18. package/dist/cli/commands/resume.js +2 -4
  19. package/dist/cli/commands/run.d.ts +11 -0
  20. package/dist/cli/commands/run.js +33 -5
  21. package/dist/cli/commands/status.js +49 -4
  22. package/dist/cli/commands/verify.js +332 -121
  23. package/dist/compile/native.js +137 -0
  24. package/dist/config/config.d.ts +40 -9
  25. package/dist/config/config.js +133 -15
  26. package/dist/config/fleet-overlay.d.ts +13 -3
  27. package/dist/config/fleet-overlay.js +12 -8
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +9 -1
  31. package/dist/drivers/orca.js +29 -7
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/gates/acceptance.d.ts +7 -0
  35. package/dist/gates/acceptance.js +27 -5
  36. package/dist/gates/baseline.d.ts +20 -1
  37. package/dist/gates/baseline.js +100 -20
  38. package/dist/gates/cache.d.ts +8 -0
  39. package/dist/gates/cache.js +12 -2
  40. package/dist/gates/llm.d.ts +6 -0
  41. package/dist/gates/llm.js +27 -8
  42. package/dist/gates/review.d.ts +6 -1
  43. package/dist/gates/review.js +122 -32
  44. package/dist/gates/run-gates.d.ts +54 -3
  45. package/dist/gates/run-gates.js +331 -45
  46. package/dist/gates/test-manifest.d.ts +42 -0
  47. package/dist/gates/test-manifest.js +69 -10
  48. package/dist/route/router.d.ts +23 -1
  49. package/dist/route/router.js +54 -16
  50. package/dist/run/consult.d.ts +3 -1
  51. package/dist/run/consult.js +4 -2
  52. package/dist/run/daemon.d.ts +2 -1
  53. package/dist/run/daemon.js +349 -79
  54. package/dist/run/interactive-seed.d.ts +4 -0
  55. package/dist/run/interactive-seed.js +35 -9
  56. package/dist/run/journal.d.ts +123 -1
  57. package/dist/run/journal.js +480 -17
  58. package/dist/run/lease.d.ts +13 -0
  59. package/dist/run/lease.js +45 -0
  60. package/dist/run/protocol.d.ts +15 -0
  61. package/dist/run/protocol.js +11 -1
  62. package/dist/run/receipt-resolver.d.ts +22 -0
  63. package/dist/run/receipt-resolver.js +40 -1
  64. package/dist/run/repair-selection.d.ts +11 -1
  65. package/dist/run/repair-selection.js +17 -9
  66. package/dist/run/wall-budget.d.ts +48 -0
  67. package/dist/run/wall-budget.js +280 -0
  68. package/dist/tui/cockpit/live-store.d.ts +6 -0
  69. package/dist/tui/cockpit/live-store.js +36 -11
  70. package/dist/tui/cockpit/run-cockpit.js +2 -2
  71. package/dist/tui/cockpit/run-view.d.ts +2 -1
  72. package/dist/tui/cockpit/run-view.js +13 -9
  73. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  74. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  75. package/package.json +2 -1
  76. package/schema/config.schema.json +8 -1
  77. package/skills/tickmarkr-auto/SKILL.md +2 -2
  78. package/skills/tickmarkr-loop/SKILL.md +17 -5
  79. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -1,7 +1,9 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { performance } from "node:perf_hooks";
5
7
  import { TEST_REPORTER_SOURCE } from "./test-reporter.js";
6
8
  import { shq } from "../adapters/types.js";
7
9
  import { beginGateEvidence, redactGateOutput } from "./baseline.js";
@@ -175,13 +177,16 @@ export function readTestReport(path) {
175
177
  * module killed for a per-file hang never reaches its report.
176
178
  */
177
179
  export function verifyManifestReport(opts) {
178
- const { manifest, nonce, exitCode, report, killedFile, hangBudgetMs } = opts;
180
+ const { manifest, nonce, exitCode, report, killedFile, hangBudgetMs, hangActiveMs, hangWallMs } = opts;
179
181
  if (killedFile !== undefined) {
182
+ const service = hangActiveMs !== undefined && hangWallMs !== undefined
183
+ ? ` (${Math.round(hangActiveMs)}ms active of ${Math.round(hangWallMs)}ms wall)` : "";
180
184
  return {
181
185
  kind: "infra",
182
186
  pass: false,
183
- details: `infra hang: "${killedFile}" started and did not complete within its ${hangBudgetMs}ms budget — killed, its process group is gone`,
184
- meta: { classification: "infra", infra: true, kind: "hang", file: killedFile, hangBudgetMs },
187
+ details: `infra hang: "${killedFile}" started and did not complete within its ${hangBudgetMs}ms budget${service} — killed, its process group is gone`,
188
+ meta: { classification: "infra", infra: true, kind: "hang", file: killedFile, hangBudgetMs,
189
+ ...(hangActiveMs !== undefined ? { activeMs: hangActiveMs, wallMs: hangWallMs } : {}) },
185
190
  };
186
191
  }
187
192
  if (!report) {
@@ -334,6 +339,36 @@ export function fileHangBudgetMs(file, baselineDurations, ceilingMs = DEFAULT_FI
334
339
  const known = timings.find((d) => d.file === file)?.durationMs;
335
340
  return Math.min(ceiling, known === undefined ? FILE_HANG_SLACK * longest : Math.max(FILE_HANG_SLACK * known, longest));
336
341
  }
342
+ /**
343
+ * OBS-953 (+add): a per-file hang budget counted in raw wall time charged a lid-close to the file that
344
+ * was running. Each poll compares how far the wall clock and the monotonic clock advanced since the
345
+ * last one. Wall advancing more than monotonic by over CLOCK_JUMP_SLACK_MS is a DETECTED discontinuity
346
+ * (`host-suspend`): its offset is subtracted from the active elapsed time of every file started before
347
+ * it ended. A poll that arrives overdue while both clocks advanced together is recorded as `unknown`
348
+ * and subtracts nothing — an ambiguous gap never excuses an active hang. Whether the host really slept
349
+ * is not provable from these clocks (a manually set clock jumps the same way); only the offset is.
350
+ */
351
+ export const CLOCK_JUMP_SLACK_MS = 1_000;
352
+ const systemClocks = { wall: () => Date.now(), mono: () => performance.now() };
353
+ let hangClocks = systemClocks;
354
+ export const setHangClocksForTests = (clocks) => { hangClocks = clocks; };
355
+ export const resetHangClocksForTests = () => { hangClocks = systemClocks; };
356
+ /** A poll gap worth recording, or undefined for an on-time poll. */
357
+ export function classifyPollGap(prev, now, pollMs) {
358
+ const wallMs = now.wall - prev.wall;
359
+ const monoMs = now.mono - prev.mono;
360
+ const gap = { from: prev.wall, to: now.wall, wallMs, monoMs };
361
+ if (wallMs - monoMs > CLOCK_JUMP_SLACK_MS)
362
+ return { kind: "host-suspend", ...gap, subtractedMs: wallMs - monoMs };
363
+ if (monoMs > pollMs + CLOCK_JUMP_SLACK_MS)
364
+ return { kind: "unknown", ...gap, subtractedMs: 0 };
365
+ return undefined;
366
+ }
367
+ /** The detected suspend a file started at `startedAt` sat through. */
368
+ const suspendedSince = (interruptions, startedAt) => interruptions.reduce((sum, i) => sum + Math.min(i.subtractedMs, Math.max(0, i.to - Math.max(i.from, startedAt))), 0);
369
+ // The daemon journals each interruption where it ran (task gate, tip verify); no sink, no journal row.
370
+ const interruptionSink = new AsyncLocalStorage();
371
+ export const runWithInterruptionSink = (sink, run) => interruptionSink.run(sink, run);
337
372
  /** Supervise the configured command and poll the runner's atomic lifecycle snapshots. Every
338
373
  * timeout kills the detached process group, including descendants holding the output pipes. */
339
374
  export function runManifestedTest(cmd, cwd, opts) {
@@ -345,20 +380,35 @@ export function runManifestedTest(cmd, cwd, opts) {
345
380
  let pid;
346
381
  let killedFile;
347
382
  let hangBudgetMs;
383
+ let hangActiveMs;
384
+ let hangWallMs;
348
385
  let poll;
386
+ const interruptions = [];
387
+ const sink = interruptionSink.getStore();
388
+ let last = { wall: hangClocks.wall(), mono: hangClocks.mono() };
349
389
  const checkHang = (atCeiling = false) => {
390
+ const at = { wall: hangClocks.wall(), mono: hangClocks.mono() };
391
+ const gap = classifyPollGap(last, at, pollMs);
392
+ last = at;
393
+ if (gap) {
394
+ interruptions.push(gap);
395
+ sink?.(gap);
396
+ }
350
397
  const report = readTestReport(opts.reportPath);
351
398
  if (!report || report.nonce !== opts.nonce)
352
399
  return;
353
- const now = Date.now();
354
400
  for (const file of opts.manifest) {
355
401
  const startedAt = report.started[file];
356
402
  if (startedAt === undefined || file in report.completed)
357
403
  continue;
358
404
  const budget = fileHangBudgetMs(file, opts.baselineDurations, overallCeilingMs, opts.longestFile);
359
- if (now - startedAt >= budget || (atCeiling && budget === overallCeilingMs)) {
405
+ const wallMs = at.wall - startedAt;
406
+ const activeMs = wallMs - suspendedSince(interruptions, startedAt);
407
+ if (activeMs >= budget || (atCeiling && budget === overallCeilingMs)) {
360
408
  killedFile = file;
361
409
  hangBudgetMs = budget;
410
+ hangActiveMs = activeMs;
411
+ hangWallMs = wallMs;
362
412
  if (!atCeiling)
363
413
  controller.abort();
364
414
  return;
@@ -372,6 +422,7 @@ export function runManifestedTest(cmd, cwd, opts) {
372
422
  pid = childPid;
373
423
  // Neither the per-file clocks nor the command ceiling includes lease queue time.
374
424
  clearInterval(poll);
425
+ last = { wall: hangClocks.wall(), mono: hangClocks.mono() };
375
426
  poll = setInterval(checkHang, pollMs);
376
427
  },
377
428
  }).then((result) => {
@@ -380,7 +431,7 @@ export function runManifestedTest(cmd, cwd, opts) {
380
431
  evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt],
381
432
  exitCode: result.signalExit ? undefined : result.code,
382
433
  stdout: result.stdout, stderr: result.stderr,
383
- report: readTestReport(opts.reportPath), killedFile, hangBudgetMs, pid,
434
+ report: readTestReport(opts.reportPath), killedFile, hangBudgetMs, hangActiveMs, hangWallMs, interruptions, pid,
384
435
  };
385
436
  }).catch((error) => {
386
437
  if (error instanceof Error) {
@@ -539,8 +590,10 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
539
590
  pollMs: 20,
540
591
  });
541
592
  let verdict = verifyManifestReport({ manifest: files, nonce, exitCode: invoked.exitCode,
542
- report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs });
593
+ report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs,
594
+ hangActiveMs: invoked.hangActiveMs, hangWallMs: invoked.hangWallMs });
543
595
  evidenceReceipts.push(...invoked.evidenceReceipts);
596
+ const interruptions = [...invoked.interruptions];
544
597
  const stranded = strandedSingleForkFiles(files, nonce, invoked);
545
598
  if (stranded) {
546
599
  const first = invoked.report;
@@ -563,8 +616,10 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
563
616
  longestFile: opts.longestFile, overallCeilingMs: opts.overallCeilingMs ?? DEFAULT_FILE_HANG_BUDGET_MS, pollMs: 20,
564
617
  });
565
618
  evidenceReceipts.push(...invoked.evidenceReceipts);
619
+ interruptions.push(...invoked.interruptions);
566
620
  verdict = verifyManifestReport({ manifest: stranded, nonce, exitCode: invoked.exitCode,
567
- report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs });
621
+ report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs,
622
+ hangActiveMs: invoked.hangActiveMs, hangWallMs: invoked.hangWallMs });
568
623
  // An all-skipped retry may contribute lifecycle accounting, but only the combined manifest
569
624
  // can establish executed success. Never rewrite either invocation's persisted certificate.
570
625
  if (verdict.pass || verdict.meta.noExecutedModules) {
@@ -596,11 +651,15 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
596
651
  + [...runnerErrors,
597
652
  stdoutTail.toString(), stderrTail.toString()].filter(Boolean).map(text => `\n${text}`).join("")
598
653
  : "";
654
+ // The interruption is reported beside the verdict, never folded into it or into raw wall service.
655
+ const interrupted = interruptions.length
656
+ ? `\nhost interruptions: ${interruptions.map(i => `${i.kind} ${Math.round(i.wallMs)}ms wall / ${Math.round(i.monoMs)}ms monotonic, ${Math.round(i.subtractedMs)}ms subtracted`).join("; ")}`
657
+ : "";
599
658
  return { evidenceReceipt, evidenceReceipts, pass: verdict.pass, kind: verdict.kind,
600
- details: verdict.details + diagnostics,
659
+ details: verdict.details + interrupted + diagnostics,
601
660
  classification: verdict.meta.classification,
602
661
  meta: { ...verdict.meta, nonce, manifest: files, manifestPath, listingCommand: invocation.listing, verification,
603
- ...(recovery ? { recovery, retryable: false } : {}),
662
+ ...(recovery ? { recovery, retryable: false } : {}), ...(interruptions.length ? { interruptions } : {}),
604
663
  spawnedCommand, processExit: invoked.exitCode, pid: invoked.pid, stdoutPath, stderrPath },
605
664
  exitCode: invoked.exitCode ?? -1, reportPath };
606
665
  }
@@ -1,5 +1,5 @@
1
1
  import { type Assignment, type BillingChannel } from "../adapters/types.js";
2
- import { type TickmarkrConfig } from "../config/config.js";
2
+ import { type TickmarkrConfig, type Tier } from "../config/config.js";
3
3
  import type { Task } from "../graph/schema.js";
4
4
  import { type RoutingProfile } from "./profile.js";
5
5
  export type LadderStep = "retry" | "escalate" | "consult" | "human";
@@ -44,6 +44,28 @@ export declare function rankPreferredChannels<C extends ChannelSeat>(channels: r
44
44
  }): C[];
45
45
  /** Review preserves its established tier-then-cost order inside a preference band. */
46
46
  export declare const reviewPreferenceTieBreak: (a: BillingChannel, b: BillingChannel) => number;
47
+ export declare function resolvedFloor(task: Task, cfg: TickmarkrConfig): {
48
+ tier: Tier;
49
+ source: "task" | "config";
50
+ } | undefined;
51
+ export declare const taskPool: (task: Task, cfg: TickmarkrConfig) => {
52
+ mode: "any" | "ordered";
53
+ channels: string[];
54
+ } | undefined;
55
+ /** Park-reason suffix naming the pool an exhausted re-dispatch stayed inside; empty when unpooled. */
56
+ export declare const poolExhaustion: (task: Task, cfg: TickmarkrConfig) => string;
57
+ export declare const inTaskPool: (task: Task, cfg: TickmarkrConfig, key: string) => boolean;
58
+ /**
59
+ * F (D-718): the one exclusion meaning route(), nextChannel() and climbChannel() share — explicit
60
+ * exclusions, demotions, tried and escalation history, and climb skips — exported so resume can test a
61
+ * restored assignment the same way. An excluded raw channel key always excludes itself (the fallback).
62
+ * The exclusion extends to another offered channel only when BOTH offered channels carry a probed
63
+ * identity and the two are equal. A missing identity is neutral: it never matches and never blocks.
64
+ * Residual, by ruling: an unprobed alias of an excluded model stays eligible by its raw key — this is
65
+ * not complete alias exclusion; reopen on an executed re-dispatch of a tried model through an alias
66
+ * with no probed identity. The daemon's nextChannel-null exhaustion fallback is not this policy's.
67
+ */
68
+ export declare function channelExclusion(offered: readonly BillingChannel[], excluded: Iterable<string>): (seat: ChannelSeat) => boolean;
47
69
  export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>, exploreCtx?: ExploreContext): Route;
48
70
  export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): Assignment | null;
49
71
  export type ClimbPick = Assignment & {
@@ -1,6 +1,6 @@
1
1
  import { channelKey, channelsFromConfig } from "../adapters/types.js";
2
2
  import { TIER_RANK } from "../config/config.js";
3
- import { channelRouteIdentity, disallowedBy, modelRouteIdentity, routingModelProvider } from "./preference.js";
3
+ import { disallowedBy, routingModelProvider } from "./preference.js";
4
4
  import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
5
5
  export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
6
6
  // OBS-89 (v1.60): the TICKMARKR_QUALITY variable is RETIRED — nothing in src reads it anymore and
@@ -65,6 +65,24 @@ export function rankPreferredChannels(channels, prefer = [], opts = {}) {
65
65
  }
66
66
  /** Review preserves its established tier-then-cost order inside a preference band. */
67
67
  export const reviewPreferenceTieBreak = (a, b) => TIER_RANK[b.tier] - TIER_RANK[a.tier] || Number(a.channel === "api") - Number(b.channel === "api");
68
+ // OBS-1185: the floor route() resolves, shared with plan's display — a task hint at or above the
69
+ // configured (or mode-compiled) floor wins; otherwise the configured floor stands.
70
+ export function resolvedFloor(task, cfg) {
71
+ const taskFloor = task.routingHints?.floor;
72
+ const configFloor = cfg.routing.floors[task.shape];
73
+ if (taskFloor && (!configFloor || TIER_RANK[taskFloor] >= TIER_RANK[configFloor]))
74
+ return { tier: taskFloor, source: "task" };
75
+ return configFloor ? { tier: configFloor, source: "config" } : undefined;
76
+ }
77
+ // OBS-1187: a declared map pool is the shape's closed candidate set for EVERY dispatch — demotion,
78
+ // retry-ban failover, tier climb, recycling and resume draw inside it, never from the open fleet.
79
+ export const taskPool = (task, cfg) => cfg.routing.map[task.shape]?.pool;
80
+ /** Park-reason suffix naming the pool an exhausted re-dispatch stayed inside; empty when unpooled. */
81
+ export const poolExhaustion = (task, cfg) => {
82
+ const pool = taskPool(task, cfg);
83
+ return pool ? ` — routing.map.${task.shape}.pool (${pool.mode}: ${pool.channels.join(", ")}) is exhausted` : "";
84
+ };
85
+ export const inTaskPool = (task, cfg, key) => taskPool(task, cfg)?.channels.includes(key) ?? true;
68
86
  function ladderFor(task, entry) {
69
87
  const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
70
88
  return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
@@ -106,11 +124,33 @@ function spreadFrontierTies(sorted, task, prefer, tied) {
106
124
  }
107
125
  return out;
108
126
  }
127
+ /**
128
+ * F (D-718): the one exclusion meaning route(), nextChannel() and climbChannel() share — explicit
129
+ * exclusions, demotions, tried and escalation history, and climb skips — exported so resume can test a
130
+ * restored assignment the same way. An excluded raw channel key always excludes itself (the fallback).
131
+ * The exclusion extends to another offered channel only when BOTH offered channels carry a probed
132
+ * identity and the two are equal. A missing identity is neutral: it never matches and never blocks.
133
+ * Residual, by ruling: an unprobed alias of an excluded model stays eligible by its raw key — this is
134
+ * not complete alias exclusion; reopen on an executed re-dispatch of a tried model through an alias
135
+ * with no probed identity. The daemon's nextChannel-null exhaustion fallback is not this policy's.
136
+ */
137
+ export function channelExclusion(offered, excluded) {
138
+ const keys = new Set(excluded);
139
+ const identityOf = new Map(offered.flatMap((c) => (c.identity ? [[channelKey(c), c.identity]] : [])));
140
+ const identities = new Set([...keys].flatMap((k) => identityOf.get(k) ?? []));
141
+ return (seat) => {
142
+ const key = channelKey(seat);
143
+ const identity = identityOf.get(key);
144
+ return keys.has(key) || (identity !== undefined && identities.has(identity));
145
+ };
146
+ }
109
147
  function withoutExcluded(channels, exclude) {
110
148
  if (!exclude?.size)
111
149
  return channels;
112
150
  // OBS-57: in-run demotion after consecutive no-trailer windows — route around poisoned channels for the rest of the run.
113
- return channels.filter((c) => !exclude.has(channelKey(c)));
151
+ // D-718: a demoted channel's probed-identical alias is routed around too; an unprobed one is not.
152
+ const excluded = channelExclusion(channels, exclude);
153
+ return channels.filter((c) => !excluded(c));
114
154
  }
115
155
  // Pools spread providers before tier, independently within each marginal-cost run. Stable
116
156
  // cost sorting preserves declaration order when forming groups; gateways share the served
@@ -199,9 +239,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
199
239
  }
200
240
  };
201
241
  const taskFloor = task.routingHints?.floor;
202
- const mapPinFloor = taskFloor && (!advisoryFloor || TIER_RANK[taskFloor] >= TIER_RANK[advisoryFloor])
203
- ? { tier: taskFloor, source: "task" }
204
- : advisoryFloor ? { tier: advisoryFloor, source: "config" } : undefined;
242
+ const mapPinFloor = resolvedFloor(task, cfg);
205
243
  const lintMapPinFloor = (tier, what = "pin") => {
206
244
  if (mapPinFloor && TIER_RANK[tier] < TIER_RANK[mapPinFloor.tier]) {
207
245
  lints.push(`${task.id} (${task.shape}): map ${what} routes ${tier}, below ${mapPinFloor.source} floor ${mapPinFloor.tier} — map ${what}s are supreme`);
@@ -364,8 +402,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
364
402
  eligible = spreadStatic(sortedStatic);
365
403
  spreadDecided = channelKey(eligible[0]) !== channelKey(sortedStatic[0]);
366
404
  }
367
- const bound = taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
368
- floor ? `floor ${floor} (config floors)` :
405
+ const resolved = resolvedFloor(task, cfg);
406
+ const bound = resolved?.source === "task" ? `floor ${resolved.tier} (task hint${src})` :
407
+ resolved ? `floor ${resolved.tier} (config floors)` :
369
408
  "tier cheap (default)";
370
409
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
371
410
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
@@ -376,20 +415,19 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
376
415
  maybeSlaLint(lints, task, profile, slaMinutes, eligible[0]);
377
416
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
378
417
  }
379
- function candidatePool(current, channels, tried, tierPredicate, exclude) {
380
- channels = withoutExcluded(channels, exclude);
418
+ function candidatePool(current, task, cfg, channels, tried, tierPredicate, exclude) {
419
+ // D-718: tried/escalated/demoted keys share channelExclusion's meaning, resolved against every offered
420
+ // channel; the provider-outage rule below still reads the raw-key remainder exactly as before.
421
+ const excluded = channelExclusion(channels, [...(exclude ?? []), ...tried]);
422
+ channels = channels.filter((c) => !exclude?.has(channelKey(c)) && inTaskPool(task, cfg, channelKey(c)));
381
423
  const triedKeys = new Set(tried);
382
- const triedIdentities = new Set(tried.map((key) => {
383
- const channel = channels.find((c) => channelKey(c) === key);
384
- return channel ? modelRouteIdentity(channel.model, channel.vendor) : channelRouteIdentity(key);
385
- }));
386
424
  // excludeAdapter is expanded by the daemon into every channel key of the failed adapter. Once that
387
425
  // complete set is present, the outage follows the current served provider across gateway aliases.
388
426
  const currentAdapterExcluded = channels.some((c) => c.adapter === current.adapter)
389
427
  && channels.filter((c) => c.adapter === current.adapter).every((c) => triedKeys.has(channelKey(c)));
390
428
  const currentChannel = channels.find((c) => c.adapter === current.adapter && c.model === current.model);
391
429
  const excludedProvider = currentAdapterExcluded ? routingModelProvider(current.model, currentChannel?.vendor) : undefined;
392
- return channels.filter((c) => !triedIdentities.has(modelRouteIdentity(c.model, c.vendor))
430
+ return channels.filter((c) => !excluded(c)
393
431
  && (!excludedProvider || routingModelProvider(c.model, c.vendor) !== excludedProvider)
394
432
  && tierPredicate(c.tier));
395
433
  }
@@ -406,7 +444,7 @@ export function nextChannel(current, task, cfg, channels, tried, profile, exclud
406
444
  // profile-dependent filter. NO exploration bonus here (route():110 has one; a probe on the
407
445
  // failure path would spend a real retry). Absent profile ⇒ every score is 0 ⇒ third key
408
446
  // all-ties ⇒ the stable sort preserves the exact v1.7 candidate ORDER.
409
- const pool = candidatePool(current, channels, tried, (tier) => TIER_RANK[tier] >= TIER_RANK[current.tier], exclude);
447
+ const pool = candidatePool(current, task, cfg, channels, tried, (tier) => TIER_RANK[tier] >= TIER_RANK[current.tier], exclude);
410
448
  const candidates = rankFailoverCandidates(pool, task, cfg, profile);
411
449
  return candidates.length ? toAssignment(candidates[0]) : null;
412
450
  }
@@ -449,7 +487,7 @@ export function climbChannel(current, task, cfg, channels, tried, profile, exclu
449
487
  const pick = nextChannel(current, task, cfg, channels, tried, profile, exclude);
450
488
  return pick ? makeClimbPick(pick, false, "no higher tier") : null;
451
489
  }
452
- const higherPool = candidatePool(current, channels, tried, (tier) => TIER_RANK[tier] > TIER_RANK[current.tier], exclude);
490
+ const higherPool = candidatePool(current, task, cfg, channels, tried, (tier) => TIER_RANK[tier] > TIER_RANK[current.tier], exclude);
453
491
  if (higherPool.length > 0) {
454
492
  const candidates = rankFailoverCandidates(higherPool, task, cfg, profile);
455
493
  return makeClimbPick(toAssignment(candidates[0]), true);
@@ -1,4 +1,4 @@
1
- import { type WorkerAdapter } from "../adapters/types.js";
1
+ import { type AuthHealth, type WorkerAdapter } from "../adapters/types.js";
2
2
  import type { TickmarkrConfig } from "../config/config.js";
3
3
  import type { Effort } from "../graph/schema.js";
4
4
  import type { ExecutorDriver, Slot } from "../drivers/types.js";
@@ -56,5 +56,7 @@ export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: Work
56
56
  vendor?: string;
57
57
  channel?: "sub" | "api";
58
58
  tier?: string;
59
+ identity?: string;
59
60
  }>;
61
+ health?: Record<string, AuthHealth> | null;
60
62
  }): Promise<ConsultVerdict>;
@@ -6,7 +6,7 @@ import { bannerShell, paneDispatchCommand } from "../brand.js";
6
6
  import { dewrapPaneVerdict, extractVerdictJson, gateExitTrailer, gatePaneName, generateVerdictNonce, verdictNonceLine } from "../gates/llm.js";
7
7
  import { classifyVerdictCause } from "../gates/verdict-cause.js";
8
8
  import { rankPreferredChannels } from "../route/role-pick.js";
9
- import { disallowedBy, routingModelProvider } from "../route/preference.js";
9
+ import { disallowedBy, observedSeat, routingModelProvider } from "../route/preference.js";
10
10
  import { sh } from "./git.js";
11
11
  import { redactSecrets } from "./redact.js";
12
12
  import { filterLlmTranscript } from "./stall.js";
@@ -148,6 +148,7 @@ export async function consult(d, cfg, adapters, driver, cwd, runDir,
148
148
  // judged against it only (never rebuilt from config, which would select installed-but-unauthed seats).
149
149
  // OBS-1182: onInvocation hears every launched seat — its identity, effort and outcome — failed seats
150
150
  // included, so a seat that never produced the verdict still reaches the journal.
151
+ // OBS-1186: health — doctor's cached verdict, the observed identity of the pinned seat (absent ⇒ unknown, conservative).
151
152
  opts = {}) {
152
153
  const n = ++consultSeq;
153
154
  const nonce = generateVerdictNonce();
@@ -219,8 +220,9 @@ opts = {}) {
219
220
  // Rank only exact live models from the daemon's role-scoped discovery pool.
220
221
  // The explicit pin remains a caller-owned fallback, outside strict preference selection.
221
222
  const live = (opts.channels ?? []).filter((c) => typeof c.model === "string");
223
+ // Ranking keeps each live channel's observed identity; the pin carries its cached identity too.
222
224
  const seats = rankPreferredChannels(live.filter((c) => disallowedBy(c, cfg.routing, "consult") === null), cfg.consult.prefer);
223
- seats.push({ adapter: cfg.consult.adapter, model: cfg.consult.model });
225
+ seats.push(observedSeat(opts.health, cfg.consult.adapter, cfg.consult.model));
224
226
  // v1.87 T2: a consult seat reads the code, so it passes through the operator's policy exactly like
225
227
  // a worker does. The filter runs AFTER the pin is pushed, so the final pinned seat is checked by
226
228
  // the same rule as every prefer entry — and disallowedBy carries the full deny grammar (adapter,
@@ -2,7 +2,7 @@ import { type WorkerAdapter } from "../adapters/types.js";
2
2
  import { type ModeResolution, type RoutingMode, type TickmarkrConfig } from "../config/config.js";
3
3
  import { type DriverChoice } from "../drivers/index.js";
4
4
  import { type ExecutorDriver, type Slot } from "../drivers/types.js";
5
- import { type Baseline, type BaselineProvenance } from "../gates/baseline.js";
5
+ import { type Baseline, type BaselineProvenance, type GateEvidenceOptions } from "../gates/baseline.js";
6
6
  import type { GateResult } from "../gates/types.js";
7
7
  import { type RunGraph } from "../graph/schema.js";
8
8
  import { type PreserveProducer } from "./git.js";
@@ -244,6 +244,7 @@ export declare function verifyIntegrationTipCached(intWt: string, commands: Reco
244
244
  lastMergedTask?: string;
245
245
  baseline?: Baseline;
246
246
  signal?: AbortSignal;
247
+ evidence?: GateEvidenceOptions;
247
248
  }): Promise<boolean>;
248
249
  type SuitePidProbe = (pid: number) => number | undefined;
249
250
  /** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership