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.
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/plan.js +144 -3
- package/dist/cli/commands/resume.js +2 -0
- package/dist/cli/commands/run.js +10 -0
- package/dist/compile/collateral.d.ts +2 -0
- package/dist/compile/collateral.js +50 -9
- package/dist/compile/ownership.d.ts +7 -0
- package/dist/compile/ownership.js +59 -22
- package/dist/config/config.d.ts +9 -0
- package/dist/config/config.js +13 -2
- package/dist/drivers/herdr.d.ts +5 -0
- package/dist/drivers/herdr.js +14 -3
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/review.d.ts +35 -2
- package/dist/gates/review.js +64 -16
- package/dist/gates/run-gates.d.ts +2 -0
- package/dist/gates/run-gates.js +17 -8
- package/dist/route/router.d.ts +13 -0
- package/dist/route/router.js +64 -11
- package/dist/run/daemon.js +241 -29
- package/dist/run/git.d.ts +25 -0
- package/dist/run/git.js +68 -1
- package/dist/tui/cockpit/live-runtime.js +12 -10
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +21 -0
package/dist/gates/review.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/gates/review.js
CHANGED
|
@@ -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
|
|
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
|
-
&&
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
const reviewerFloor = task
|
|
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, ...
|
|
333
|
-
: { gate: "review", pass: true, details: `WARNING: ${reason} — review waived by config`, meta: { noEligibleReviewer: true, ...
|
|
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;
|
package/dist/gates/run-gates.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
643
|
-
// Preserve the original no-answer cause when no replacement exists, but name the
|
|
644
|
-
// floor that correctly refused a lower-tier fallback.
|
|
645
|
-
|
|
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;
|
package/dist/route/router.d.ts
CHANGED
|
@@ -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;
|
package/dist/route/router.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
344
|
+
return channels.filter((c) => !triedIdentities.has(modelRouteIdentity(c.model, c.vendor))
|
|
352
345
|
&& (!excludedProvider || routingModelProvider(c.model, c.vendor) !== excludedProvider)
|
|
353
|
-
&&
|
|
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
|
-
|
|
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
|
+
}
|