tickmarkr 1.70.0 → 1.72.0

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.
@@ -1,4 +1,6 @@
1
1
  import { channelKey, channelsFromConfig } from "../adapters/types.js";
2
+ import { validateGraph } from "../graph/schema.js";
3
+ import { route, RoutingError } from "./router.js";
2
4
  const adapterIds = (adapters) => typeof adapters[0] === "string" ? adapters : adapters.map((a) => a.id);
3
5
  export function excludedChannels(cfg, adapters, health) {
4
6
  const { allow, deny } = cfg.routing;
@@ -47,3 +49,62 @@ export function disallowedBy(c, routing) {
47
49
  }
48
50
  return null;
49
51
  }
52
+ // Delegates to route()'s preflightPrefer — doctor/resume preflight reuses the router grammar,
53
+ // not a second colon/tier/model parser of the same config.
54
+ const PREFLIGHT_SHAPE = "chore";
55
+ const preflightTask = validateGraph({
56
+ version: 1, spec: { source: "prd", paths: ["p"], hash: "h" },
57
+ tasks: [{ id: "T0", title: "preflight", goal: "preflight", shape: PREFLIGHT_SHAPE, complexity: 1, acceptance: ["preflight"] }],
58
+ }).tasks[0];
59
+ const disallowedFromPreferError = (msg) => {
60
+ const m = msg.match(/prefer entry .+ is disallowed by routing\.(deny|allow) \(([^)]+)\)/);
61
+ return m ? { by: m[1], entry: m[2] } : null;
62
+ };
63
+ export function preferEntryDenied(p, cfg) {
64
+ if (!cfg.routing.allow && !cfg.routing.deny)
65
+ return null;
66
+ const probe = structuredClone(cfg);
67
+ probe.routing.map = { ...probe.routing.map, [PREFLIGHT_SHAPE]: { prefer: [p] } };
68
+ try {
69
+ route(preflightTask, probe, []);
70
+ return null;
71
+ }
72
+ catch (e) {
73
+ if (e instanceof RoutingError)
74
+ return disallowedFromPreferError(e.message);
75
+ throw e;
76
+ }
77
+ }
78
+ export function denyPreferCollisions(cfg) {
79
+ if (!cfg.routing.allow && !cfg.routing.deny)
80
+ return [];
81
+ const out = [];
82
+ for (const [shape, entry] of Object.entries(cfg.routing.map)) {
83
+ if (entry.pin) {
84
+ const d = disallowedBy({ adapter: entry.pin.via, model: entry.pin.model }, cfg.routing);
85
+ if (d) {
86
+ out.push({
87
+ kind: "pin",
88
+ shape,
89
+ detail: `${entry.pin.via}:${entry.pin.model}`,
90
+ disallowed: d,
91
+ });
92
+ }
93
+ }
94
+ const prefer = entry.prefer ?? [];
95
+ if (prefer.length && prefer.every((p) => preferEntryDenied(p, cfg) !== null)) {
96
+ out.push({
97
+ kind: "prefer",
98
+ shape,
99
+ detail: prefer.join(" > "),
100
+ disallowed: preferEntryDenied(prefer[0], cfg),
101
+ });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ export function denyPreferCollisionLine({ kind, shape, detail, disallowed }) {
107
+ const surface = kind === "pin" ? `routing.map.${shape}.pin` : `routing.map.${shape}.prefer`;
108
+ const verb = kind === "pin" ? "is disallowed" : "fully disallowed";
109
+ return `deny∩prefer: ${surface} ${detail} ${verb} by routing.${disallowed.by} (${disallowed.entry}) — remove the ${disallowed.by} entry or adjust ${kind === "pin" ? "pin" : "prefer"}`;
110
+ }
@@ -41,4 +41,8 @@ export interface RunSummary {
41
41
  lastMergedTask?: string;
42
42
  }
43
43
  export declare function formatSummary(s: RunSummary): string;
44
+ export declare const EARLY_LAUNCH_LIVENESS_MS = 60000;
45
+ /** Test seam — lowers the empty-pane liveness window without sleeping 60s per case. */
46
+ export declare function setEarlyLaunchLivenessMsForTests(ms: number): void;
47
+ export declare function resetEarlyLaunchLivenessMsForTests(): void;
44
48
  export declare function runDaemon(repoRoot: string, opts?: RunOptions): Promise<RunSummary>;
@@ -74,6 +74,18 @@ const BLOCKED_POLL_MS = 30_000; // between trailer-wait slices, check whether th
74
74
  const PROVIDER_DEATH_REQUEUE_CAP = 2; // v1.46 T1: requeue same assignment twice, then fall through to the normal ladder
75
75
  const PROVIDER_DEATH_BACKOFF_MS = 500; // short backoff before provider-death requeue
76
76
  const NO_TRAILER_DEMOTION_STREAK = 2; // OBS-57: consecutive no-trailer windows demote a channel for the rest of the run
77
+ // OBS-117 (v1.71 T6): a worker pane that never prints a byte by T+60s after dispatch is a dead
78
+ // channel — don't burn the full stall window waiting for a silent launch failure. Checked on the
79
+ // existing stall-wait poll cadence only (no new timer). Spinner/ANSI repaints count as output.
80
+ export const EARLY_LAUNCH_LIVENESS_MS = 60_000;
81
+ let earlyLaunchLivenessMs = EARLY_LAUNCH_LIVENESS_MS;
82
+ /** Test seam — lowers the empty-pane liveness window without sleeping 60s per case. */
83
+ export function setEarlyLaunchLivenessMsForTests(ms) {
84
+ earlyLaunchLivenessMs = ms;
85
+ }
86
+ export function resetEarlyLaunchLivenessMsForTests() {
87
+ earlyLaunchLivenessMs = EARLY_LAUNCH_LIVENESS_MS;
88
+ }
77
89
  async function commitsAheadOf(base, wt) {
78
90
  const head = await gitHead(wt);
79
91
  if (head === base)
@@ -217,6 +229,7 @@ export async function runDaemon(repoRoot, opts = {}) {
217
229
  // GATE-08 lesson at the humanGate guard: condition on the data, not the code path). Dead-code
218
230
  // equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
219
231
  const resume = opts.resume ? journal.replayResumeState() : new Map();
232
+ const replayedExclusions = opts.resume ? journal.replayExcludedChannels() : new Set();
220
233
  if (opts.resume) {
221
234
  // v1.53 T5: a superseded run is dead — resuming it beside its successor is the exact
222
235
  // two-concurrent-runs hazard supersession exists to prevent. Fail closed, naming the successor.
@@ -252,7 +265,10 @@ export async function runDaemon(repoRoot, opts = {}) {
252
265
  continue;
253
266
  graph = setStatus(graph, id, st);
254
267
  }
255
- journal.append("run-resume", undefined, { pid: process.pid }); // v1.13 (VIS-11): record the live daemon pid for status liveness
268
+ journal.append("run-resume", undefined, {
269
+ pid: process.pid, // v1.13 (VIS-11): record the live daemon pid for status liveness
270
+ ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
271
+ });
256
272
  }
257
273
  else {
258
274
  baseRef = await gitHead(repoRoot);
@@ -301,7 +317,8 @@ export async function runDaemon(repoRoot, opts = {}) {
301
317
  const keptSlots = [];
302
318
  const runTag = runId.replace(/^run-/, ""); // full date-time — cross-run unique even across days
303
319
  // OBS-57: per-run in-run demotion — channels that burn consecutive no-trailer windows route around for later attempts.
304
- const demotedChannels = new Set();
320
+ // v1.71 OBS-119: on resume, re-seed from the journal fold (replayedExclusions) before any dispatch.
321
+ const demotedChannels = new Set(replayedExclusions);
305
322
  const noTrailerStreak = new Map();
306
323
  // OBS-17 T2: reconcile at every safe point — run start/resume (just journaled above), each task
307
324
  // terminal event, and run-end. The desired set is the pure journal fold (reconcile.ts); the driver
@@ -394,7 +411,9 @@ export async function runDaemon(repoRoot, opts = {}) {
394
411
  // fresh-budget release, prefer nextChannel over the surviving tried-list so burned channels are
395
412
  // not re-tried first (consult bans / prior failovers survive the release).
396
413
  const rs = resume.get(t.id);
397
- if (rs?.lastAssignment && rs.attempts > 0 && channels.some((c) => channelKey(c) === channelKey(rs.lastAssignment))) {
414
+ if (rs?.lastAssignment && rs.attempts > 0
415
+ && channels.some((c) => channelKey(c) === channelKey(rs.lastAssignment))
416
+ && !demotedChannels.has(channelKey(rs.lastAssignment))) {
398
417
  assignment = rs.lastAssignment; // restore the consult-chosen assignment (bypasses route()'s static re-pick)
399
418
  }
400
419
  else if (rs && rs.tried.length) {
@@ -690,6 +709,7 @@ export async function runDaemon(repoRoot, opts = {}) {
690
709
  let output;
691
710
  let exitCode;
692
711
  let timedOut = false;
712
+ let earlyLaunchDead = false;
693
713
  let settleParsed;
694
714
  let seedResult;
695
715
  if (interactive) {
@@ -724,12 +744,18 @@ export async function runDaemon(repoRoot, opts = {}) {
724
744
  // OBS-82: the stall clock compares NORMALIZED snapshots so a spinner glyph/elapsed-time
725
745
  // repaint is silence, not activity. ONLY this inactivity compare sees normalized text —
726
746
  // trailer detection, harvest, paging, and quota checks all read the raw pane.
747
+ let everHadOutput = output.length > 0;
727
748
  let lastStallSnapshot = normalizeStallSnapshot(output);
728
749
  let lastOutputAt = Date.now();
729
750
  while (Date.now() - lastOutputAt < stallWindowMs) {
730
751
  const sliceStart = Date.now();
731
752
  const remaining = stallWindowMs - (sliceStart - lastOutputAt);
732
- const slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
753
+ let slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
754
+ if (!everHadOutput) {
755
+ const earlyLeft = earlyLaunchLivenessMs - (sliceStart - attemptStart);
756
+ if (earlyLeft > 0)
757
+ slice = Math.min(slice, earlyLeft);
758
+ }
733
759
  if (await driver.waitOutput(slot, `(${trailerPattern(nonce)})|TICKMARKR_EXIT_${nonce}:\\d`, slice, { regex: true })) {
734
760
  // verify before accepting: a worker that merely DISPLAYS a marker (e.g. editing tickmarkr's
735
761
  // own source, where "TICKMARKR_EXIT:" is a string literal) must not end the wait. Only a
@@ -743,11 +769,20 @@ export async function runDaemon(repoRoot, opts = {}) {
743
769
  break;
744
770
  }
745
771
  }
746
- const currentStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 1000));
772
+ const paneText = await driver.read(slot, 1000);
773
+ if (paneText.length > 0)
774
+ everHadOutput = true;
775
+ const currentStallSnapshot = normalizeStallSnapshot(paneText);
747
776
  if (currentStallSnapshot !== lastStallSnapshot) {
748
777
  lastStallSnapshot = currentStallSnapshot;
749
778
  lastOutputAt = Date.now();
750
779
  }
780
+ // OBS-117 (v1.71 T6): zero raw output by the early-launch deadline is a dead channel now.
781
+ if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
782
+ earlyLaunchDead = true;
783
+ output = paneText;
784
+ break;
785
+ }
751
786
  // v1.23 T2: piggyback on this poll slice — same cadence as blocked/idle checks, no new timer.
752
787
  await sampleContext();
753
788
  // page on "idle" too: herdr's blocked-scrape is strict and proved flaky for TUI dialogs
@@ -828,21 +863,35 @@ export async function runDaemon(repoRoot, opts = {}) {
828
863
  // OBS-82: same normalized-snapshot compare as the interactive site — spinner-only repaints
829
864
  // exhaust the budget here too; harvest below still reads the raw pane.
830
865
  const stallWindowMs = taskTimeoutMinutes * 60_000;
831
- let lastStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 500));
866
+ const initialPane = await driver.read(slot, 500);
867
+ let everHadOutput = initialPane.length > 0;
868
+ let lastStallSnapshot = normalizeStallSnapshot(initialPane);
832
869
  let lastOutputAt = Date.now();
833
870
  finished = false;
834
871
  while (Date.now() - lastOutputAt < stallWindowMs) {
835
872
  const remaining = stallWindowMs - (Date.now() - lastOutputAt);
836
- const slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
873
+ let slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
874
+ if (!everHadOutput) {
875
+ const earlyLeft = earlyLaunchLivenessMs - (Date.now() - attemptStart);
876
+ if (earlyLeft > 0)
877
+ slice = Math.min(slice, earlyLeft);
878
+ }
837
879
  if (await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, slice, { regex: true })) {
838
880
  finished = true;
839
881
  break;
840
882
  }
841
- const currentStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 500));
883
+ const paneText = await driver.read(slot, 500);
884
+ if (paneText.length > 0)
885
+ everHadOutput = true;
886
+ const currentStallSnapshot = normalizeStallSnapshot(paneText);
842
887
  if (currentStallSnapshot !== lastStallSnapshot) {
843
888
  lastStallSnapshot = currentStallSnapshot;
844
889
  lastOutputAt = Date.now();
845
890
  }
891
+ if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
892
+ earlyLaunchDead = true;
893
+ break;
894
+ }
846
895
  }
847
896
  output = await driver.read(slot, 500);
848
897
  exitCode = Number(exitRe.exec(output)?.[1] ?? 1);
@@ -940,10 +989,13 @@ export async function runDaemon(repoRoot, opts = {}) {
940
989
  // AFTER the quota check so quota behavior stays byte-identical (a quota hit returns/continues
941
990
  // before reaching here); provider-outage lands here only once the v1.46 same-channel requeue
942
991
  // cap above is spent, so a transient blip still recovers in place.
943
- const dead = classifyDeadChannel(result);
992
+ // OBS-117 (v1.71 T6): a silent launch failure has no CLI signature to parse — the same
993
+ // setup-required typed dead-channel path a late-harvest "command not found" would take.
994
+ const dead = classifyDeadChannel(result) ?? (earlyLaunchDead ? "setup-required" : undefined);
944
995
  if (dead) {
945
996
  const from = channelKey(assignment);
946
997
  demotedChannels.add(from); // excluded for later attempts AND later tasks in this run
998
+ journal.append("channel-exclusion", t.id, { channel: from, reason: dead, kind: "dead-channel" });
947
999
  const next = failover("dead-channel");
948
1000
  journal.append("dead-channel-failover", t.id, { reason: dead, from, to: next ? channelKey(next) : null });
949
1001
  if (next) {
@@ -1130,9 +1182,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1130
1182
  for (const t of ready) {
1131
1183
  const p = execTask(t)
1132
1184
  .catch(async (err) => {
1185
+ const taskEvents = journal.read().filter((e) => e.taskId === t.id);
1186
+ const dispatch = [...taskEvents].reverse().find((e) => e.event === "task-dispatch");
1187
+ const verifiedFailure = taskEvents.some((e) => e.event === "gate-result" && e.data.pass === false);
1188
+ const kind = verifiedFailure ? "gate-fail" : dispatch ? "dispatch" : "infra";
1189
+ const attempts = dispatch && Number.isInteger(dispatch.data.attempt) ? dispatch.data.attempt : 0;
1133
1190
  graph = setStatus(graph, t.id, "failed");
1134
1191
  saveGraph(repoRoot, graph);
1135
- journal.append("task-failed", t.id, { error: String(err) });
1192
+ journal.append("task-failed", t.id, { error: String(err), kind, attempts });
1136
1193
  journal.telemetry({ taskId: t.id, shape: t.shape, adapter: "-", model: "-", channel: "-", attempts: 0, outcome: "failed", durationMs: 0 });
1137
1194
  await reconcile({ spareLiveLlm: true }); // task-failed is a terminal event
1138
1195
  })
@@ -4,6 +4,7 @@ export interface InteractiveSeedResult {
4
4
  output: string;
5
5
  seedFailed: boolean;
6
6
  seedError?: string;
7
+ sessionId?: string;
7
8
  }
8
9
  export declare function runInteractiveSeed(opts: {
9
10
  driver: Pick<ExecutorDriver, "run" | "waitOutput" | "read">;
@@ -6,9 +6,17 @@ export async function runInteractiveSeed(opts) {
6
6
  const seed = opts.adapter.interactiveSeed;
7
7
  await opts.driver.run(opts.slot, seed.launch(opts.assignment.model));
8
8
  const ready = await opts.driver.waitOutput(opts.slot, seed.readinessMatch, opts.taskTimeoutMinutes * 60_000);
9
+ const banner = await opts.driver.read(opts.slot, 1000);
9
10
  if (!ready) {
10
- const output = await opts.driver.read(opts.slot, 1000);
11
- return { output, seedFailed: true, seedError: `readiness pattern not seen: ${seed.readinessMatch}` };
11
+ return { output: banner, seedFailed: true, seedError: `readiness pattern not seen: ${seed.readinessMatch}` };
12
+ }
13
+ let sessionId;
14
+ if (seed.confirmBanner) {
15
+ const confirm = seed.confirmBanner(banner, opts.assignment.model);
16
+ if (!confirm.ok) {
17
+ return { output: banner, seedFailed: true, seedError: confirm.error };
18
+ }
19
+ sessionId = confirm.sessionId;
12
20
  }
13
21
  const seedText = seed.seedLine(opts.promptFile);
14
22
  await opts.driver.run(opts.slot, seedText);
@@ -18,8 +26,8 @@ export async function runInteractiveSeed(opts) {
18
26
  output = await opts.driver.read(opts.slot, 1000);
19
27
  const bottom = output.trimEnd().split("\n").pop() ?? "";
20
28
  if (!bottom.includes(seedText)) {
21
- return { output, seedFailed: false };
29
+ return { output, seedFailed: false, ...(sessionId ? { sessionId } : {}) };
22
30
  }
23
31
  }
24
- return { output, seedFailed: true, seedError: "seed line never left the input box" };
32
+ return { output, seedFailed: true, seedError: "seed line never left the input box", ...(sessionId ? { sessionId } : {}) };
25
33
  }
@@ -15,13 +15,18 @@ export interface ResumeState {
15
15
  tried: string[];
16
16
  lastAssignment?: Assignment;
17
17
  }
18
+ export declare const CHANNEL_EXCLUSION_KINDS: readonly ["dead-channel"];
19
+ export type ChannelExclusionKind = (typeof CHANNEL_EXCLUSION_KINDS)[number];
18
20
  export declare const ATTEMPT_CAP_RELEASE: "attempt-cap";
19
- export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved"];
21
+ export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch"];
20
22
  export type ParkKind = (typeof PARK_KINDS)[number];
21
23
  export declare const RETRY_MODES: readonly ["resume", "fresh"];
22
24
  export type RetryMode = (typeof RETRY_MODES)[number];
23
25
  export declare const WORKER_RESULT_CAUSES: readonly ["provider-death", "stall-timeout", "malformed-trailer", "clean-exit-no-trailer"];
24
26
  export type WorkerResultCause = (typeof WORKER_RESULT_CAUSES)[number];
27
+ export declare function isQualityFailureParkKind(kind: ParkKind): boolean;
28
+ export declare function recordedTaskFailureKind(events: JournalEvent[], taskId: string): ParkKind | undefined;
29
+ export declare function runHasEnded(events: JournalEvent[]): boolean;
25
30
  /** OBS-53: classify worker-result failures so retries and routing see the true signal, not one lumped bucket. */
26
31
  export declare function classifyWorkerResultCause(opts: {
27
32
  output: string;
@@ -58,6 +63,8 @@ export declare const TelemetryRowSchema: z.ZodObject<{
58
63
  stall: "stall";
59
64
  "merge-conflict": "merge-conflict";
60
65
  "tip-moved": "tip-moved";
66
+ infra: "infra";
67
+ dispatch: "dispatch";
61
68
  }>>;
62
69
  tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
63
70
  input: z.ZodNumber;
@@ -139,6 +146,7 @@ export declare class Journal {
139
146
  read(): JournalEvent[];
140
147
  replayStatuses(): Map<string, TaskStatus>;
141
148
  replayResumeState(): Map<string, ResumeState>;
149
+ replayExcludedChannels(): Set<string>;
142
150
  telemetry(row: TelemetryRow): void;
143
151
  readTelemetry(): TelemetryRow[];
144
152
  }
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { channelKey, TokenUsageSchema } from "../adapters/types.js";
5
5
  import { stateDirName, tickmarkrDir } from "../graph/graph.js";
6
6
  import { TIERS } from "../graph/schema.js";
7
- import { buildProfile } from "../route/profile.js";
7
+ import { buildProfile, classify } from "../route/profile.js";
8
8
  import { redactSecrets } from "./redact.js";
9
9
  export function formatJournalNarration({ event, taskId, data }) {
10
10
  const assignment = data.assignment;
@@ -26,6 +26,9 @@ export function formatJournalNarration({ event, taskId, data }) {
26
26
  : String(direct);
27
27
  return [event, taskId, detail?.replace(/\s+/g, " ").slice(0, 120)].filter(Boolean).join(" — ");
28
28
  }
29
+ // v1.71 OBS-119: run-wide channel exclusions derived from journal events — companion to
30
+ // replayResumeState(), consumed by the daemon on resume to re-seed demotedChannels.
31
+ export const CHANNEL_EXCLUSION_KINDS = ["dead-channel"];
29
32
  // v1.24 OBS-18: explicit data on task-approved when the operator releases an attempt-cap park.
30
33
  // Approve stamps this; replayResumeState zeros the attempt counter (fresh budget) while keeping
31
34
  // tried (consult bans / burned channels). Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
@@ -40,9 +43,43 @@ const DispatchAssignmentSchema = z.object({
40
43
  tier: z.enum(TIERS),
41
44
  });
42
45
  export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
43
- "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved"];
46
+ "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch"];
44
47
  export const RETRY_MODES = ["resume", "fresh"];
45
48
  export const WORKER_RESULT_CAUSES = ["provider-death", "stall-timeout", "malformed-trailer", "clean-exit-no-trailer"];
49
+ // Status consumes the routing profile's existing quality split directly: verified park kinds classify
50
+ // to 0, while availability/recovery noise classifies to null. Keep the synthetic row here at the
51
+ // run↔route seam so presentation code never grows a second list of "bad" park kinds.
52
+ export function isQualityFailureParkKind(kind) {
53
+ return classify({
54
+ shape: "", adapter: "-", model: "-", channel: "-", attempts: 0,
55
+ outcome: "human", durationMs: 0, parkKind: kind,
56
+ }) === 0;
57
+ }
58
+ // The newest terminal event owns the cause. Unknown/malformed kinds fail toward undefined so a
59
+ // task-failed row keeps the legacy red treatment instead of being mistaken for recoverable noise.
60
+ export function recordedTaskFailureKind(events, taskId) {
61
+ for (let i = events.length - 1; i >= 0; i--) {
62
+ const e = events[i];
63
+ if (e.taskId !== taskId || (e.event !== "task-human" && e.event !== "task-failed"))
64
+ continue;
65
+ return typeof e.data.kind === "string" && PARK_KINDS.includes(e.data.kind)
66
+ ? e.data.kind
67
+ : undefined;
68
+ }
69
+ return undefined;
70
+ }
71
+ // Runs can end and later resume in the same journal. The newest lifecycle marker decides whether
72
+ // an unresolved task is still recoverable by this live daemon or belongs to an ended run.
73
+ export function runHasEnded(events) {
74
+ for (let i = events.length - 1; i >= 0; i--) {
75
+ const event = events[i].event;
76
+ if (event === "run-end" || event === "superseded")
77
+ return true;
78
+ if (event === "run-start" || event === "run-resume")
79
+ return false;
80
+ }
81
+ return false;
82
+ }
46
83
  // OBS-53: provider-outage signatures in dead worker output ("Unable to reach the model provider" and kin).
47
84
  const PROVIDER_OUTAGE_RE = /Unable to reach the model provider|cannot reach the model provider|model provider.*(?:unavailable|unreachable)/i;
48
85
  /** OBS-53: classify worker-result failures so retries and routing see the true signal, not one lumped bucket. */
@@ -424,6 +461,20 @@ export class Journal {
424
461
  }
425
462
  return m;
426
463
  }
464
+ // v1.71 OBS-119: run-wide exclusion fold — same replay discipline as replayResumeState().
465
+ // channel-exclusion is the typed event; dead-channel-failover.from is the pre-v1.71 compat seam.
466
+ replayExcludedChannels() {
467
+ const excluded = new Set();
468
+ for (const e of this.read()) {
469
+ if (e.event === "channel-exclusion" && typeof e.data.channel === "string") {
470
+ excluded.add(e.data.channel);
471
+ }
472
+ else if (e.event === "dead-channel-failover" && typeof e.data.from === "string") {
473
+ excluded.add(e.data.from);
474
+ }
475
+ }
476
+ return excluded;
477
+ }
427
478
  telemetry(row) {
428
479
  // T3 secret redaction: same persistence seam as append — credential-free rows are byte-identical.
429
480
  appendFileSync(join(this.dir, "telemetry.jsonl"), redactSecrets(JSON.stringify(row)) + "\n");
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+ export type FleetListRow = {
3
+ id: string;
4
+ content: ReactNode;
5
+ };
6
+ export declare function ToggleMark({ active }: {
7
+ active: boolean;
8
+ }): import("react").JSX.Element;
9
+ export declare function FleetListScreen({ title, legend, rows, cursor, details, }: {
10
+ title: string;
11
+ legend: string;
12
+ rows: FleetListRow[];
13
+ cursor: number;
14
+ details?: string[];
15
+ }): import("react").JSX.Element;
16
+ export declare function FleetReviewScreen({ title, legend, diff, }: {
17
+ title: string;
18
+ legend: string;
19
+ diff: string;
20
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { GLYPHS } from "../../brand.js";
4
+ export function ToggleMark({ active }) {
5
+ return active
6
+ ? _jsx(Text, { color: "ansi256(41)", children: GLYPHS.toggleActive })
7
+ : _jsx(Text, { dimColor: true, children: GLYPHS.toggleInactive });
8
+ }
9
+ export function FleetListScreen({ title, legend, rows, cursor, details = [], }) {
10
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: title }), _jsx(Text, { dimColor: true, children: legend }), rows.map((row, index) => (_jsxs(Text, { bold: index === cursor, children: [index === cursor ? `${GLYPHS.pointer} ` : " ", row.content] }, row.id))), details.map((detail) => _jsx(Text, { children: detail }, detail))] }));
11
+ }
12
+ export function FleetReviewScreen({ title, legend, diff, }) {
13
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: title }), _jsx(Text, { dimColor: true, children: legend }), _jsx(Text, { children: diff })] }));
14
+ }
@@ -0,0 +1,113 @@
1
+ import type { AuthHealth, WorkerAdapter } from "../../adapters/types.js";
2
+ import type { MapEntry, RoutingMode, Tier } from "../../config/config.js";
3
+ import { type Shape } from "../../graph/schema.js";
4
+ export type FleetSteeringKey = "review" | "consult";
5
+ export type FleetEditorState = {
6
+ denyAdapters: string[];
7
+ denyModels: string[];
8
+ classifications: FleetClassification[];
9
+ selectedMode: RoutingMode;
10
+ map: Record<string, MapEntry>;
11
+ steering: Record<FleetSteeringKey, string[] | undefined>;
12
+ };
13
+ export type FleetOverlayReview = {
14
+ kind: "empty";
15
+ } | {
16
+ kind: "diff";
17
+ before: string;
18
+ after: string;
19
+ diff: string;
20
+ path: string;
21
+ };
22
+ export type FleetEditorResult = {
23
+ kind: "write";
24
+ review: Extract<FleetOverlayReview, {
25
+ kind: "diff";
26
+ }>;
27
+ } | {
28
+ kind: "discard";
29
+ } | {
30
+ kind: "no-changes";
31
+ } | {
32
+ kind: "quit";
33
+ } | {
34
+ kind: "refresh";
35
+ };
36
+ type AgentCli = {
37
+ id: string;
38
+ version: string;
39
+ authed: boolean;
40
+ };
41
+ export type FleetModelGroup = {
42
+ adapter: string;
43
+ rows: Array<{
44
+ model: string;
45
+ tier?: Tier;
46
+ detectedAt?: string;
47
+ }>;
48
+ };
49
+ export type FleetClassification = {
50
+ adapter: string;
51
+ model: string;
52
+ tier: Tier;
53
+ note: string;
54
+ };
55
+ export type FleetModeOption = {
56
+ id: RoutingMode;
57
+ gloss: string;
58
+ };
59
+ export type FleetShapeRow = {
60
+ id: Shape;
61
+ label: string;
62
+ };
63
+ export type FleetCandidateOption = {
64
+ id: string;
65
+ label: string;
66
+ pin: {
67
+ via: string;
68
+ model: string;
69
+ };
70
+ };
71
+ export declare function formatDoctorAge(ageMs: number | null): string;
72
+ export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, }: {
73
+ ageMs: number | null;
74
+ agents: AgentCli[];
75
+ initialDenyAdapters: string[];
76
+ initialDenyModels: string[];
77
+ modelGroups: FleetModelGroup[];
78
+ initialMode: RoutingMode;
79
+ modeOptions: FleetModeOption[];
80
+ initialMap: Record<string, MapEntry>;
81
+ modePreview: (mode: RoutingMode, map: Record<string, MapEntry>) => string[];
82
+ shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>) => FleetShapeRow[];
83
+ candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>) => FleetCandidateOption[];
84
+ preferOptionsForShape: (shape: Shape, current: string[]) => string[];
85
+ initialSteering: Record<FleetSteeringKey, string[] | undefined>;
86
+ steeringOptionsFor: (which: FleetSteeringKey, current: string[]) => string[];
87
+ reviewOverlay: (state: FleetEditorState) => FleetOverlayReview;
88
+ reloadGuard: (bytes: string) => string | null;
89
+ }): import("react").JSX.Element;
90
+ export declare function runFleetInkEditor({ ageMs, adapters, health, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, initialInput, input, output, debug, }: {
91
+ ageMs: number | null;
92
+ adapters: WorkerAdapter[];
93
+ health: Record<string, AuthHealth>;
94
+ initialDenyAdapters: string[];
95
+ initialDenyModels: string[];
96
+ modelGroups: FleetModelGroup[];
97
+ initialMode: RoutingMode;
98
+ modeOptions: FleetModeOption[];
99
+ initialMap: Record<string, MapEntry>;
100
+ modePreview: (mode: RoutingMode, map: Record<string, MapEntry>) => string[];
101
+ shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>) => FleetShapeRow[];
102
+ candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>) => FleetCandidateOption[];
103
+ preferOptionsForShape: (shape: Shape, current: string[]) => string[];
104
+ initialSteering: Record<FleetSteeringKey, string[] | undefined>;
105
+ steeringOptionsFor: (which: FleetSteeringKey, current: string[]) => string[];
106
+ reviewOverlay: (state: FleetEditorState) => FleetOverlayReview;
107
+ reloadGuard: (bytes: string) => string | null;
108
+ initialInput?: string[];
109
+ input: NodeJS.ReadStream;
110
+ output: NodeJS.WriteStream;
111
+ debug?: boolean;
112
+ }): Promise<FleetEditorResult>;
113
+ export {};