tickmarkr 1.56.0 → 1.58.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/codex.d.ts +2 -0
- package/dist/adapters/codex.js +41 -5
- package/dist/adapters/kimi.js +121 -8
- package/dist/route/router.js +58 -7
- 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/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
|
},
|
package/dist/adapters/kimi.js
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { probeVersion } from "./claude-code.js";
|
|
6
6
|
import { parseWorkerResult } from "./prompt.js";
|
|
7
|
-
import { channelsFromConfig, MODEL_ID_RE, shq } from "./types.js";
|
|
8
|
-
// KIMI-03:
|
|
9
|
-
//
|
|
10
|
-
// kimi
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
7
|
+
import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
|
|
8
|
+
// KIMI-03 → v1.58 T5: the "no harness-readable counter" block (research F-6, 2026-07-17) is
|
|
9
|
+
// LIFTED for collectUsage — kimi 0.27.0 writes a wire journal per agent at
|
|
10
|
+
// ~/.kimi-code/sessions/<wd>/session_<uuid>/agents/<agent>/wire.jsonl, and ~/.kimi-code/
|
|
11
|
+
// workspaces.json maps each wd_* dir to its realpath'd workspace root. Field mapping pinned ONLY
|
|
12
|
+
// after live verification of the real store (2026-07-18, 53 wire files / 178 usage rows, zero
|
|
13
|
+
// tokens spent):
|
|
14
|
+
// - the canonical row is top-level `{"type":"usage.record","usage":{...},"usageScope":"turn",
|
|
15
|
+
// "time":<epoch ms>}` — 178/178 rows carried scope "turn" and the identical key set
|
|
16
|
+
// {inputOther, output, inputCacheRead, inputCacheCreation};
|
|
17
|
+
// - per-turn DELTA semantics verified arithmetically: each turn's inputCacheRead ≈ prior
|
|
18
|
+
// inputCacheRead + inputOther + output (the grown context re-read from cache — claude's
|
|
19
|
+
// per-message convention), and inputOther/output are non-monotonic per-turn counts, so
|
|
20
|
+
// SUMMING rows is the correct fold (never a cumulative 3A+2B+C trap);
|
|
21
|
+
// - the SAME usage object is echoed inside the step.end loop event
|
|
22
|
+
// (`type:"context.append_loop_event"`, event.usage) — folding anything but usage.record
|
|
23
|
+
// rows double-counts every turn, hence the exact type+scope pin below;
|
|
24
|
+
// - mapping: inputOther→input, output→output, inputCacheRead→cacheRead,
|
|
25
|
+
// inputCacheCreation→cacheWrite (creation observed live only as 0; mapped by name).
|
|
26
|
+
// Absent or ambiguous usage resolves to UNMETERED, never an invented count: unknown usageScope,
|
|
27
|
+
// non-numeric core fields, torn lines, or no post-cursor row all skip/return undefined — the
|
|
28
|
+
// "?? 0" poisoning class stays banned. contextUsage remains absent (resumeUnknownContext below).
|
|
29
|
+
const MAX_SESSION_FILES = 20;
|
|
30
|
+
const MAX_SESSION_BYTES = 8_000_000;
|
|
14
31
|
const CREDENTIALS_PATH = join(homedir(), ".kimi-code", "credentials", "kimi-code.json");
|
|
15
32
|
// KIMI-01. Auth verdict from ~/.kimi-code/credentials/kimi-code.json ONLY — flat JSON, never a
|
|
16
33
|
// network call. expires_at is epoch SECONDS (not ISO like grok). Non-empty refresh_token dominates
|
|
@@ -101,6 +118,102 @@ export const kimi = {
|
|
|
101
118
|
return { command: this.headlessCommand(ctx.promptFile, a.model) };
|
|
102
119
|
},
|
|
103
120
|
parse: parseKimiResult,
|
|
121
|
+
collectUsage(cwd, sinceMs) {
|
|
122
|
+
try {
|
|
123
|
+
const real = realpathSync(cwd);
|
|
124
|
+
const home = join(homedir(), ".kimi-code");
|
|
125
|
+
const ws = JSON.parse(readFileSync(join(home, "workspaces.json"), "utf8"));
|
|
126
|
+
// a recreated workspace gets a new wd_* hash for the same root — fold every match
|
|
127
|
+
const wdDirs = Object.entries(ws.workspaces ?? {})
|
|
128
|
+
.filter(([, v]) => v && typeof v === "object" && v.root === real)
|
|
129
|
+
.map(([k]) => k);
|
|
130
|
+
// newest-first by mtime, bounded — mtime picks WHICH files to scan, never a record's cursor
|
|
131
|
+
const files = [];
|
|
132
|
+
for (const wd of wdDirs) {
|
|
133
|
+
const wdPath = join(home, "sessions", wd);
|
|
134
|
+
let sessions;
|
|
135
|
+
try {
|
|
136
|
+
sessions = readdirSync(wdPath);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
for (const s of sessions) {
|
|
142
|
+
if (!s.startsWith("session_"))
|
|
143
|
+
continue;
|
|
144
|
+
const agentsPath = join(wdPath, s, "agents");
|
|
145
|
+
let agents;
|
|
146
|
+
try {
|
|
147
|
+
agents = readdirSync(agentsPath);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const a of agents) {
|
|
153
|
+
const p = join(agentsPath, a, "wire.jsonl");
|
|
154
|
+
try {
|
|
155
|
+
files.push({ path: p, m: statSync(p).mtimeMs });
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
files.sort((a, b) => b.m - a.m);
|
|
164
|
+
let input = 0, output = 0, kept = false;
|
|
165
|
+
let cacheRead, cacheWrite;
|
|
166
|
+
for (const { path: fp } of files.slice(0, MAX_SESSION_FILES)) {
|
|
167
|
+
let text;
|
|
168
|
+
try {
|
|
169
|
+
text = readFileSync(fp, "utf8").slice(0, MAX_SESSION_BYTES);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
for (const line of text.split("\n")) {
|
|
175
|
+
if (!line.trim())
|
|
176
|
+
continue;
|
|
177
|
+
let recRaw;
|
|
178
|
+
try {
|
|
179
|
+
recRaw = JSON.parse(line);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const rec = recRaw;
|
|
185
|
+
// exact type+scope pin: the step.end loop event ECHOES the same usage (double-count),
|
|
186
|
+
// and any scope other than the live-verified "turn" has unknown fold semantics — skip
|
|
187
|
+
if (rec.type !== "usage.record" || rec.usageScope !== "turn")
|
|
188
|
+
continue;
|
|
189
|
+
if (typeof rec.time !== "number" || !Number.isFinite(rec.time) || rec.time < sinceMs)
|
|
190
|
+
continue;
|
|
191
|
+
const u = rec.usage;
|
|
192
|
+
if (!u || typeof u !== "object")
|
|
193
|
+
continue;
|
|
194
|
+
const uu = u;
|
|
195
|
+
// ambiguous row (core fields missing/non-numeric) resolves to unmetered, never 0
|
|
196
|
+
if (typeof uu.inputOther !== "number" || typeof uu.output !== "number")
|
|
197
|
+
continue;
|
|
198
|
+
input += uu.inputOther;
|
|
199
|
+
output += uu.output;
|
|
200
|
+
if (typeof uu.inputCacheRead === "number")
|
|
201
|
+
cacheRead = (cacheRead ?? 0) + uu.inputCacheRead;
|
|
202
|
+
if (typeof uu.inputCacheCreation === "number")
|
|
203
|
+
cacheWrite = (cacheWrite ?? 0) + uu.inputCacheCreation;
|
|
204
|
+
kept = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!kept)
|
|
208
|
+
return undefined; // nothing matched ⇒ unmetered, never {input:0,…}
|
|
209
|
+
const out = { input, output, ...(cacheRead !== undefined ? { cacheRead } : {}), ...(cacheWrite !== undefined ? { cacheWrite } : {}) };
|
|
210
|
+
const p = TokenUsageSchema.safeParse(out);
|
|
211
|
+
return p.success ? p.data : undefined;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return undefined; // missing home/workspaces.json / any throw ⇒ fail open
|
|
215
|
+
}
|
|
216
|
+
},
|
|
104
217
|
listModels: async () => {
|
|
105
218
|
const r = spawnSync("kimi", ["provider", "list", "--json"], { encoding: "utf8", timeout: 15000 });
|
|
106
219
|
return r.error || r.status !== 0 ? [] : parseKimiModels(r.stdout || "");
|
package/dist/route/router.js
CHANGED
|
@@ -67,6 +67,43 @@ function ladderFor(task, entry) {
|
|
|
67
67
|
const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
|
|
68
68
|
return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
|
|
69
69
|
}
|
|
70
|
+
// v1.58 frontier spread (operator ruling .planning/rulings/2026-07-18-frontier-spread-credits.md):
|
|
71
|
+
// every sub channel marginal-cost-ranks 0, so tier-equal frontier ties fell through all sort keys
|
|
72
|
+
// to discovery order — the first sub channel (claude-code:fable) served every frontier auto pick,
|
|
73
|
+
// and same-day quota exhaustion on that one channel interrupted two live pipeline agents. A
|
|
74
|
+
// deterministic task-keyed rotation now spreads each residual frontier tie across its whole tie
|
|
75
|
+
// group, so sol/k3-class channels serve frontier work as first-class candidates. Strictly the LAST
|
|
76
|
+
// tiebreak: it permutes only maximal runs already tied on every key above it (prefer band,
|
|
77
|
+
// marginal cost, tier — plus exploration bonus and learned score on the learned path), and only
|
|
78
|
+
// runs of zero-marginal-cost frontier SUB channels outside any prefer entry. Pins return upstream,
|
|
79
|
+
// denies filter upstream, prefer order is operator-explicit — none are touched.
|
|
80
|
+
// ponytail: frontier runs only — the ruled scope (ruling §1); spreading mid/cheap ties too = drop
|
|
81
|
+
// the tier guard. Rotation, not utilization: profile-driven spread would be inert exactly where
|
|
82
|
+
// the concentration bites (cold profiles, plan-time static routing, learned:off).
|
|
83
|
+
const spreadOffset = (task) => {
|
|
84
|
+
let h = 0;
|
|
85
|
+
for (const ch of `${task.id}\n${task.goal}`)
|
|
86
|
+
h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
|
87
|
+
return h;
|
|
88
|
+
};
|
|
89
|
+
function spreadFrontierTies(sorted, task, prefer, tied) {
|
|
90
|
+
const out = [...sorted];
|
|
91
|
+
const preferLen = prefer?.length ?? 0;
|
|
92
|
+
let i = 0;
|
|
93
|
+
while (i < out.length) {
|
|
94
|
+
let j = i + 1;
|
|
95
|
+
while (j < out.length && tied(out[i], out[j]))
|
|
96
|
+
j++;
|
|
97
|
+
const len = j - i;
|
|
98
|
+
if (len > 1 && out[i].tier === "frontier" && marginalCostRank(out[i]) === 0 && preferIndex(out[i], prefer) === preferLen) {
|
|
99
|
+
const run = out.slice(i, j);
|
|
100
|
+
const k = spreadOffset(task) % len;
|
|
101
|
+
out.splice(i, len, ...run.slice(k), ...run.slice(0, k));
|
|
102
|
+
}
|
|
103
|
+
i = j;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
70
107
|
function withoutExcluded(channels, exclude) {
|
|
71
108
|
if (!exclude?.size)
|
|
72
109
|
return channels;
|
|
@@ -174,8 +211,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
174
211
|
for (const p of prefer ?? [])
|
|
175
212
|
preflightPrefer(p);
|
|
176
213
|
// key order is a contract: prefer > marginal cost > tier (cheapest sufficient) > learned score (v1.6 ROUTE-06)
|
|
177
|
-
// > discovery order (same-tier fairness, D2). The learned score
|
|
178
|
-
// discovery-order tail, never a pin/floor/prefer/cost/tier boundary (they all sort above it, ROUTE-08)
|
|
214
|
+
// > frontier spread (v1.58) > discovery order (same-tier fairness, D2). The learned score decides only the
|
|
215
|
+
// discovery-order tail, never a pin/floor/prefer/cost/tier boundary (they all sort above it, ROUTE-08);
|
|
216
|
+
// the spread rotates only what would otherwise fall to discovery order.
|
|
179
217
|
const staticCmp = (a, b) => preferIndex(a, prefer) - preferIndex(b, prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
|
|
180
218
|
const eligibleRaw = channels.filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier]);
|
|
181
219
|
if (!eligibleRaw.length) {
|
|
@@ -192,6 +230,11 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
192
230
|
let eligible;
|
|
193
231
|
let deviation;
|
|
194
232
|
let learnedChosen = "";
|
|
233
|
+
let spreadDecided = false;
|
|
234
|
+
// v1.58: the spread is applied identically on both sort paths (and to the deviation baseline), so
|
|
235
|
+
// an empty/cold profile stays byte-identical to the 3-arg call (ROUTE-07) and a deviation always
|
|
236
|
+
// means learned evidence moved the pick — never the spread mislabeled as learning.
|
|
237
|
+
const spreadStatic = (sorted) => spreadFrontierTies(sorted, task, prefer, (a, b) => staticCmp(a, b) === 0);
|
|
195
238
|
if (profile) {
|
|
196
239
|
// scores precomputed ONCE over the eligible set — never inside the comparator (Pitfall 1).
|
|
197
240
|
// ponytail: no epsilon — two finite scores subtract to a finite number (Phase 12 totality); an
|
|
@@ -210,7 +253,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
210
253
|
const off = exploreOff(task, cfg, exploreCtx);
|
|
211
254
|
const bonuses = new Map(eligibleRaw.map((c) => [channelKey(c), off ? 0 : explorationBonus(cellOf(profile, task.shape, channelKey(c), c.channel), cap)]));
|
|
212
255
|
const bonusOf = (c) => bonuses.get(channelKey(c));
|
|
213
|
-
const staticWinner = [...eligibleRaw].sort(staticCmp)[0];
|
|
256
|
+
const staticWinner = spreadStatic([...eligibleRaw].sort(staticCmp))[0];
|
|
214
257
|
// Phase 34 ROUTE-17: prefer becomes a BAND + group-rep keys so exploration fires ACROSS prefer
|
|
215
258
|
// entries, while intra-entry order (cost > tier > bonus > score) and every cold path stay byte-identical.
|
|
216
259
|
// Cold reduces to preferIndex || cost || tier ≡ staticCmp (proof P1); rep keys use the group HEAD so
|
|
@@ -232,7 +275,11 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
232
275
|
}));
|
|
233
276
|
const kOf = (c) => keyOf.get(channelKey(c));
|
|
234
277
|
const firstDiff = (a, b) => kOf(a).findIndex((v, i) => v !== kOf(b)[i]);
|
|
235
|
-
|
|
278
|
+
const sortedByKey = [...eligibleRaw].sort((a, b) => { const i = firstDiff(a, b); return i === -1 ? 0 : kOf(a)[i] - kOf(b)[i]; });
|
|
279
|
+
// spread runs tied on the FULL learned key (bonus/score included) — a warm score or live probe
|
|
280
|
+
// bonus still decides its tie exactly as before; the spread rotates only the residual all-equal runs.
|
|
281
|
+
eligible = spreadFrontierTies(sortedByKey, task, prefer, (a, b) => firstDiff(a, b) === -1);
|
|
282
|
+
spreadDecided = channelKey(eligible[0]) !== channelKey(sortedByKey[0]);
|
|
236
283
|
const w = eligible[0];
|
|
237
284
|
const ru = channelKey(staticWinner) !== channelKey(w) ? staticWinner : eligible[1];
|
|
238
285
|
// Phase 34 ROUTE-17: first-differing-key markers — probe when a rep or intra bonus key decided.
|
|
@@ -250,7 +297,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
250
297
|
}
|
|
251
298
|
}
|
|
252
299
|
else {
|
|
253
|
-
|
|
300
|
+
const sortedStatic = eligibleRaw.sort(staticCmp); // ROUTE-07/09: the v1.5 key order, spread applied as the last key
|
|
301
|
+
eligible = spreadStatic(sortedStatic);
|
|
302
|
+
spreadDecided = channelKey(eligible[0]) !== channelKey(sortedStatic[0]);
|
|
254
303
|
}
|
|
255
304
|
const bound = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
|
|
256
305
|
(taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
|
|
@@ -261,8 +310,10 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
261
310
|
const preferVia = preferFromAuto(task.shape, preferCtx)
|
|
262
311
|
? `via prefer (auto-modernized ${preferCtx.autoPrefer.derivedAt.slice(0, 10)})`
|
|
263
312
|
: "via prefer";
|
|
264
|
-
|
|
265
|
-
|
|
313
|
+
// a spread-decided winner is never inside a prefer band (the spread skips those runs), so the
|
|
314
|
+
// three arms below are mutually exclusive by construction
|
|
315
|
+
const chosenBy = learnedChosen || (spreadDecided ? "via frontier spread"
|
|
316
|
+
: prefer && preferIndex(eligible[0], prefer) < prefer.length ? preferVia : "cheapest sufficient tier");
|
|
266
317
|
maybeSlaLint(lints, task, profile, slaMinutes, eligible[0]);
|
|
267
318
|
return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
|
|
268
319
|
}
|
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
|
+
}
|