tickmarkr 1.55.0 → 1.57.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/README.md +13 -5
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +41 -5
- package/dist/cli/commands/fleet-picker.d.ts +8 -0
- package/dist/cli/commands/fleet-picker.js +25 -0
- package/dist/cli/commands/fleet.js +76 -12
- package/dist/route/candidates.d.ts +11 -0
- package/dist/route/candidates.js +35 -0
- package/dist/run/daemon.js +14 -8
- package/dist/run/stall.d.ts +4 -0
- package/dist/run/stall.js +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,11 +143,19 @@ away from a failing adapter will never retry it in subsequent `tickmarkr resume`
|
|
|
143
143
|
|
|
144
144
|
## Choosing your fleet: `tickmarkr fleet`
|
|
145
145
|
|
|
146
|
-
`tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. It walks
|
|
147
|
-
|
|
148
|
-
unified diff of your config overlay that is written **only
|
|
149
|
-
through every
|
|
150
|
-
requires a typed benchmark-provenance note, stored as a
|
|
146
|
+
`tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. It walks six steps —
|
|
147
|
+
probe data, agent CLIs, model tiers, routing mode, shape routing with a candidate picker, and
|
|
148
|
+
steering preferences — and ends in a unified diff of your config overlay that is written **only
|
|
149
|
+
on explicit confirm**. Pressing Enter through every step writes nothing. Assigning a tier to a
|
|
150
|
+
model that has no classification yet requires a typed benchmark-provenance note, stored as a
|
|
151
|
+
config comment beside the assignment.
|
|
152
|
+
|
|
153
|
+
The shape routing step (step 5/6) features an **arrow-driven candidate picker** that ranks
|
|
154
|
+
available channels by the production router's own logic, showing economics (tier + channel
|
|
155
|
+
cost signals for API and sub channels) and the router's rationale for each rank. This replaces
|
|
156
|
+
typed pin entry with verifiable suggestions. Step 4/6 shows an estimated spend context line
|
|
157
|
+
across all shapes under each routing mode's resolved floors, also derived from the same preview
|
|
158
|
+
routing machinery.
|
|
151
159
|
|
|
152
160
|
```bash
|
|
153
161
|
tickmarkr fleet # interactive editor (requires a TTY)
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -5,4 +5,6 @@ export declare function readCodexModelsCache(path?: string): {
|
|
|
5
5
|
};
|
|
6
6
|
export declare function seedCodexTrust(repoRoot: string, configPath?: string): TrustVerdict;
|
|
7
7
|
export declare function hasCodexTrustedProject(text: string, root: string): boolean;
|
|
8
|
+
export declare function codexConfigMcpServerNames(configPath?: string): string[];
|
|
9
|
+
export declare function codexMcpSuppressionFlags(configPath?: string): string;
|
|
8
10
|
export declare const codex: WorkerAdapter;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -123,6 +123,44 @@ export function hasCodexTrustedProject(text, root) {
|
|
|
123
123
|
const re = new RegExp(String.raw `\[projects\.(?:"${esc}"|'${esc}')\][^\[]*?trust_level\s*=\s*"trusted"`, "s");
|
|
124
124
|
return re.test(text);
|
|
125
125
|
}
|
|
126
|
+
// v1.57 T1 / OBS-82: first key segment after `mcp_servers.` — bare, "basic", or 'literal' TOML
|
|
127
|
+
// keys; sub-tables ([mcp_servers.x.env]) dedupe to their server name. Fails OPEN to [] on a
|
|
128
|
+
// missing/unreadable config (fresh install has none — base flags must still work).
|
|
129
|
+
export function codexConfigMcpServerNames(configPath) {
|
|
130
|
+
const p = configPath ?? join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = readFileSync(p, "utf8");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
const names = new Set();
|
|
139
|
+
for (const m of text.matchAll(/^[ \t]*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gm)) {
|
|
140
|
+
names.add((m[1] ?? m[2] ?? m[3]));
|
|
141
|
+
}
|
|
142
|
+
return [...names];
|
|
143
|
+
}
|
|
144
|
+
// OBS-24 → OBS-82: -c 'mcp_servers={}' was codex's analog of claude's --strict-mcp-config, but
|
|
145
|
+
// codex ≥0.144 MERGES the empty inline table with config instead of replacing it (live probe
|
|
146
|
+
// 2026-07-18, codex-cli 0.144.5: `mcp list` identical with and without the override) — so a down
|
|
147
|
+
// operator-global MCP server wedges startup indefinitely again (OBS-82: 45m spinner). No global
|
|
148
|
+
// MCP kill switch exists (`codex features list` has nothing MCP-shaped), and plugin-bundled
|
|
149
|
+
// servers (~/.codex/plugins/cache) never appear under [mcp_servers.*], so suppression is
|
|
150
|
+
// two-pronged: --disable plugins kills plugin loading (incl. the OBS-82 sites-design-picker),
|
|
151
|
+
// per-name enabled=false overrides kill every server named in $CODEX_HOME/config.toml. The empty
|
|
152
|
+
// table stays for older codex (replace semantics there; harmless no-op under merge). The LIVE
|
|
153
|
+
// test in real-adapters.test.ts runs this exact builder against the real `codex mcp list`
|
|
154
|
+
// surface and asserts zero enabled servers — executable proof, not a comment claim. Scanned
|
|
155
|
+
// names reach the shell line ONLY through shq — config values flow into shells.
|
|
156
|
+
export function codexMcpSuppressionFlags(configPath) {
|
|
157
|
+
const flags = ["--disable plugins", `-c 'mcp_servers={}'`];
|
|
158
|
+
for (const name of codexConfigMcpServerNames(configPath)) {
|
|
159
|
+
const key = /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
|
|
160
|
+
flags.push(`-c ${shq(`mcp_servers.${key}.enabled=false`)}`);
|
|
161
|
+
}
|
|
162
|
+
return flags.join(" ");
|
|
163
|
+
}
|
|
126
164
|
export const codex = {
|
|
127
165
|
id: "codex",
|
|
128
166
|
vendor: "openai",
|
|
@@ -132,14 +170,12 @@ export const codex = {
|
|
|
132
170
|
probe: async () => probeVersion("codex"),
|
|
133
171
|
channels: (cfg) => channelsFromConfig("codex", cfg),
|
|
134
172
|
// --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
// 2026-07-15: probe wedged >120s with MCP, 30s clean), wedging probes and worktree workers alike.
|
|
138
|
-
headlessCommand: (promptFile, model) => `codex exec --sandbox workspace-write -c 'mcp_servers={}' ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
|
|
173
|
+
// MCP suppression built per dispatch (config can change between runs) — see codexMcpSuppressionFlags.
|
|
174
|
+
headlessCommand: (promptFile, model) => `codex exec --sandbox workspace-write ${codexMcpSuppressionFlags()} ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
|
|
139
175
|
// TUI uses expanded -a never -s workspace-write (exec-only flags do not apply)
|
|
140
176
|
// (--help 2026-07-09: valid approval policies are untrusted|on-request|never; the previously
|
|
141
177
|
// used `on-failure` is invalid and made codex exit 2 pre-inference)
|
|
142
|
-
interactiveCommand: (promptFile, model) => `codex -a never -s workspace-write
|
|
178
|
+
interactiveCommand: (promptFile, model) => `codex -a never -s workspace-write ${codexMcpSuppressionFlags()} ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
|
|
143
179
|
invoke(task, _cwd, a, ctx) {
|
|
144
180
|
return { command: this.headlessCommand(ctx.promptFile, a.model) };
|
|
145
181
|
},
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Assignment, BillingChannel } from "../../adapters/types.js";
|
|
2
|
+
import type { TickmarkrConfig } from "../../config/config.js";
|
|
3
|
+
import type { Task } from "../../graph/schema.js";
|
|
4
|
+
import { type RankedCandidate } from "../../route/candidates.js";
|
|
5
|
+
import type { RoutingProfile } from "../../route/profile.js";
|
|
6
|
+
export declare function costSignal(a: Assignment, pricing: Record<string, number>): string;
|
|
7
|
+
export declare function candidateRow(c: RankedCandidate, pricing: Record<string, number>): string;
|
|
8
|
+
export declare function shapeCandidates(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile): RankedCandidate[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { rankCandidates } from "../../route/candidates.js";
|
|
2
|
+
// v1.56 T2: ranking glue + row presentation for the per-shape candidate picker. Everything here
|
|
3
|
+
// is pure — the picker loop in fleet.ts mutates only in-memory editable state, and disk stays
|
|
4
|
+
// reachable solely through fleet's diff-confirm + reload-guard write path.
|
|
5
|
+
// Channel economics + tier, never invented dollars: a flat-rate sub quota rendered as $0 would be
|
|
6
|
+
// the one dishonest thing this screen could say (v1.56 ruling — cost visible where the choice is made).
|
|
7
|
+
export function costSignal(a, pricing) {
|
|
8
|
+
if (a.channel === "sub")
|
|
9
|
+
return "sub flat-rate quota";
|
|
10
|
+
const perTask = pricing[a.tier];
|
|
11
|
+
return perTask == null ? "api metered" : `api ~$${perTask.toFixed(2)}/task`;
|
|
12
|
+
}
|
|
13
|
+
export function candidateRow(c, pricing) {
|
|
14
|
+
const a = c.assignment;
|
|
15
|
+
const override = c.belowFloor ? " · below floor — operator override" : "";
|
|
16
|
+
return `${a.adapter}:${a.model} ${a.tier} ${costSignal(a, pricing)} — ${c.why}${override}`;
|
|
17
|
+
}
|
|
18
|
+
// Ranking ignores the shape's own map pin so a pinned shape still offers the full candidate
|
|
19
|
+
// order — route() returns a map pin first and fail-louds on its exclusion, which would collapse
|
|
20
|
+
// the picker to a single row. No hidden mutation: fresh cfg objects only.
|
|
21
|
+
export function shapeCandidates(task, cfg, channels, profile) {
|
|
22
|
+
const { pin: _pin, ...entry } = cfg.routing.map[task.shape] ?? {};
|
|
23
|
+
const map = { ...cfg.routing.map, [task.shape]: entry };
|
|
24
|
+
return rankCandidates(task, { ...cfg, routing: { ...cfg.routing, map } }, channels, profile);
|
|
25
|
+
}
|
|
@@ -8,6 +8,7 @@ import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
|
|
|
8
8
|
import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
|
|
9
9
|
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
|
|
10
10
|
import { SHAPES, TIERS } from "../../graph/schema.js";
|
|
11
|
+
import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
|
|
11
12
|
import { route } from "../../route/router.js";
|
|
12
13
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
13
14
|
import { loadRoutingProfile } from "../../run/journal.js";
|
|
@@ -308,6 +309,46 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
308
309
|
// step 4/6 — routing mode (selection is in-memory; the write happens only through the diff confirm).
|
|
309
310
|
// Candidate floor tables come from the ONE preset compiler via resolveRunMode — no mode math here.
|
|
310
311
|
const modeCfgs = Object.fromEntries(ROUTING_MODES.map((m) => [m, m === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: m, globalDir })]));
|
|
312
|
+
// v1.56 T3: both screens preview-route through ONE lens — the same live channel set, learned
|
|
313
|
+
// profile, and per-mode resolved floors — so the mode spend context and the shape rows can
|
|
314
|
+
// never disagree about what the router would do.
|
|
315
|
+
const channels = discoverChannels(cfg, adapters, health);
|
|
316
|
+
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
317
|
+
const previewCfg = (m) => ({ ...cfg, routing: { ...cfg.routing, map: editable.map, floors: modeCfgs[m].cfg.routing.floors } });
|
|
318
|
+
// Estimated spend context under the highlighted mode: tier mix across all nine preview shapes,
|
|
319
|
+
// then channel economics — dollars only for api-routed shapes (plan.ts's rough per-task
|
|
320
|
+
// pricing table); a sub channel is flat-rate quota and never renders as a dollar amount.
|
|
321
|
+
const modeSpend = (cand) => {
|
|
322
|
+
const tierCount = {};
|
|
323
|
+
let subs = 0;
|
|
324
|
+
let apiN = 0;
|
|
325
|
+
let apiUsd = 0;
|
|
326
|
+
for (const shape of SHAPES) {
|
|
327
|
+
try {
|
|
328
|
+
const a = route(previewTask(shape), previewCfg(cand), channels, profile).assignment;
|
|
329
|
+
tierCount[a.tier] = (tierCount[a.tier] ?? 0) + 1;
|
|
330
|
+
if (a.channel === "sub")
|
|
331
|
+
subs += 1;
|
|
332
|
+
else {
|
|
333
|
+
apiN += 1;
|
|
334
|
+
apiUsd += cfg.pricing[a.tier] ?? 0;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// unroutable under this mode's floors — the shape screen names the error per row
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const mix = [...TIERS].reverse().flatMap((t) => (tierCount[t] ? [`${tierCount[t]} ${t}`] : [])).join(" · ");
|
|
342
|
+
const parts = [];
|
|
343
|
+
if (subs)
|
|
344
|
+
parts.push(`${subs === SHAPES.length ? "all" : subs} sub (flat-rate quota)`);
|
|
345
|
+
if (apiN)
|
|
346
|
+
parts.push(`${apiN} api · est. cost (API shapes only, rough): ~$${apiUsd.toFixed(2)}`);
|
|
347
|
+
const unroutable = SHAPES.length - subs - apiN;
|
|
348
|
+
if (unroutable)
|
|
349
|
+
parts.push(`${unroutable} unroutable`);
|
|
350
|
+
return ` mix: ${mix} — ${parts.join(" · ")}`;
|
|
351
|
+
};
|
|
311
352
|
const modeRows = () => ROUTING_MODES.map((m) => `${m === rm.mode.mode ? toggleActive() : " "} ${m.padEnd(11)} ${MODE_GLOSS[m]}`);
|
|
312
353
|
// preview: the highlighted mode's resolved floor table diffed against the current mode
|
|
313
354
|
const floorPreview = (cand) => {
|
|
@@ -325,7 +366,11 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
325
366
|
const modeLegend = "↑↓/jk move · enter select · esc/q quit";
|
|
326
367
|
let modeCursor = ROUTING_MODES.indexOf(rm.mode.mode);
|
|
327
368
|
let selectedMode = rm.mode.mode;
|
|
328
|
-
const modeFrame = () => [
|
|
369
|
+
const modeFrame = () => [
|
|
370
|
+
...listFrame(modeTitle, modeLegend, modeRows(), modeCursor),
|
|
371
|
+
modeSpend(ROUTING_MODES[modeCursor]),
|
|
372
|
+
...floorPreview(ROUTING_MODES[modeCursor]),
|
|
373
|
+
];
|
|
329
374
|
term.frame(modeFrame());
|
|
330
375
|
for (;;) {
|
|
331
376
|
const k = await term.key();
|
|
@@ -341,17 +386,15 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
341
386
|
term.frame(modeFrame());
|
|
342
387
|
}
|
|
343
388
|
}
|
|
344
|
-
// step 5/6 — shape routing (
|
|
389
|
+
// step 5/6 — shape routing (candidate picker for pin, typed entry for prefer);
|
|
345
390
|
// previews route under the SELECTED mode's resolved floors, never a floor edit of its own
|
|
346
|
-
const channels = discoverChannels(cfg, adapters, health);
|
|
347
|
-
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
348
391
|
const autoPrefer = readAutoPrefer(cwd);
|
|
349
392
|
const overlayShapes = overlayPreferShapes(cwd, { globalDir });
|
|
350
393
|
const shapeRows = () => SHAPES.map((shape) => {
|
|
351
394
|
let now;
|
|
352
395
|
try {
|
|
353
|
-
const r = route(previewTask(shape),
|
|
354
|
-
now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
|
|
396
|
+
const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile);
|
|
397
|
+
now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier}) ${costSignal(r.assignment, cfg.pricing)}`;
|
|
355
398
|
}
|
|
356
399
|
catch (e) {
|
|
357
400
|
now = e.message;
|
|
@@ -385,12 +428,33 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
385
428
|
changed = true;
|
|
386
429
|
}
|
|
387
430
|
else if (k.name === "p") {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
431
|
+
// v1.56 T2: arrow-driven candidate picker replaces the typed pin entry (operator ruling:
|
|
432
|
+
// the suggestion proposes, the user disposes). Every rank is a production route() result
|
|
433
|
+
// (T1 seam); a pick mutates editable only — the write still funnels through the ONE
|
|
434
|
+
// diff-confirm + reload-guard path below. The picker adds no listeners of its own, so
|
|
435
|
+
// every exit path inherits term.close()'s release-and-pause contract in the finally.
|
|
436
|
+
const cand = shapeCandidates(previewTask(shape), previewCfg(selectedMode), channels, profile);
|
|
437
|
+
let pCursor = 0;
|
|
438
|
+
const pickerFrame = () => listFrame(`pick · ${shape}`, "↑↓/jk move · enter pin · esc cancel · q quit", cand.map((c) => candidateRow(c, cfg.pricing)), pCursor);
|
|
439
|
+
term.frame(pickerFrame());
|
|
440
|
+
for (;;) {
|
|
441
|
+
const pk = await term.key();
|
|
442
|
+
if (pk.name === "q" || (pk.ctrl === true && pk.name === "c"))
|
|
443
|
+
return QUIT;
|
|
444
|
+
if (pk.name === "escape")
|
|
445
|
+
break;
|
|
446
|
+
if (isNext(pk) && cand.length > 0) {
|
|
447
|
+
const a = cand[pCursor].assignment;
|
|
448
|
+
editable.map[shape] = { pin: { via: a.adapter, model: a.model } };
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
const pMoved = moveCursor(pk, pCursor, cand.length);
|
|
452
|
+
if (pMoved !== pCursor) {
|
|
453
|
+
pCursor = pMoved;
|
|
454
|
+
term.frame(pickerFrame());
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
changed = true; // the picker replaced the frame — every exit redraws the shape screen
|
|
394
458
|
}
|
|
395
459
|
else if (k.name === "f") {
|
|
396
460
|
const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Assignment, type BillingChannel } from "../adapters/types.js";
|
|
2
|
+
import type { TickmarkrConfig } from "../config/config.js";
|
|
3
|
+
import type { Task } from "../graph/schema.js";
|
|
4
|
+
import type { RoutingProfile } from "./profile.js";
|
|
5
|
+
import { type RoutingPreferContext } from "./router.js";
|
|
6
|
+
export interface RankedCandidate {
|
|
7
|
+
assignment: Assignment;
|
|
8
|
+
why: string;
|
|
9
|
+
belowFloor: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function rankCandidates(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext): RankedCandidate[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { channelKey } from "../adapters/types.js";
|
|
2
|
+
import { route, RoutingError } from "./router.js";
|
|
3
|
+
// v1.56 T1 (scoper ruling 2, RULED FOR FABLE): NO comparator lives here — every rank IS a
|
|
4
|
+
// production route() result. Order derives solely from re-calling route with a growing exclusion
|
|
5
|
+
// set (the OBS-57 exclude seam), exploration off so repeated calls agree. When the eligible pool
|
|
6
|
+
// exhausts (RoutingError), the advisory floor for this shape is dropped ONCE and the remaining
|
|
7
|
+
// live channels rank after, marked belowFloor — mirroring the routes-but-lints semantics a
|
|
8
|
+
// below-floor map pin already has (router.ts pin path).
|
|
9
|
+
export function rankCandidates(task, cfg, channels, profile, preferCtx) {
|
|
10
|
+
const ranked = [];
|
|
11
|
+
const exclude = new Set();
|
|
12
|
+
let effCfg = cfg;
|
|
13
|
+
let belowFloor = false;
|
|
14
|
+
while (exclude.size < channels.length) {
|
|
15
|
+
let r;
|
|
16
|
+
try {
|
|
17
|
+
r = route(task, effCfg, channels, profile, preferCtx, exclude, { noExplore: true });
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
if (!(e instanceof RoutingError))
|
|
21
|
+
throw e;
|
|
22
|
+
if (belowFloor)
|
|
23
|
+
break; // pool exhausted (or an unresolvable map pin) — ranking is complete
|
|
24
|
+
// eligible pool dry: drop this shape's advisory floor and keep iterating the production
|
|
25
|
+
// router over the leftover live channels. No hidden mutation — fresh cfg objects only.
|
|
26
|
+
belowFloor = true;
|
|
27
|
+
const { [task.shape]: _dropped, ...floors } = cfg.routing.floors;
|
|
28
|
+
effCfg = { ...cfg, routing: { ...cfg.routing, floors } };
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
ranked.push({ assignment: r.assignment, why: r.provenance, belowFloor });
|
|
32
|
+
exclude.add(channelKey(r.assignment));
|
|
33
|
+
}
|
|
34
|
+
return ranked;
|
|
35
|
+
}
|
package/dist/run/daemon.js
CHANGED
|
@@ -21,6 +21,7 @@ import { acquireRunLock, releaseRunLock } from "./lock.js";
|
|
|
21
21
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
22
22
|
import { nextChannel, QUALITY_ENV, route } from "../route/router.js";
|
|
23
23
|
import { desiredPanes } from "./reconcile.js";
|
|
24
|
+
import { normalizeStallSnapshot } from "./stall.js";
|
|
24
25
|
const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
|
|
25
26
|
// An override (flag/spec) re-resolves through loadConfigWithMode itself, via a synthesized repo overlay
|
|
26
27
|
// carrying routing.mode — floors, explore, lints, and provenance all come from config.ts's preset
|
|
@@ -630,7 +631,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
630
631
|
// OBS-54: reaping keys on new pane output, not dispatch wall clock. Poll at least twice per
|
|
631
632
|
// stall window (and at the existing 30s cadence for normal windows) so an active worker resets it.
|
|
632
633
|
const stallWindowMs = taskTimeoutMinutes * 60_000;
|
|
633
|
-
|
|
634
|
+
// OBS-82: the stall clock compares NORMALIZED snapshots so a spinner glyph/elapsed-time
|
|
635
|
+
// repaint is silence, not activity. ONLY this inactivity compare sees normalized text —
|
|
636
|
+
// trailer detection, harvest, paging, and quota checks all read the raw pane.
|
|
637
|
+
let lastStallSnapshot = normalizeStallSnapshot(output);
|
|
634
638
|
let lastOutputAt = Date.now();
|
|
635
639
|
while (Date.now() - lastOutputAt < stallWindowMs) {
|
|
636
640
|
const sliceStart = Date.now();
|
|
@@ -649,9 +653,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
649
653
|
break;
|
|
650
654
|
}
|
|
651
655
|
}
|
|
652
|
-
const
|
|
653
|
-
if (
|
|
654
|
-
|
|
656
|
+
const currentStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 1000));
|
|
657
|
+
if (currentStallSnapshot !== lastStallSnapshot) {
|
|
658
|
+
lastStallSnapshot = currentStallSnapshot;
|
|
655
659
|
lastOutputAt = Date.now();
|
|
656
660
|
}
|
|
657
661
|
// v1.23 T2: piggyback on this poll slice — same cadence as blocked/idle checks, no new timer.
|
|
@@ -709,8 +713,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
709
713
|
// T5: same brand header as the interactive path and the gate/consult dispatch scripts.
|
|
710
714
|
await driver.run(slot, `${bannerShell()}; ${inv.command}; ${exitMarkerCmd}`);
|
|
711
715
|
// OBS-54: headless workers have the same output-inactivity budget as visible panes.
|
|
716
|
+
// OBS-82: same normalized-snapshot compare as the interactive site — spinner-only repaints
|
|
717
|
+
// exhaust the budget here too; harvest below still reads the raw pane.
|
|
712
718
|
const stallWindowMs = taskTimeoutMinutes * 60_000;
|
|
713
|
-
let
|
|
719
|
+
let lastStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 500));
|
|
714
720
|
let lastOutputAt = Date.now();
|
|
715
721
|
finished = false;
|
|
716
722
|
while (Date.now() - lastOutputAt < stallWindowMs) {
|
|
@@ -720,9 +726,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
720
726
|
finished = true;
|
|
721
727
|
break;
|
|
722
728
|
}
|
|
723
|
-
const
|
|
724
|
-
if (
|
|
725
|
-
|
|
729
|
+
const currentStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 500));
|
|
730
|
+
if (currentStallSnapshot !== lastStallSnapshot) {
|
|
731
|
+
lastStallSnapshot = currentStallSnapshot;
|
|
726
732
|
lastOutputAt = Date.now();
|
|
727
733
|
}
|
|
728
734
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Normalize one pane snapshot for the stall-inactivity compare (and ONLY that compare — trailer
|
|
2
|
+
* parsing, harvest, waitOutput, and paging read the raw text). Two snapshots that normalize equal
|
|
3
|
+
* are the same frame modulo spinner presentation; any other byte difference is worker activity. */
|
|
4
|
+
export declare function normalizeStallSnapshot(text: string): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// OBS-82: codex's MCP-startup spinner repaints a braille glyph + elapsed-time cell forever, so the
|
|
2
|
+
// daemon's raw snapshot compare reads a wedged pane as active and the stall clock never fires.
|
|
3
|
+
// This normalizer deletes ONLY presentation tokens from a closed allowlist — ANSI/VT escape
|
|
4
|
+
// sequences, braille-range spinner glyphs, and elapsed-time tokens bound to time-unit suffixes.
|
|
5
|
+
// Every other byte passes through identical: words, paths, server names, and progress counts
|
|
6
|
+
// (a five-of-seven counter change IS activity) all remain change-sensitive. The asymmetry is the
|
|
7
|
+
// design: an allowlist MISS degrades to today's recoverable no-reap behavior, while an over-broad
|
|
8
|
+
// deletion would reap a healthy worker — a new failure class. Grow the allowlist only with
|
|
9
|
+
// captured evidence (tests/fixtures/codex-mcp-spinner/).
|
|
10
|
+
// CSI (with intermediates), OSC (BEL- or ST-terminated), DCS/SOS/PM/APC strings, single-char
|
|
11
|
+
// escapes, and charset selection — the raw-pty forms; herdr pane reads are already rendered.
|
|
12
|
+
// eslint-disable-next-line no-control-regex
|
|
13
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x9b[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][^\x1b]*(?:\x1b\\)?|\x1b[()][0-9A-Za-z]|\x1b[0-~]/g;
|
|
14
|
+
// Braille patterns U+2800–U+28FF — the codex spinner cell (captured fixture: ⠋⠙⠸⠴⠦⠇ …).
|
|
15
|
+
const SPINNER_RE = /[⠀-⣿]/g;
|
|
16
|
+
// A digit run (optionally decimal) bound directly to a time-unit suffix, standing alone as a
|
|
17
|
+
// word: 9s, 41s, 3m, 1h, 800ms. Never bare digits — "(6/7)" and "5 of 7" stay change-sensitive.
|
|
18
|
+
const ELAPSED_RE = /(?<![\w.])\d+(?:\.\d+)?(?:ms|[hms])(?!\w)/g;
|
|
19
|
+
/** Normalize one pane snapshot for the stall-inactivity compare (and ONLY that compare — trailer
|
|
20
|
+
* parsing, harvest, waitOutput, and paging read the raw text). Two snapshots that normalize equal
|
|
21
|
+
* are the same frame modulo spinner presentation; any other byte difference is worker activity. */
|
|
22
|
+
export function normalizeStallSnapshot(text) {
|
|
23
|
+
return text.replace(ANSI_RE, "").replace(SPINNER_RE, "").replace(ELAPSED_RE, "");
|
|
24
|
+
}
|