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.
- package/README.md +12 -2
- package/dist/adapters/codex.d.ts +2 -1
- package/dist/adapters/codex.js +26 -5
- package/dist/adapters/model-lints.d.ts +1 -0
- package/dist/adapters/model-lints.js +48 -1
- package/dist/adapters/model-windows.d.ts +2 -1
- package/dist/adapters/model-windows.js +18 -7
- package/dist/adapters/types.d.ts +6 -0
- package/dist/cli/commands/approve.d.ts +6 -3
- package/dist/cli/commands/approve.js +43 -6
- package/dist/cli/commands/doctor.d.ts +8 -2
- package/dist/cli/commands/doctor.js +6 -1
- package/dist/cli/commands/fleet.js +264 -59
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/plan.js +13 -8
- package/dist/cli/commands/report.d.ts +40 -2
- package/dist/cli/commands/report.js +278 -11
- package/dist/cli/commands/resume.js +2 -4
- package/dist/cli/commands/run.d.ts +11 -0
- package/dist/cli/commands/run.js +33 -5
- package/dist/cli/commands/status.js +49 -4
- package/dist/cli/commands/verify.js +332 -121
- package/dist/compile/native.js +137 -0
- package/dist/config/config.d.ts +40 -9
- package/dist/config/config.js +133 -15
- package/dist/config/fleet-overlay.d.ts +13 -3
- package/dist/config/fleet-overlay.js +12 -8
- package/dist/drivers/herdr.d.ts +12 -0
- package/dist/drivers/herdr.js +51 -0
- package/dist/drivers/orca.d.ts +9 -1
- package/dist/drivers/orca.js +29 -7
- package/dist/drivers/types.d.ts +2 -0
- package/dist/drivers/types.js +2 -2
- package/dist/gates/acceptance.d.ts +7 -0
- package/dist/gates/acceptance.js +27 -5
- package/dist/gates/baseline.d.ts +20 -1
- package/dist/gates/baseline.js +100 -20
- package/dist/gates/cache.d.ts +8 -0
- package/dist/gates/cache.js +12 -2
- package/dist/gates/llm.d.ts +6 -0
- package/dist/gates/llm.js +27 -8
- package/dist/gates/review.d.ts +6 -1
- package/dist/gates/review.js +122 -32
- package/dist/gates/run-gates.d.ts +54 -3
- package/dist/gates/run-gates.js +331 -45
- package/dist/gates/test-manifest.d.ts +42 -0
- package/dist/gates/test-manifest.js +69 -10
- package/dist/route/router.d.ts +23 -1
- package/dist/route/router.js +54 -16
- package/dist/run/consult.d.ts +3 -1
- package/dist/run/consult.js +4 -2
- package/dist/run/daemon.d.ts +2 -1
- package/dist/run/daemon.js +349 -79
- package/dist/run/interactive-seed.d.ts +4 -0
- package/dist/run/interactive-seed.js +35 -9
- package/dist/run/journal.d.ts +123 -1
- package/dist/run/journal.js +480 -17
- package/dist/run/lease.d.ts +13 -0
- package/dist/run/lease.js +45 -0
- package/dist/run/protocol.d.ts +15 -0
- package/dist/run/protocol.js +11 -1
- package/dist/run/receipt-resolver.d.ts +22 -0
- package/dist/run/receipt-resolver.js +40 -1
- package/dist/run/repair-selection.d.ts +11 -1
- package/dist/run/repair-selection.js +17 -9
- package/dist/run/wall-budget.d.ts +48 -0
- package/dist/run/wall-budget.js +280 -0
- package/dist/tui/cockpit/live-store.d.ts +6 -0
- package/dist/tui/cockpit/live-store.js +36 -11
- package/dist/tui/cockpit/run-cockpit.js +2 -2
- package/dist/tui/cockpit/run-view.d.ts +2 -1
- package/dist/tui/cockpit/run-view.js +13 -9
- package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
- package/dist/tui/cockpit/setup-cockpit.js +4 -0
- package/package.json +2 -1
- package/schema/config.schema.json +8 -1
- package/skills/tickmarkr-auto/SKILL.md +2 -2
- package/skills/tickmarkr-loop/SKILL.md +17 -5
- 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
|
-
|
|
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
|
}
|
package/dist/route/router.d.ts
CHANGED
|
@@ -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 & {
|
package/dist/route/router.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { channelKey, channelsFromConfig } from "../adapters/types.js";
|
|
2
2
|
import { TIER_RANK } from "../config/config.js";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
368
|
-
|
|
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
|
-
|
|
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) => !
|
|
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);
|
package/dist/run/consult.d.ts
CHANGED
|
@@ -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>;
|
package/dist/run/consult.js
CHANGED
|
@@ -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(
|
|
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,
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -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
|