tickmarkr 1.53.0 → 1.54.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.
- package/dist/adapters/model-lints.d.ts +1 -0
- package/dist/adapters/model-lints.js +27 -0
- package/dist/cli/commands/fleet.js +66 -12
- package/dist/cli/commands/plan.js +4 -2
- package/dist/config/config.d.ts +1 -0
- package/dist/config/config.js +17 -2
- package/dist/run/consult.d.ts +3 -0
- package/dist/run/consult.js +78 -48
- package/dist/run/daemon.d.ts +1 -0
- package/dist/run/daemon.js +86 -9
- package/dist/run/git.js +31 -2
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ export type RoutedAssignment = {
|
|
|
17
17
|
/** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
|
|
18
18
|
export declare function contextWindowLints(tasks: ReadonlyArray<Task>, assignments: ReadonlyArray<RoutedAssignment>, cfg: TickmarkrConfig, repoRoot: string): string[];
|
|
19
19
|
export declare function seedPreferLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
|
|
20
|
+
export declare function preferEntryLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, mapShapes?: ReadonlySet<string>): string[];
|
|
20
21
|
export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
|
|
21
22
|
tty?: boolean;
|
|
22
23
|
stateDir?: string;
|
|
@@ -95,6 +95,33 @@ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new
|
|
|
95
95
|
}
|
|
96
96
|
return lints;
|
|
97
97
|
}
|
|
98
|
+
// v1.54 T3: dead-steering sweep — operator prefer entries (routing.map overlay shapes, review.prefer,
|
|
99
|
+
// consult.prefer) that can never match an installed channel are named at plan time; v1.53 T2 pins the
|
|
100
|
+
// no-match case as a silent no-op, which makes a typo invisible. Advisory only: reads config + doctor
|
|
101
|
+
// health (no live probes), never touches routing. Seed map prefers stay seedPreferLints' turf (auto-prefer
|
|
102
|
+
// routes around dead seeds) — mapShapes limits this sweep to operator-authored entries so a bare default
|
|
103
|
+
// fleet isn't double-linted. Entry grammar mirrors preferIndex: adapter | adapter:model (first colon).
|
|
104
|
+
export function preferEntryLints(cfg, health, mapShapes = new Set()) {
|
|
105
|
+
const lints = [];
|
|
106
|
+
const sweep = (surface, entries) => {
|
|
107
|
+
for (const entry of entries ?? []) {
|
|
108
|
+
const i = entry.indexOf(":");
|
|
109
|
+
const adapter = i < 0 ? entry : entry.slice(0, i);
|
|
110
|
+
const model = i < 0 ? undefined : entry.slice(i + 1);
|
|
111
|
+
if (!health[adapter]?.installed) {
|
|
112
|
+
lints.push(`${surface} '${entry}' names uninstalled adapter '${adapter}' — dead steering (entry can never match)`);
|
|
113
|
+
}
|
|
114
|
+
else if (model !== undefined && !(model in (cfg.tiers[adapter]?.models ?? {}))) {
|
|
115
|
+
lints.push(`${surface} '${entry}' names model '${model}' absent from ${adapter}'s configured channels — dead steering (entry can never match)`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
for (const shape of mapShapes)
|
|
120
|
+
sweep(`routing.map.${shape}.prefer`, cfg.routing.map[shape]?.prefer);
|
|
121
|
+
sweep("review.prefer", cfg.review.prefer);
|
|
122
|
+
sweep("consult.prefer", cfg.consult.prefer);
|
|
123
|
+
return lints;
|
|
124
|
+
}
|
|
98
125
|
// Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
|
|
99
126
|
// No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
|
|
100
127
|
// modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
|
|
@@ -194,9 +194,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
194
194
|
const health = cached;
|
|
195
195
|
const term = openTerm(input, output);
|
|
196
196
|
try {
|
|
197
|
-
// step 1/
|
|
197
|
+
// step 1/6 — probe data
|
|
198
198
|
const ageLine = formatAge(doctorAgeMs(cwd));
|
|
199
|
-
term.frame(listFrame("step 1/
|
|
199
|
+
term.frame(listFrame("step 1/6 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
|
|
200
200
|
for (;;) {
|
|
201
201
|
const k = await term.key();
|
|
202
202
|
if (isAbort(k))
|
|
@@ -207,7 +207,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
207
207
|
if (isNext(k))
|
|
208
208
|
break;
|
|
209
209
|
}
|
|
210
|
-
// step 2/
|
|
210
|
+
// step 2/6 — agent CLIs (space toggles adapter deny)
|
|
211
211
|
const installed = adapters.filter((a) => health[a.id]?.installed);
|
|
212
212
|
const adapterRows = () => installed.map((a) => {
|
|
213
213
|
const h = health[a.id];
|
|
@@ -216,7 +216,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
216
216
|
});
|
|
217
217
|
const listLegend = "↑↓/jk move · space toggle · enter next · esc/q quit";
|
|
218
218
|
let aCursor = 0;
|
|
219
|
-
term.frame(listFrame("step 2/
|
|
219
|
+
term.frame(listFrame("step 2/6 · agent CLIs", listLegend, adapterRows(), aCursor));
|
|
220
220
|
for (;;) {
|
|
221
221
|
const k = await term.key();
|
|
222
222
|
if (isAbort(k))
|
|
@@ -240,9 +240,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
240
240
|
changed = true;
|
|
241
241
|
}
|
|
242
242
|
if (changed)
|
|
243
|
-
term.frame(listFrame("step 2/
|
|
243
|
+
term.frame(listFrame("step 2/6 · agent CLIs", listLegend, adapterRows(), aCursor));
|
|
244
244
|
}
|
|
245
|
-
// step 3/
|
|
245
|
+
// step 3/6 — models per enabled adapter (space toggles deny, t assigns tier from the row)
|
|
246
246
|
const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
|
|
247
247
|
for (const a of enabledAdapters) {
|
|
248
248
|
const unclassifiedAll = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
|
|
@@ -260,7 +260,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
260
260
|
const denied = isDeniedModel(a.id, r.model, editable);
|
|
261
261
|
return `${denied ? toggleInactive() : toggleActive()} ${r.model} ${tier?.tier ?? "???"} ${denied ? "denied" : "allowed"}`;
|
|
262
262
|
});
|
|
263
|
-
const title = `step 3/
|
|
263
|
+
const title = `step 3/6 · models · ${a.id}`;
|
|
264
264
|
const modelsLegend = "↑↓/jk move · space toggle · t tier · enter next · esc/q quit";
|
|
265
265
|
let mCursor = 0;
|
|
266
266
|
term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
|
|
@@ -305,7 +305,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
305
305
|
term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
|
-
// step 4/
|
|
308
|
+
// step 4/6 — routing mode (selection is in-memory; the write happens only through the diff confirm).
|
|
309
309
|
// Candidate floor tables come from the ONE preset compiler via resolveRunMode — no mode math here.
|
|
310
310
|
const modeCfgs = Object.fromEntries(ROUTING_MODES.map((m) => [m, m === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: m, globalDir })]));
|
|
311
311
|
const modeRows = () => ROUTING_MODES.map((m) => `${m === rm.mode.mode ? toggleActive() : " "} ${m.padEnd(11)} ${MODE_GLOSS[m]}`);
|
|
@@ -321,7 +321,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
321
321
|
...(changed.length ? changed.map((s) => ` ${s}: ${cur[s]} → ${next[s]}`) : [" (no floor changes)"]),
|
|
322
322
|
];
|
|
323
323
|
};
|
|
324
|
-
const modeTitle = "step 4/
|
|
324
|
+
const modeTitle = "step 4/6 · routing mode";
|
|
325
325
|
const modeLegend = "↑↓/jk move · enter select · esc/q quit";
|
|
326
326
|
let modeCursor = ROUTING_MODES.indexOf(rm.mode.mode);
|
|
327
327
|
let selectedMode = rm.mode.mode;
|
|
@@ -341,7 +341,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
341
341
|
term.frame(modeFrame());
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
|
-
// step 5/
|
|
344
|
+
// step 5/6 — shape routing (typed entry for pin/tier/prefer from the highlighted row);
|
|
345
345
|
// previews route under the SELECTED mode's resolved floors, never a floor edit of its own
|
|
346
346
|
const channels = discoverChannels(cfg, adapters, health);
|
|
347
347
|
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
@@ -359,7 +359,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
359
359
|
const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
|
|
360
360
|
return `${shape} → ${now}${auto}`;
|
|
361
361
|
});
|
|
362
|
-
const shapesTitle = "step 5/
|
|
362
|
+
const shapesTitle = "step 5/6 · shape routing";
|
|
363
363
|
// v1.52 T5: no map-tier editing action — routing.floors is the only band authority now.
|
|
364
364
|
const shapesLegend = "↑↓/jk move · a auto · p pin · f prefer · enter next · esc/q quit";
|
|
365
365
|
let sCursor = 0;
|
|
@@ -400,6 +400,43 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
400
400
|
if (changed)
|
|
401
401
|
term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
|
|
402
402
|
}
|
|
403
|
+
// step 6/6 — steering (v1.54 T4): review.prefer + consult.prefer via the same typed-entry
|
|
404
|
+
// pattern as the shape prefer action; edits are in-memory only and land on the merged overlay
|
|
405
|
+
// below, so they ride the ONE diff-confirm + reload-guard write path (never a second writer)
|
|
406
|
+
const STEER_KEYS = ["review", "consult"];
|
|
407
|
+
const initialSteering = {
|
|
408
|
+
review: cfg.review.prefer?.slice(),
|
|
409
|
+
consult: cfg.consult.prefer?.slice(),
|
|
410
|
+
};
|
|
411
|
+
const steering = structuredClone(initialSteering);
|
|
412
|
+
const steerRows = () => STEER_KEYS.map((k) => `${k}.prefer → ${steering[k]?.join(", ") ?? "(none)"}`);
|
|
413
|
+
const steerTitle = "step 6/6 · steering";
|
|
414
|
+
const steerLegend = "↑↓/jk move · f prefer · enter next · esc/q quit";
|
|
415
|
+
let stCursor = 0;
|
|
416
|
+
term.frame(listFrame(steerTitle, steerLegend, steerRows(), stCursor));
|
|
417
|
+
for (;;) {
|
|
418
|
+
const k = await term.key();
|
|
419
|
+
if (isAbort(k))
|
|
420
|
+
return QUIT;
|
|
421
|
+
if (isNext(k))
|
|
422
|
+
break;
|
|
423
|
+
let changed = false;
|
|
424
|
+
const moved = moveCursor(k, stCursor, STEER_KEYS.length);
|
|
425
|
+
if (moved !== stCursor) {
|
|
426
|
+
stCursor = moved;
|
|
427
|
+
changed = true;
|
|
428
|
+
}
|
|
429
|
+
else if (k.name === "f") {
|
|
430
|
+
const which = STEER_KEYS[stCursor];
|
|
431
|
+
const grammar = which === "consult" ? "adapter:model seats" : "adapters or adapter:model";
|
|
432
|
+
const raw = await term.askTyped(`${which}.prefer (comma-separated ${grammar}, empty clears)> `);
|
|
433
|
+
const list = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
434
|
+
steering[which] = list.length ? list : undefined;
|
|
435
|
+
changed = true;
|
|
436
|
+
}
|
|
437
|
+
if (changed)
|
|
438
|
+
term.frame(listFrame(steerTitle, steerLegend, steerRows(), stCursor));
|
|
439
|
+
}
|
|
403
440
|
// review — unified diff + typed confirm (the ONLY write path; the mode screen funnels through here)
|
|
404
441
|
const before = currentRepoOverlayText(cwd);
|
|
405
442
|
const existing = readOverlayFile(repoOverlayPath(cwd));
|
|
@@ -409,8 +446,25 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
409
446
|
const merged = fleetEditableEquals(initial, editable)
|
|
410
447
|
? structuredClone(existing)
|
|
411
448
|
: fleetRepoOverlayFromDelta(initial, editable, existing);
|
|
449
|
+
// steering lands on the merged object and serializes via repoOverlayYaml's non-fleet
|
|
450
|
+
// passthrough; clearing removes the prefer key (and an emptied parent block) from the overlay
|
|
451
|
+
let steeringChanged = false;
|
|
452
|
+
for (const key of STEER_KEYS) {
|
|
453
|
+
if (JSON.stringify(steering[key]) === JSON.stringify(initialSteering[key]))
|
|
454
|
+
continue;
|
|
455
|
+
steeringChanged = true;
|
|
456
|
+
const block = { ...merged[key] };
|
|
457
|
+
if (steering[key])
|
|
458
|
+
block.prefer = steering[key];
|
|
459
|
+
else
|
|
460
|
+
delete block.prefer;
|
|
461
|
+
if (Object.keys(block).length)
|
|
462
|
+
merged[key] = block;
|
|
463
|
+
else
|
|
464
|
+
delete merged[key];
|
|
465
|
+
}
|
|
412
466
|
const after = withModeLine(repoOverlayYaml(merged, provenanceMap(editable)), writeMode);
|
|
413
|
-
if ((!modeChanged && fleetEditableEquals(initial, editable)) || before === after) {
|
|
467
|
+
if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, editable)) || before === after) {
|
|
414
468
|
return "fleet: no overlay changes (empty diff)";
|
|
415
469
|
}
|
|
416
470
|
const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
|
|
2
|
-
import { formatModelAuthLine, contextWindowLints, modelLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
2
|
+
import { formatModelAuthLine, contextWindowLints, modelLints, preferEntryLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
3
3
|
import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
|
|
6
|
-
import { DEFAULT_CONFIG, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
6
|
+
import { DEFAULT_CONFIG, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
7
7
|
import { loadGraph } from "../../graph/graph.js";
|
|
8
8
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
9
9
|
import { excludedChannels, exclusionLine } from "../../route/preference.js";
|
|
@@ -121,6 +121,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
121
121
|
lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
|
|
122
122
|
}
|
|
123
123
|
lints.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() })); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
|
|
124
|
+
// v1.54 T3: dead-steering sweep — advisory only, renders with the routing lints, never alters routing.
|
|
125
|
+
lints.push(...preferEntryLints(cfg, health, overlayPreferShapes(cwd)));
|
|
124
126
|
if (cfg.review.required && channels.length && !fleetCanCrossVendorReview(channels)) {
|
|
125
127
|
lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
|
|
126
128
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -174,6 +174,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
174
174
|
adapter: z.ZodString;
|
|
175
175
|
model: z.ZodString;
|
|
176
176
|
stallMinutes: z.ZodNumber;
|
|
177
|
+
prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
177
178
|
}, z.core.$strip>;
|
|
178
179
|
visibility: z.ZodObject<{
|
|
179
180
|
llm: z.ZodEnum<{
|
package/dist/config/config.js
CHANGED
|
@@ -155,7 +155,18 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
155
155
|
// v1.53 T2: prefer — ordered reviewer preference (entry grammar: adapter | adapter:model, same as
|
|
156
156
|
// routing.map.prefer). Reorders diversity-eligible channels only; never widens or narrows eligibility.
|
|
157
157
|
review: z.object({ complexityThreshold: z.number(), required: z.boolean(), prefer: z.array(z.string()).optional() }),
|
|
158
|
-
|
|
158
|
+
// v1.54 T1: prefer — ranked consult seat failover. Entries MUST be adapter:model (unlike
|
|
159
|
+
// review.prefer's adapter|adapter:model grammar): a consult seat has no channel to inherit a
|
|
160
|
+
// model from, so a bare adapter is meaningless and fails config load fail-closed. The pinned
|
|
161
|
+
// adapter/model pair below stays the final fallback seat; an absent list is byte-identical.
|
|
162
|
+
consult: z.object({
|
|
163
|
+
adapter: z.string(),
|
|
164
|
+
model: z.string(),
|
|
165
|
+
stallMinutes: z.number().positive(),
|
|
166
|
+
prefer: z
|
|
167
|
+
.array(z.string().regex(/^[^:]+:.+$/, "consult.prefer entries must be adapter:model — a bare adapter has no model to run the seat with"))
|
|
168
|
+
.optional(),
|
|
169
|
+
}),
|
|
159
170
|
visibility: z.object({
|
|
160
171
|
llm: z.enum(["pane", "headless"]),
|
|
161
172
|
keepPanes: z.enum(["run", "attempt", "forever"]),
|
|
@@ -479,7 +490,11 @@ export function configTemplate(overlay) {
|
|
|
479
490
|
# review: { complexityThreshold: 7, required: true, prefer: [codex:gpt-5.6-sol, kimi] }
|
|
480
491
|
# # prefer: ordered reviewer seat preference (adapter | adapter:model); ranks
|
|
481
492
|
# # diversity-eligible channels only — never admits a same-vendor/same-model reviewer
|
|
482
|
-
# consult: { adapter: claude-code, model: fable, stallMinutes: 15 }
|
|
493
|
+
# consult: { adapter: claude-code, model: fable, stallMinutes: 15, prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] }
|
|
494
|
+
# # prefer: ranked consult seat failover — entries are adapter:model ONLY (a
|
|
495
|
+
# # consult seat has no channel to inherit a model from). The seat walks the
|
|
496
|
+
# # list to the first live adapter; a failed seat or unparseable verdict falls
|
|
497
|
+
# # to the next entry; the pinned adapter/model above is always the final seat
|
|
483
498
|
# cost: # v1.20 REC-02 — OPTIONAL price table for the usage/cost report. Absent ⇒ every
|
|
484
499
|
# # channel reports "not measurable" (never a crash or a fake $0). Two economics,
|
|
485
500
|
# # never conflated: API = tokens × per-Mtok rate; sub = flat plan amortized over a
|
package/dist/run/consult.d.ts
CHANGED
package/dist/run/consult.js
CHANGED
|
@@ -92,6 +92,8 @@ let consultSeq = 0;
|
|
|
92
92
|
export async function consult(d, cfg, adapters, driver, cwd, runDir,
|
|
93
93
|
// T2 ownership contract: runId names the consult pane canonically (tickmarkr:consult:<task>:0:<runId>)
|
|
94
94
|
// via SlotOpts.owned; without it the legacy gatePaneName shape survives (non-daemon callers/tests).
|
|
95
|
+
// v1.54 T1: channels — the daemon's doctor-filtered live channel list; prefer-seat liveness is
|
|
96
|
+
// judged against it only (never rebuilt from config, which would select installed-but-unauthed seats).
|
|
95
97
|
opts = {}) {
|
|
96
98
|
const n = ++consultSeq;
|
|
97
99
|
const nonce = generateVerdictNonce();
|
|
@@ -99,54 +101,82 @@ opts = {}) {
|
|
|
99
101
|
mkdirSync(dir, { recursive: true });
|
|
100
102
|
const promptFile = join(dir, `${d.taskId}-${n}.md`);
|
|
101
103
|
writeFileSync(promptFile, buildDossierPrompt(d, nonce));
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
out
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
104
|
+
// One seat = the WHOLE invoke-and-parse unit, both visibility branches (OBS-69 class: a headless-only
|
|
105
|
+
// failover would leave the production pane path hard-failing on seat one). null = no parseable verdict.
|
|
106
|
+
const invokeSeat = async (seatAdapter, seatModel, seatIdx) => {
|
|
107
|
+
const adapter = getAdapter(seatAdapter, adapters);
|
|
108
|
+
let out;
|
|
109
|
+
if (cfg.visibility.llm === "headless") {
|
|
110
|
+
const r = await sh(adapter.headlessCommand(promptFile, seatModel), cwd, cfg.consult.stallMinutes * 60_000);
|
|
111
|
+
out = r.stdout + r.stderr;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// T8: role-first pane name for fleet visibility (consult · T2); consultSeq stays on the dossier artifact only
|
|
115
|
+
const slot = await driver.slot(cwd, gatePaneName("consult", d.taskId), {
|
|
116
|
+
label: `CONSULT ${d.taskId}`,
|
|
117
|
+
...(opts.runId ? { owned: { role: "consult", taskId: d.taskId, attempt: 0, runId: opts.runId } } : {}),
|
|
118
|
+
});
|
|
119
|
+
opts.onSlot?.(slot);
|
|
120
|
+
const scriptPath = join(dir, `${d.taskId}-${n}${seatIdx > 0 ? `-s${seatIdx}` : ""}.sh`);
|
|
121
|
+
// OBS-50: visible consult panes get the brand banner; headless path above stays banner-free (machine-parsed stdout)
|
|
122
|
+
writeFileSync(scriptPath, [
|
|
123
|
+
"export BASH_SILENCE_DEPRECATION_WARNING=1",
|
|
124
|
+
bannerShell(),
|
|
125
|
+
adapter.headlessCommand(promptFile, seatModel),
|
|
126
|
+
gateExitTrailer(nonce),
|
|
127
|
+
].join("\n"));
|
|
128
|
+
try {
|
|
129
|
+
await driver.run(slot, paneDispatchCommand(scriptPath));
|
|
130
|
+
// nonce-suffixed exit only: a dossier quoting tickmarkr's own exit literal must not false-complete.
|
|
131
|
+
await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, cfg.consult.stallMinutes * 60_000, { regex: true });
|
|
132
|
+
out = await driver.read(slot, 300);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
// a failed seat must not leak its pane before the next seat opens under the same name
|
|
136
|
+
if (!opts.keep)
|
|
137
|
+
await driver.close(slot);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
130
140
|
out = augmentFakeVerdictOutput(adapter, out, nonce);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
...(excludeAdapter ? { excludeAdapter } : {}),
|
|
141
|
+
const v = extractVerdictJson(out, nonce);
|
|
142
|
+
if (!v || !ACTIONS.includes(v.action))
|
|
143
|
+
return null;
|
|
144
|
+
// fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
|
|
145
|
+
// empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
|
|
146
|
+
// and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
|
|
147
|
+
// zero-match expansion as channel-level reroute.
|
|
148
|
+
const raw = v.excludeAdapter;
|
|
149
|
+
const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
150
|
+
const reason = typeof v.reason === "string" ? v.reason : undefined;
|
|
151
|
+
const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
|
|
152
|
+
const notes = String(v.notes ?? guidance ?? reason ?? "");
|
|
153
|
+
return {
|
|
154
|
+
action: v.action,
|
|
155
|
+
notes,
|
|
156
|
+
...(reason ? { reason } : {}),
|
|
157
|
+
...(guidance ? { guidance } : {}),
|
|
158
|
+
...(excludeAdapter ? { excludeAdapter } : {}),
|
|
159
|
+
};
|
|
151
160
|
};
|
|
161
|
+
// v1.54 T1: ranked seat failover. Walk consult.prefer (adapter:model entries) to the first entry
|
|
162
|
+
// whose adapter is in the live channel set; a failed seat or unparseable verdict falls to the next;
|
|
163
|
+
// the pinned consult.adapter/model is always the final seat. No channels provided (non-daemon
|
|
164
|
+
// callers) ⇒ empty live set ⇒ pin only, byte-identical to pre-v1.54 behavior. Failover changes
|
|
165
|
+
// only WHICH seat answers — per-seat parsing and the fail-safe human action below are untouched.
|
|
166
|
+
const live = new Set((opts.channels ?? []).map((c) => c.adapter));
|
|
167
|
+
const seats = (cfg.consult.prefer ?? [])
|
|
168
|
+
.map((entry) => ({ adapter: entry.slice(0, entry.indexOf(":")), model: entry.slice(entry.indexOf(":") + 1) }))
|
|
169
|
+
.filter((s) => live.has(s.adapter));
|
|
170
|
+
seats.push({ adapter: cfg.consult.adapter, model: cfg.consult.model });
|
|
171
|
+
for (const [i, seat] of seats.entries()) {
|
|
172
|
+
try {
|
|
173
|
+
const v = await invokeSeat(seat.adapter, seat.model, i);
|
|
174
|
+
if (v)
|
|
175
|
+
return v;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// failed seat (unknown adapter, dead driver/pane, shell error) — fall to the next entry
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { action: "human", notes: "consult verdict unparseable — failing safe to human" };
|
|
152
182
|
}
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface RunOptions {
|
|
|
13
13
|
globalDir?: string;
|
|
14
14
|
mode?: RoutingMode;
|
|
15
15
|
narrate?: (event: JournalEvent) => void;
|
|
16
|
+
exit?: (code: number) => void;
|
|
16
17
|
}
|
|
17
18
|
export type ModeSource = "run flag" | "spec" | "repo config" | "global config" | "default";
|
|
18
19
|
export interface ResolvedRunMode {
|
package/dist/run/daemon.js
CHANGED
|
@@ -106,6 +106,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
106
106
|
// subprocess, so the optional-chain open below is a no-op there). Cosmetic-only: any failure is
|
|
107
107
|
// swallowed (never affects the run); the operator closes a surviving watch pane.
|
|
108
108
|
const lock = acquireRunLock(repoRoot, runId);
|
|
109
|
+
// v1.54 T2: declared before the try so the finally can always deregister (a throw before
|
|
110
|
+
// registration leaves it undefined — the guard below covers that path).
|
|
111
|
+
let onTermination;
|
|
109
112
|
try {
|
|
110
113
|
let graph = loadGraph(repoRoot);
|
|
111
114
|
// v1.51 T2: the routing mode resolves BEFORE any routing input is built — run flag > spec front-matter
|
|
@@ -120,6 +123,67 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
120
123
|
// No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
|
|
121
124
|
// for the run, so this run's own telemetry never feeds back into its own routing.
|
|
122
125
|
const profile = loadRoutingProfile(repoRoot, cfg);
|
|
126
|
+
// v1.54 T2 (OBS-71): signal reaper — a killed daemon closes its own panes and releases the lock.
|
|
127
|
+
// Every slot this run opens stays in liveSlots until closed: the worker path opens through
|
|
128
|
+
// trackedDriver below, and gates/consults receive trackedDriver as THEIR driver, so their pane
|
|
129
|
+
// opens/closes keep the ledger exact. Termination then closes exactly what is still live — and a
|
|
130
|
+
// slot closed once (task-done, quota reroute, gate self-clean) can never be closed twice.
|
|
131
|
+
const liveSlots = new Set();
|
|
132
|
+
const closeSlot = async (s) => {
|
|
133
|
+
if (!liveSlots.delete(s))
|
|
134
|
+
return; // already closed — never twice
|
|
135
|
+
await driver.close(s);
|
|
136
|
+
};
|
|
137
|
+
const trackedDriver = {
|
|
138
|
+
id: driver.id,
|
|
139
|
+
interactive: driver.interactive,
|
|
140
|
+
slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
|
|
141
|
+
run: (s, cmd) => driver.run(s, cmd),
|
|
142
|
+
waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
|
|
143
|
+
waitAgentStatus: (s, st, ms) => driver.waitAgentStatus(s, st, ms),
|
|
144
|
+
status: (s) => driver.status(s),
|
|
145
|
+
read: (s, n) => driver.read(s, n),
|
|
146
|
+
...(driver.sendKey ? { sendKey: driver.sendKey.bind(driver) } : {}),
|
|
147
|
+
notify: (m, o) => driver.notify(m, o),
|
|
148
|
+
close: closeSlot,
|
|
149
|
+
worktree: (r, b, base) => driver.worktree(r, b, base),
|
|
150
|
+
};
|
|
151
|
+
// Termination (SIGINT/SIGTERM): close every live slot, reconcile owned panes against an EMPTY
|
|
152
|
+
// desired set (herdr panes not in memory; panesToClose spares foreign names, watch panes, and
|
|
153
|
+
// other runs' panes by construction), release the run lock, then exit. Journal-silent by design —
|
|
154
|
+
// no run-end/interrupted event, so stop-amend-resume keeps resuming. keepPanes:"forever" (the
|
|
155
|
+
// keep-everything debug override) preserves panes but still releases the lock and exits.
|
|
156
|
+
let termSignal;
|
|
157
|
+
let abortRun = () => { };
|
|
158
|
+
const aborted = new Promise((_, reject) => { abortRun = reject; });
|
|
159
|
+
aborted.catch(() => { });
|
|
160
|
+
let reaping = false;
|
|
161
|
+
const exit = opts.exit ?? ((code) => process.exit(code));
|
|
162
|
+
onTermination = (sig) => {
|
|
163
|
+
termSignal = sig;
|
|
164
|
+
void (async () => {
|
|
165
|
+
if (!reaping) {
|
|
166
|
+
reaping = true;
|
|
167
|
+
if (cfg.visibility.keepPanes !== "forever") {
|
|
168
|
+
for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
|
|
169
|
+
try {
|
|
170
|
+
await closeSlot(s);
|
|
171
|
+
}
|
|
172
|
+
catch { /* cosmetic — reconcile is the backstop */ }
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await driver.reconcile?.(new Set(), runId);
|
|
176
|
+
}
|
|
177
|
+
catch { /* cosmetic — visibility is never a gate */ }
|
|
178
|
+
}
|
|
179
|
+
releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
|
|
180
|
+
}
|
|
181
|
+
abortRun(new Error(`terminated by ${sig}`));
|
|
182
|
+
exit(sig === "SIGINT" ? 130 : 143);
|
|
183
|
+
})();
|
|
184
|
+
};
|
|
185
|
+
process.on("SIGINT", onTermination);
|
|
186
|
+
process.on("SIGTERM", onTermination);
|
|
123
187
|
const journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
|
|
124
188
|
const branchEvent = opts.resume
|
|
125
189
|
? [...journal.read()].reverse().find((e) => (e.event === "run-start" || e.event === "run-end" || e.event === "merge") && typeof e.data.branch === "string")
|
|
@@ -346,9 +410,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
346
410
|
journalTail: JSON.stringify(journal.read().slice(-20)),
|
|
347
411
|
transcript: transcript.slice(-8000),
|
|
348
412
|
diff: diffOrFeedback, gates,
|
|
349
|
-
}, cfg, adapters,
|
|
413
|
+
}, cfg, adapters, trackedDriver, repoRoot, journal.dir,
|
|
350
414
|
// D-07: consult panes self-clean when the verdict is read (keepLlm) — only "forever" keeps them.
|
|
351
|
-
|
|
415
|
+
// v1.54 T1: channels = this run's doctor-filtered live list — consult.prefer seat liveness
|
|
416
|
+
// is judged against it, never rebuilt from config (installed-but-unauthed seats would stall).
|
|
417
|
+
{ keep: keepLlm, onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined, runId, channels });
|
|
352
418
|
};
|
|
353
419
|
// returns true → continue attempting, false → task is terminal (parked)
|
|
354
420
|
// trigger (why the consult ran) is threaded in so the decompose/human park keeps its cause —
|
|
@@ -497,7 +563,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
497
563
|
const adapter = getAdapter(assignment.adapter, adapters);
|
|
498
564
|
// VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
|
|
499
565
|
// the legacy name stays the fallback for drivers without owned handling (subprocess spies).
|
|
500
|
-
const slot = await
|
|
566
|
+
const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, { group: "workers", owned: { role: "worker", taskId: t.id, attempt, runId } });
|
|
501
567
|
const sessionId = retryMode === "resume" ? priorSession.id : slot.name;
|
|
502
568
|
const icmd = retryMode === "resume"
|
|
503
569
|
? adapter.resumeCommand(sessionId, promptFile, assignment.model)
|
|
@@ -677,7 +743,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
677
743
|
if (keepOpen && (finished || driver.id !== "subprocess"))
|
|
678
744
|
keptSlots.push(slot);
|
|
679
745
|
else
|
|
680
|
-
await
|
|
746
|
+
await closeSlot(slot);
|
|
681
747
|
// SPEND-01: usage from the harness's own cwd-keyed structured store, read POST-HOC from disk —
|
|
682
748
|
// `wt` is this task's private worktree, so the path is unique; the read is sliced to records
|
|
683
749
|
// stamped at/after this attempt's dispatch instant. Never the harvested pane text, never the
|
|
@@ -731,7 +797,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
731
797
|
if (idx >= 0) {
|
|
732
798
|
keptSlots.splice(idx, 1);
|
|
733
799
|
try {
|
|
734
|
-
await
|
|
800
|
+
await closeSlot(slot);
|
|
735
801
|
}
|
|
736
802
|
catch { /* cosmetic — reconcile is the backstop */ }
|
|
737
803
|
}
|
|
@@ -810,7 +876,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
810
876
|
commands, baseline, channels, adapters, cfg,
|
|
811
877
|
via: cfg.visibility.llm === "pane"
|
|
812
878
|
? {
|
|
813
|
-
driver,
|
|
879
|
+
driver: trackedDriver,
|
|
814
880
|
// D-07: judge/review panes self-clean when their verdict is read (keepLlm) — only "forever" keeps them.
|
|
815
881
|
keep: keepLlm,
|
|
816
882
|
onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined,
|
|
@@ -862,7 +928,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
862
928
|
const idx = keptSlots.indexOf(slot);
|
|
863
929
|
if (idx >= 0) {
|
|
864
930
|
keptSlots.splice(idx, 1);
|
|
865
|
-
await
|
|
931
|
+
await closeSlot(slot);
|
|
866
932
|
}
|
|
867
933
|
}
|
|
868
934
|
await reconcile({ spareLiveLlm: true }); // task-done is a terminal event — sweep this task's leftovers
|
|
@@ -901,6 +967,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
901
967
|
};
|
|
902
968
|
const inflight = new Map();
|
|
903
969
|
while (true) {
|
|
970
|
+
// v1.54 T2: a signal that landed while nothing was racing `aborted` (empty inflight window)
|
|
971
|
+
// must still stop the run before it can dispatch more work or write run-end.
|
|
972
|
+
if (termSignal)
|
|
973
|
+
throw new Error(`terminated by ${termSignal}`);
|
|
904
974
|
const ready = readyTasks(graph)
|
|
905
975
|
.filter((t) => !inflight.has(t.id))
|
|
906
976
|
.slice(0, Math.max(0, concurrency - inflight.size));
|
|
@@ -918,14 +988,14 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
918
988
|
}
|
|
919
989
|
if (inflight.size === 0)
|
|
920
990
|
break;
|
|
921
|
-
await Promise.race(inflight.values());
|
|
991
|
+
await Promise.race([...inflight.values(), aborted]); // aborted rejects on termination — unwinds the run
|
|
922
992
|
}
|
|
923
993
|
// D-07: the sweep now closes only what's LEFT in keptSlots — done-closed worker slots were removed
|
|
924
994
|
// (no double-close) and self-cleaned LLM/consult panes were never added under keepLlm:false. This
|
|
925
995
|
// leaves failed/parked attempts' worker slots, which keep their failure context until run end.
|
|
926
996
|
if (cfg.visibility.keepPanes === "run") {
|
|
927
997
|
for (const s of keptSlots)
|
|
928
|
-
await
|
|
998
|
+
await closeSlot(s); // panes persist for the run's duration, then clean up
|
|
929
999
|
}
|
|
930
1000
|
saveGraph(repoRoot, graph);
|
|
931
1001
|
const byStatus = (s) => graph.tasks.filter((t) => t.status === s).map((t) => t.id);
|
|
@@ -986,6 +1056,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
986
1056
|
return summary;
|
|
987
1057
|
}
|
|
988
1058
|
finally {
|
|
1059
|
+
// v1.54 T2: deregister on EVERY exit (normal run end, throw, termination unwind) — the daemon
|
|
1060
|
+
// test suite runs runDaemon dozens of times in one process; a leaked handler would close a
|
|
1061
|
+
// later run's slots.
|
|
1062
|
+
if (onTermination) {
|
|
1063
|
+
process.removeListener("SIGINT", onTermination);
|
|
1064
|
+
process.removeListener("SIGTERM", onTermination);
|
|
1065
|
+
}
|
|
989
1066
|
releaseRunLock(repoRoot);
|
|
990
1067
|
}
|
|
991
1068
|
}
|
package/dist/run/git.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { shq } from "../adapters/types.js";
|
|
5
5
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
6
6
|
import { ROUTING_ENV_SEAMS } from "../route/router.js";
|
|
@@ -110,11 +110,40 @@ export async function createWorktree(repo, branch, baseRef) {
|
|
|
110
110
|
// (real directory, wrong/broken symlink) is restored to the provisioned link. Idempotent: an
|
|
111
111
|
// already-correct link is a no-op. Returns whether dest is the provisioned link: provisioning ignores
|
|
112
112
|
// the result; the pre-gate caller treats false as a named environmental park, never a masked test red.
|
|
113
|
+
// OBS-78: target repos typically ignore `node_modules/` — a directories-only pattern that does NOT
|
|
114
|
+
// match the provisioned SYMLINK, so a worker staging with `git add -A` commits the link and burns an
|
|
115
|
+
// attempt on the scope gate. Write `node_modules` (no slash: matches the link too) into the exclude
|
|
116
|
+
// file git actually consults for this worktree. Per-worktree `info/` is ignored in linked worktrees
|
|
117
|
+
// (gitrepository-layout redirects it), so resolve through the `.git` gitfile + `commondir` indirection
|
|
118
|
+
// to the common git dir. Local git metadata only — the target repository's .gitignore is never edited.
|
|
119
|
+
// Idempotent (appends only when no entry exists) and best-effort, like the link itself.
|
|
120
|
+
function excludeNodeModules(dir) {
|
|
121
|
+
try {
|
|
122
|
+
let gitDir = join(dir, ".git");
|
|
123
|
+
if (lstatSync(gitDir).isFile()) { // linked worktree: .git is a gitfile naming the real git dir
|
|
124
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(gitDir, "utf8"));
|
|
125
|
+
if (!m)
|
|
126
|
+
return;
|
|
127
|
+
gitDir = resolve(dir, m[1]);
|
|
128
|
+
}
|
|
129
|
+
const commondir = join(gitDir, "commondir");
|
|
130
|
+
if (existsSync(commondir))
|
|
131
|
+
gitDir = resolve(gitDir, readFileSync(commondir, "utf8").trim());
|
|
132
|
+
const exclude = join(gitDir, "info", "exclude");
|
|
133
|
+
const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
134
|
+
if (/^node_modules$/m.test(current))
|
|
135
|
+
return; // already excluded — repeated re-asserts add nothing
|
|
136
|
+
mkdirSync(join(gitDir, "info"), { recursive: true });
|
|
137
|
+
writeFileSync(exclude, current + (current && !current.endsWith("\n") ? "\n" : "") + "node_modules\n");
|
|
138
|
+
}
|
|
139
|
+
catch { /* not a git checkout (bare tmpdir in tests) — never fail provisioning over the exclude */ }
|
|
140
|
+
}
|
|
113
141
|
export function linkNodeModules(repo, dir, { force = false } = {}) {
|
|
114
142
|
const src = join(repo, "node_modules");
|
|
115
143
|
const dest = join(dir, "node_modules");
|
|
116
144
|
if (!existsSync(src))
|
|
117
145
|
return true; // nothing provisioned to link — correct state is no link (OBS-27 best-effort)
|
|
146
|
+
excludeNodeModules(dir); // OBS-78: the link must never be stageable in this worktree
|
|
118
147
|
try {
|
|
119
148
|
if (lstatSync(dest).isSymbolicLink() && readlinkSync(dest) === src)
|
|
120
149
|
return true; // already the provisioned link
|