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.
@@ -64,9 +64,42 @@ export declare function matchClosureId(candidate: unknown, fingerprints: Iterabl
64
64
  * all route through matchClosureId.
65
65
  */
66
66
  export declare function isReviewClosureInvalid(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly string[]): boolean;
67
+ export type ReviewerFloorCause = "author-tier" | "task-floor" | "config" | "prior-reviewer";
68
+ /**
69
+ * RF-1 (OBS-922 add.2/3): the tier a reviewer must meet is the maximum of the author's tier, the
70
+ * task-declared floor, a configured `review.floor` tier and, on a second round or a retry, the prior
71
+ * reviewer's tier. The cause names the input that reached the maximum (earlier inputs win a tie, so a
72
+ * floor the author's tier already satisfies is attributed to the author).
73
+ */
74
+ export declare function resolveReviewerFloor(authorTier: Tier, taskFloor?: Tier, configFloor?: Tier, priorReviewerTier?: Tier): {
75
+ floor: Tier;
76
+ cause: ReviewerFloorCause;
77
+ };
78
+ /** A prior reviewer of THIS task: a channel key, or a journaled row's key plus the tier it was DISPATCHED at. */
79
+ export type PriorReviewer = string | {
80
+ reviewer: string;
81
+ tier?: unknown;
82
+ };
83
+ /**
84
+ * The highest tier among the task's prior reviewers — the prior reviewer's tier for RF-1. A recorded
85
+ * dispatch tier is historical evidence and wins over the current pool; an unrecorded one falls back to
86
+ * the seat's channel; a seat neither establishes (it left the pool on resume, or the journal holds
87
+ * garbage) holds frontier — fail closed, never silently dropped.
88
+ */
89
+ export declare function priorReviewerTier(channels: BillingChannel[], priorReviewers?: readonly PriorReviewer[]): Tier | undefined;
90
+ /**
91
+ * The gate's floor: author tier, task floor, review.floor (a tier — `worker` names none) and the seats
92
+ * the caller names as THIS TASK's prior reviewers (earlier rounds' seats, a flaked seat). Eligibility
93
+ * exclusions are NOT evidence — a retry bans a flaked seat's whole adapter, and those sibling channels
94
+ * never reviewed — and neither is the run-scoped LRU rotation history, which names unrelated tasks' seats.
95
+ */
96
+ export declare function gateReviewerFloor(task: Pick<Task, "routingHints">, cfg: TickmarkrConfig, author: Assignment, channels: BillingChannel[], priorReviewers?: readonly PriorReviewer[]): {
97
+ floor: Tier;
98
+ cause: ReviewerFloorCause;
99
+ };
67
100
  export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[], // v1.1 failover: reviewer channels that already produced garbage for this task
68
101
  prefer?: string[], // v1.53 T2: review.prefer — reorders eligible channels, never changes eligibility
69
- floor?: Tier, // task-declared only; config floors govern workers and must not silently move review seats
102
+ floor?: Tier, // task/config/prior floor from the caller; the author's own tier is ALWAYS applied here (RF-1)
70
103
  history?: string[], // run-scoped picks, oldest to newest; empty preserves the established ranking
71
104
  onSeat?: (seat: number, count: number) => void, demoted?: ReadonlySet<string>): BillingChannel | null;
72
105
  export type ReviewUnparseableCause = VerdictUnparseableCause | "launch-never-started" | "truncated" | "silent";
@@ -76,4 +109,4 @@ export type ReviewUnparseableCause = VerdictUnparseableCause | "launch-never-sta
76
109
  * judgement rather than a guarantee made by this renderer.
77
110
  */
78
111
  export declare function renderDeclaredWriteScope(files: ReadonlyArray<string>): string;
79
- export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string, reviewHistory?: string[], demotedReviewers?: ReadonlySet<string>, carriedFindings?: readonly StructuredFinding[]): Promise<GateResult>;
112
+ export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string, reviewHistory?: string[], demotedReviewers?: ReadonlySet<string>, carriedFindings?: readonly StructuredFinding[], priorReviewers?: readonly PriorReviewer[]): Promise<GateResult>;
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { channelKey, shq } from "../adapters/types.js";
4
4
  import { criticalPathHits, DEFAULT_DIFF_CAP, DEFAULT_REVIEW_CRITICAL_PATHS, declaredReviewPolicy, isReviewLeafPath, raiseReviewPolicy, REVIEW_VERSION_MIRRORS, TIER_RANK, } from "../config/config.js";
5
5
  import { filesGlob } from "../graph/files-glob.js";
6
- import { renderAcceptanceItem } from "../graph/schema.js";
6
+ import { renderAcceptanceItem, TIERS } from "../graph/schema.js";
7
7
  import { getAdapter } from "../adapters/registry.js";
8
8
  import { shOk } from "../run/git.js";
9
9
  import { structuredFindings } from "../run/journal.js";
@@ -200,9 +200,52 @@ function reviewPreferIndex(c, prefer) {
200
200
  const i = prefer.findIndex((p) => p === c.adapter || p === channelKey(c));
201
201
  return i === -1 ? prefer.length : i;
202
202
  }
203
+ /**
204
+ * RF-1 (OBS-922 add.2/3): the tier a reviewer must meet is the maximum of the author's tier, the
205
+ * task-declared floor, a configured `review.floor` tier and, on a second round or a retry, the prior
206
+ * reviewer's tier. The cause names the input that reached the maximum (earlier inputs win a tie, so a
207
+ * floor the author's tier already satisfies is attributed to the author).
208
+ */
209
+ export function resolveReviewerFloor(authorTier, taskFloor, configFloor, priorReviewerTier) {
210
+ const inputs = [
211
+ [authorTier, "author-tier"], [taskFloor, "task-floor"], [configFloor, "config"], [priorReviewerTier, "prior-reviewer"],
212
+ ];
213
+ let best = { floor: authorTier, cause: "author-tier" };
214
+ for (const [tier, cause] of inputs) {
215
+ if (tier !== undefined && TIER_RANK[tier] > TIER_RANK[best.floor])
216
+ best = { floor: tier, cause };
217
+ }
218
+ return best;
219
+ }
220
+ /**
221
+ * The highest tier among the task's prior reviewers — the prior reviewer's tier for RF-1. A recorded
222
+ * dispatch tier is historical evidence and wins over the current pool; an unrecorded one falls back to
223
+ * the seat's channel; a seat neither establishes (it left the pool on resume, or the journal holds
224
+ * garbage) holds frontier — fail closed, never silently dropped.
225
+ */
226
+ export function priorReviewerTier(channels, priorReviewers = []) {
227
+ let top;
228
+ for (const p of priorReviewers) {
229
+ const key = typeof p === "string" ? p : p.reviewer;
230
+ const seen = (typeof p === "string" ? undefined : p.tier) ?? channels.find((ch) => channelKey(ch) === key)?.tier;
231
+ const tier = TIERS.includes(seen) ? seen : "frontier";
232
+ if (top === undefined || TIER_RANK[tier] > TIER_RANK[top])
233
+ top = tier;
234
+ }
235
+ return top;
236
+ }
237
+ /**
238
+ * The gate's floor: author tier, task floor, review.floor (a tier — `worker` names none) and the seats
239
+ * the caller names as THIS TASK's prior reviewers (earlier rounds' seats, a flaked seat). Eligibility
240
+ * exclusions are NOT evidence — a retry bans a flaked seat's whole adapter, and those sibling channels
241
+ * never reviewed — and neither is the run-scoped LRU rotation history, which names unrelated tasks' seats.
242
+ */
243
+ export function gateReviewerFloor(task, cfg, author, channels, priorReviewers = []) {
244
+ return resolveReviewerFloor(author.tier, task.routingHints?.floor, cfg.review.floor === "worker" ? undefined : cfg.review.floor, priorReviewerTier(channels, priorReviewers));
245
+ }
203
246
  export function pickReviewer(author, channels, exclude = [], // v1.1 failover: reviewer channels that already produced garbage for this task
204
247
  prefer = [], // v1.53 T2: review.prefer — reorders eligible channels, never changes eligibility
205
- floor, // task-declared only; config floors govern workers and must not silently move review seats
248
+ floor, // task/config/prior floor from the caller; the author's own tier is ALWAYS applied here (RF-1)
206
249
  history = [], // run-scoped picks, oldest to newest; empty preserves the established ranking
207
250
  onSeat, demoted = new Set()) {
208
251
  // FLEET-05 success criterion 2: an author not resolvable in the channel list yields NO reviewer.
@@ -213,6 +256,8 @@ onSeat, demoted = new Set()) {
213
256
  if (!authorChannel)
214
257
  return null;
215
258
  const authorProvider = modelProvider(author.model, authorChannel.vendor);
259
+ // RF-1: every caller inherits the author-tier floor — a reviewer is never seated below its author.
260
+ const effectiveFloor = resolveReviewerFloor(author.tier, floor).floor;
216
261
  const ranked = channels
217
262
  // Three independent axes: different vendor, different resolved provider identity (OBS-946: on initial pick
218
263
  // as well as failover, so an aggregator channel stamped "mixed" never seats the author's own provider),
@@ -222,7 +267,7 @@ onSeat, demoted = new Set()) {
222
267
  && modelProvider(c.model, c.vendor) !== authorProvider
223
268
  && modelId(c.model) !== modelId(author.model)
224
269
  && !exclude.includes(channelKey(c))
225
- && (floor === undefined || TIER_RANK[c.tier] >= TIER_RANK[floor]))
270
+ && TIER_RANK[c.tier] >= TIER_RANK[effectiveFloor])
226
271
  .sort((a, b) => reviewPreferIndex(a, prefer) - reviewPreferIndex(b, prefer) || TIER_RANK[b.tier] - TIER_RANK[a.tier] || marginalCostRank(a) - marginalCostRank(b));
227
272
  const reviewer = [...ranked].sort((a, b) => Number(demoted.has(channelKey(a))) - Number(demoted.has(channelKey(b)))
228
273
  || history.lastIndexOf(channelKey(a)) - history.lastIndexOf(channelKey(b))
@@ -247,7 +292,10 @@ ${files.map((path) => `- ${path}`).join("\n")}`;
247
292
  export async function reviewGate(task, worktree, baseRef, author, channels, adapters, cfg, via, excludeReviewers,
248
293
  // OBS-196: run dir for raw-output persistence on an unparseable verdict; absent (older callers,
249
294
  // direct tests) skips persistence and changes nothing else.
250
- artifactDir, reviewHistory, demotedReviewers, carriedFindings = []) {
295
+ artifactDir, reviewHistory, demotedReviewers, carriedFindings = [],
296
+ // RF-1: channel keys of THIS task's prior reviewers (earlier rounds, a flaked seat) — task-scoped,
297
+ // never the run-wide rotation history nor excludeReviewers; the seat holds the highest of their tiers.
298
+ priorReviewers = []) {
251
299
  // R3 (OBS-186): participation is keyed on PATHS. The compiler's assignment comes from the DECLARED
252
300
  // files[]; the operator's floor may RAISE it to full and can never lower it. `complexityThreshold` is
253
301
  // retired — the branch that returned a green skip on a complexity comparison is gone, and with it the
@@ -317,20 +365,19 @@ artifactDir, reviewHistory, demotedReviewers, carriedFindings = []) {
317
365
  policy: "full",
318
366
  ...(promotedBy ? { promotedFrom: declaredPolicy, promotedBy } : {}),
319
367
  };
320
- // A reviewer floor is opt-in at the task. Applying cfg.routing.floors here would change the
321
- // historical seat for every task that never asked for review-tier coupling.
322
- const reviewerFloor = task.routingHints?.floor;
368
+ // RF-1: the floor is max(author tier, task floor, review.floor tier, prior reviewer's tier). Only
369
+ // review.floor is read from config — cfg.routing.floors governs workers and never moves review seats.
370
+ const { floor: reviewerFloor, cause: reviewerFloorCause } = gateReviewerFloor(task, cfg, author, channels, priorReviewers);
371
+ const floorMeta = { reviewerFloor, reviewerFloorCause };
323
372
  let rotationSeat;
324
373
  const reviewer = pickReviewer(author, channels, excludeReviewers ?? [], cfg.review.prefer ?? [], reviewerFloor, reviewHistory, reviewHistory ? (seat) => { rotationSeat = seat; } : undefined, demotedReviewers);
325
374
  if (!reviewer) {
326
375
  // meta.noEligibleReviewer lets run-gates' review-retry keep the ORIGINAL unparseable result when
327
376
  // the retry finds no second seat — a truthful cause beats a synthetic no-reviewer failure.
328
- const reason = reviewerFloor
329
- ? `no cross-vendor reviewer available at or above task-declared ${reviewerFloor} floor (diversity rule)`
330
- : "no cross-vendor reviewer available (diversity rule)";
377
+ const reason = `no cross-vendor reviewer available at or above ${reviewerFloor} floor (${reviewerFloorCause}; diversity rule)`;
331
378
  return cfg.review.required || priorMaterials.length > 0
332
- ? { gate: "review", pass: false, details: `unreadable — ${reason}; ${priorMaterials.length ? "carried materials require a review verdict" : "set review.required:false to waive"}`, meta: { noEligibleReviewer: true, unreadable: true, ...(reviewerFloor ? { reviewerFloor } : {}) } }
333
- : { gate: "review", pass: true, details: `WARNING: ${reason} — review waived by config`, meta: { noEligibleReviewer: true, ...(reviewerFloor ? { reviewerFloor } : {}) } };
379
+ ? { gate: "review", pass: false, details: `unreadable — ${reason}; ${priorMaterials.length ? "carried materials require a review verdict" : "set review.required:false to waive"}`, meta: { noEligibleReviewer: true, unreadable: true, ...floorMeta } }
380
+ : { gate: "review", pass: true, details: `WARNING: ${reason} — review waived by config`, meta: { noEligibleReviewer: true, ...floorMeta } };
334
381
  }
335
382
  reviewHistory?.push(channelKey(reviewer));
336
383
  const rotationMeta = rotationSeat === undefined ? {} : { rotationSeat };
@@ -369,6 +416,7 @@ Classify every concern as "material" (a correctness, security, or acceptance-cri
369
416
  block the merge) or "minor" (style, naming, or preference that should not block). ONLY material findings
370
417
  block approval. For a minor concern you have decided not to block on, set "defer": true and give a
371
418
  one-line "rationale" — it is recorded in the review, never dropped.
419
+ A fix you prescribe that would break suites outside the task's declared write scope (files[]) is a scope finding, never a material one.
372
420
 
373
421
  Respond with ONLY this JSON:
374
422
  {"nonce": "${nonce}", "approve": true|false, "resolved": [], "reraised": [], "findings": [{"note": "...", "severity": "material"|"minor", "defer": false, "rationale": ""}], "comments": [{"path": "path/to/file", "line": 42, "body": "actionable feedback"}]}
@@ -453,7 +501,9 @@ The top-level comments array is optional. Use it only for actionable line-anchor
453
501
  meta: {
454
502
  ...policyMeta,
455
503
  ...rotationMeta,
504
+ ...floorMeta,
456
505
  reviewer: channelKey(reviewer),
506
+ reviewerTier: reviewer.tier,
457
507
  vendor: reviewer.vendor,
458
508
  provider,
459
509
  ...(cause === "malformed-verdict" ? { unparseable: true } : { noVerdict: true, classification: "infra", infra: true }),
@@ -488,15 +538,13 @@ The top-level comments array is optional. Use it only for actionable line-anchor
488
538
  pass: decided.pass,
489
539
  details,
490
540
  meta: {
491
- ...policyMeta, ...rotationMeta, reviewer: channelKey(reviewer), vendor: reviewer.vendor, provider,
541
+ ...policyMeta, ...rotationMeta, ...floorMeta, reviewer: channelKey(reviewer), reviewerTier: reviewer.tier, vendor: reviewer.vendor, provider,
542
+ // OBS-990 b: the verbatim ids plus ONE normalised copy of each list — never a third alias.
492
543
  ...(priorMaterials.length ? {
493
544
  resolved: v.resolved,
494
545
  reraised: v.reraised,
495
- normalisedMatches: (v.resolved ?? []).map((id) => matchClosureId(id, priorIds)).filter((id) => id !== undefined),
496
546
  resolvedMatches: (v.resolved ?? []).map((id) => matchClosureId(id, priorIds)).filter((id) => id !== undefined),
497
547
  reraisedMatches: (v.reraised ?? []).map((id) => matchClosureId(id, priorIds)).filter((id) => id !== undefined),
498
- normalisedResolved: (v.resolved ?? []).map((id) => matchClosureId(id, priorIds)).filter((id) => id !== undefined),
499
- normalisedReraised: (v.reraised ?? []).map((id) => matchClosureId(id, priorIds)).filter((id) => id !== undefined),
500
548
  } : {}),
501
549
  ...(reraised.length ? { findings: [
502
550
  ...structuredFindings("review", details).filter((finding) => !reraised.some((prior) => prior.note === finding.note)),
@@ -3,6 +3,7 @@ import { type TickmarkrConfig } from "../config/config.js";
3
3
  import { type GateName, type Task } from "../graph/schema.js";
4
4
  import { type Baseline } from "./baseline.js";
5
5
  import { type GateVia } from "./llm.js";
6
+ import { type PriorReviewer } from "./review.js";
6
7
  import type { GateResult } from "./types.js";
7
8
  import { type StructuredFinding } from "../run/journal.js";
8
9
  export type LoadProvider = () => number;
@@ -57,6 +58,7 @@ export interface GateContext {
57
58
  excludeReviewers?: string[];
58
59
  demotedReviewers?: Set<string>;
59
60
  reviewHistory?: string[];
61
+ priorReviewers?: PriorReviewer[];
60
62
  artifactDir?: string;
61
63
  pipeline?: "v185" | "legacy";
62
64
  selectTests?: boolean;
@@ -11,7 +11,7 @@ import { evidenceGate } from "./evidence.js";
11
11
  import { captureLlmOutput } from "./llm.js";
12
12
  import { disallowedBy } from "../route/preference.js";
13
13
  import { marginalCostRank } from "../route/router.js";
14
- import { pickReviewer, reviewGate } from "./review.js";
14
+ import { gateReviewerFloor, pickReviewer, reviewGate } from "./review.js";
15
15
  import { scopeGate } from "./scope.js";
16
16
  import { shGit } from "../run/git.js";
17
17
  import { withJudgeInvocationEvidence } from "../run/journal.js";
@@ -602,7 +602,11 @@ export async function runGates(task, ctx) {
602
602
  }
603
603
  return rv;
604
604
  };
605
- let rv = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, ctx.via, ctx.excludeReviewers, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings));
605
+ // RF-1: THIS task's prior reviewers — earlier rounds' seats plus the seats that produced garbage for
606
+ // it (excludeReviewers names only dispatched seats). Kept apart from the eligibility exclusions the
607
+ // retry below adds for a flaked seat's whole adapter: those sibling channels never reviewed.
608
+ const priorReviewers = [...(ctx.priorReviewers ?? []), ...(ctx.excludeReviewers ?? [])];
609
+ let rv = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, ctx.via, ctx.excludeReviewers, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, priorReviewers));
606
610
  // OBS-193/574: an unparseable review verdict retries the REVIEW exactly once, preferring a
607
611
  // different adapter. Only a single-adapter eligible pool may fall back to another channel on the
608
612
  // flaked adapter. The flaked verdict never enters results; an exhausted pool preserves its cause.
@@ -622,10 +626,14 @@ export async function runGates(task, ctx) {
622
626
  const priorExclusions = ctx.excludeReviewers ?? [];
623
627
  const flakedAdapter = flaked.slice(0, flaked.indexOf(":"));
624
628
  const adapterExclusions = ctx.channels.filter((c) => c.adapter === flakedAdapter).map(channelKey);
625
- const crossAdapter = pickReviewer(ctx.author, ctx.channels, [...priorExclusions, ...adapterExclusions], ctx.cfg.review.prefer ?? [], task.routingHints?.floor);
629
+ // RF-1: the retry filters by the floor reviewGate resolves — author tier, task floor, review.floor
630
+ // and the prior reviewers' tiers, the flaked seat's own included, so a retry never drops a tier.
631
+ const retryPrior = [...priorReviewers, flaked];
632
+ const retryFloor = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, retryPrior).floor;
633
+ const crossAdapter = pickReviewer(ctx.author, ctx.channels, [...priorExclusions, ...adapterExclusions], ctx.cfg.review.prefer ?? [], retryFloor);
626
634
  const exclusion = crossAdapter ? "adapter" : "channel";
627
635
  const retryExclusions = [...priorExclusions, ...(crossAdapter ? adapterExclusions : [flaked])];
628
- const second = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, retryExclusions, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings));
636
+ const second = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, retryExclusions, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior));
629
637
  if (second.meta?.noEligibleReviewer !== true) {
630
638
  const retried = typeof second.meta?.reviewer === "string" ? second.meta.reviewer : "none";
631
639
  const route = exclusion === "adapter"
@@ -639,10 +647,11 @@ export async function runGates(task, ctx) {
639
647
  meta: { ...second.meta, reviewRetry: { flaked, retried, exclusion } },
640
648
  };
641
649
  }
642
- else if (task.routingHints?.floor) {
643
- // Preserve the original no-answer cause when no replacement exists, but name the declared
644
- // floor that correctly refused a lower-tier fallback.
645
- rv = { ...rv, details: `${rv.details}\nreview re-route refused: ${second.details}` };
650
+ else {
651
+ // Preserve the original no-answer cause when no replacement exists, but name the resolved
652
+ // floor that correctly refused a lower-tier fallback — in details AND in the row's meta.
653
+ const { reviewerFloor, reviewerFloorCause } = second.meta ?? {};
654
+ rv = { ...rv, details: `${rv.details}\nreview re-route refused: ${second.details}`, meta: { ...rv.meta, reviewerFloor, reviewerFloorCause } };
646
655
  }
647
656
  }
648
657
  return invocations.length ? { ...rv, meta: { ...rv.meta, invocations } } : rv;
@@ -33,3 +33,16 @@ export declare class RoutingError extends Error {
33
33
  export declare function marginalCostRank(c: BillingChannel): number;
34
34
  export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>, exploreCtx?: ExploreContext): Route;
35
35
  export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): Assignment | null;
36
+ export type ClimbPick = Assignment & {
37
+ climbed: boolean;
38
+ reason?: string;
39
+ };
40
+ export type ClimbResult = ClimbPick;
41
+ /**
42
+ * v2.5.4 OBS-986 (ES-1): climb one tier on request when untried channels exist in higher tiers.
43
+ * With routing.escalateTier on, returns the cheapest untried live channel strictly above current.tier,
44
+ * ordered as nextChannel orders (tier, then marginal cost, learned score strictly last), flagged climbed: true.
45
+ * When the knob is off, when current is frontier, or when no higher tier has an untried channel,
46
+ * returns nextChannel's same-or-higher pick flagged climbed: false naming the reason.
47
+ */
48
+ export declare function climbChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): ClimbPick | null;
@@ -328,15 +328,8 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
328
328
  maybeSlaLint(lints, task, profile, slaMinutes, eligible[0]);
329
329
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
330
330
  }
331
- export function nextChannel(current, task, cfg, channels, tried, profile, exclude) {
331
+ function candidatePool(current, channels, tried, tierPredicate, exclude) {
332
332
  channels = withoutExcluded(channels, exclude);
333
- // already cheapest-sufficient: TIER_RANK asc is the PRIMARY key so escalation climbs one band at a time.
334
- // Do NOT "unify" this onto route()'s key order (marginal-cost first) — that reverses climb-one-band on mixed fleets (ROUTE-02, D2).
335
- // ROUTE-13: learnedScore is the STRICTLY-LAST key — within-band tiebreak only. Precomputed
336
- // outside the comparator (Pitfall 1), never arithmetic-combined with the band keys, no
337
- // profile-dependent filter. NO exploration bonus here (route():110 has one; a probe on the
338
- // failure path would spend a real retry). Absent profile ⇒ every score is 0 ⇒ third key
339
- // all-ties ⇒ the stable sort preserves the exact v1.7 candidate ORDER.
340
333
  const triedKeys = new Set(tried);
341
334
  const triedIdentities = new Set(tried.map((key) => {
342
335
  const channel = channels.find((c) => channelKey(c) === key);
@@ -348,11 +341,71 @@ export function nextChannel(current, task, cfg, channels, tried, profile, exclud
348
341
  && channels.filter((c) => c.adapter === current.adapter).every((c) => triedKeys.has(channelKey(c)));
349
342
  const currentChannel = channels.find((c) => c.adapter === current.adapter && c.model === current.model);
350
343
  const excludedProvider = currentAdapterExcluded ? routingModelProvider(current.model, currentChannel?.vendor) : undefined;
351
- const pool = channels.filter((c) => !triedIdentities.has(modelRouteIdentity(c.model, c.vendor))
344
+ return channels.filter((c) => !triedIdentities.has(modelRouteIdentity(c.model, c.vendor))
352
345
  && (!excludedProvider || routingModelProvider(c.model, c.vendor) !== excludedProvider)
353
- && TIER_RANK[c.tier] >= TIER_RANK[current.tier]);
346
+ && tierPredicate(c.tier));
347
+ }
348
+ function rankFailoverCandidates(pool, task, cfg, profile) {
354
349
  const scores = new Map(pool.map((c) => [channelKey(c), profile ? learnedScore(profile, task.shape, channelKey(c), c.channel, { availWeight: cfg.routing.learnedTuning?.availWeight }) : 0]));
355
350
  const scoreOf = (c) => scores.get(channelKey(c));
356
- const candidates = pool.sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier] || marginalCostRank(a) - marginalCostRank(b) || scoreOf(b) - scoreOf(a));
351
+ return pool.sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier] || marginalCostRank(a) - marginalCostRank(b) || scoreOf(b) - scoreOf(a));
352
+ }
353
+ export function nextChannel(current, task, cfg, channels, tried, profile, exclude) {
354
+ // already cheapest-sufficient: TIER_RANK asc is the PRIMARY key so escalation climbs one band at a time.
355
+ // Do NOT "unify" this onto route()'s key order (marginal-cost first) — that reverses climb-one-band on mixed fleets (ROUTE-02, D2).
356
+ // ROUTE-13: learnedScore is the STRICTLY-LAST key — within-band tiebreak only. Precomputed
357
+ // outside the comparator (Pitfall 1), never arithmetic-combined with the band keys, no
358
+ // profile-dependent filter. NO exploration bonus here (route():110 has one; a probe on the
359
+ // failure path would spend a real retry). Absent profile ⇒ every score is 0 ⇒ third key
360
+ // all-ties ⇒ the stable sort preserves the exact v1.7 candidate ORDER.
361
+ const pool = candidatePool(current, channels, tried, (tier) => TIER_RANK[tier] >= TIER_RANK[current.tier], exclude);
362
+ const candidates = rankFailoverCandidates(pool, task, cfg, profile);
357
363
  return candidates.length ? toAssignment(candidates[0]) : null;
358
364
  }
365
+ function makeClimbPick(assignment, climbed, reason) {
366
+ const pick = {
367
+ adapter: assignment.adapter,
368
+ model: assignment.model,
369
+ channel: assignment.channel,
370
+ tier: assignment.tier,
371
+ climbed,
372
+ ...(climbed ? {} : { reason }),
373
+ };
374
+ Object.defineProperty(pick, "assignment", {
375
+ get() {
376
+ return {
377
+ adapter: this.adapter,
378
+ model: this.model,
379
+ channel: this.channel,
380
+ tier: this.tier,
381
+ };
382
+ },
383
+ enumerable: false,
384
+ });
385
+ return pick;
386
+ }
387
+ /**
388
+ * v2.5.4 OBS-986 (ES-1): climb one tier on request when untried channels exist in higher tiers.
389
+ * With routing.escalateTier on, returns the cheapest untried live channel strictly above current.tier,
390
+ * ordered as nextChannel orders (tier, then marginal cost, learned score strictly last), flagged climbed: true.
391
+ * When the knob is off, when current is frontier, or when no higher tier has an untried channel,
392
+ * returns nextChannel's same-or-higher pick flagged climbed: false naming the reason.
393
+ */
394
+ export function climbChannel(current, task, cfg, channels, tried, profile, exclude) {
395
+ const escalateOn = cfg.routing.escalateTier !== "off";
396
+ if (!escalateOn) {
397
+ const pick = nextChannel(current, task, cfg, channels, tried, profile, exclude);
398
+ return pick ? makeClimbPick(pick, false, "routing.escalateTier knob is off") : null;
399
+ }
400
+ if (current.tier === "frontier") {
401
+ const pick = nextChannel(current, task, cfg, channels, tried, profile, exclude);
402
+ return pick ? makeClimbPick(pick, false, "no higher tier") : null;
403
+ }
404
+ const higherPool = candidatePool(current, channels, tried, (tier) => TIER_RANK[tier] > TIER_RANK[current.tier], exclude);
405
+ if (higherPool.length > 0) {
406
+ const candidates = rankFailoverCandidates(higherPool, task, cfg, profile);
407
+ return makeClimbPick(toAssignment(candidates[0]), true);
408
+ }
409
+ const pick = nextChannel(current, task, cfg, channels, tried, profile, exclude);
410
+ return pick ? makeClimbPick(pick, false, "no higher tier has an untried channel") : null;
411
+ }