tickmarkr 2.6.8 → 2.7.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/catalog-remote.js +5 -2
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +18 -10
- package/dist/adapters/model-lints.js +21 -11
- package/dist/adapters/model-windows.js +0 -1
- package/dist/adapters/registry.js +7 -3
- package/dist/adapters/types.d.ts +2 -0
- package/dist/cli/commands/doctor.js +3 -0
- package/dist/config/config.js +6 -5
- package/dist/run/daemon.d.ts +2 -2
- package/dist/run/daemon.js +8 -37
- package/dist/run/suite-census.d.ts +20 -0
- package/dist/run/suite-census.js +100 -0
- package/dist/tui/ink/fleet-app.js +25 -9
- package/package.json +4 -1
|
@@ -505,9 +505,12 @@ export async function refreshCatalogCommand(opts) {
|
|
|
505
505
|
let modelsDev = current.catalog.modelsDev;
|
|
506
506
|
let modelsDevUpdated = false;
|
|
507
507
|
try {
|
|
508
|
-
|
|
509
|
-
|
|
508
|
+
// v2.6.9 (queue row 97): validate before the payload replaces the spine — an invalid payload left in
|
|
509
|
+
// modelsDev would be written beside an updated sibling leg and make the whole cache unreadable.
|
|
510
|
+
const fetched = await fetchCatalog(fetcher, MODELS_DEV_CATALOG_URL, {}, timeoutMs, (r) => r.json());
|
|
511
|
+
if (!validModelsDevCatalog(fetched))
|
|
510
512
|
throw new Error("models.dev catalog schema is invalid");
|
|
513
|
+
modelsDev = fetched;
|
|
511
514
|
modelsDevUpdated = true;
|
|
512
515
|
legAt.modelsDev = stamp;
|
|
513
516
|
legs.push({ leg: "models.dev", status: "updated", detail: "fetched", retry: "next retry after this leg is stale" });
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare function readCodexModelsCache(path?: string): {
|
|
|
3
3
|
models: string[];
|
|
4
4
|
fetchedAt?: string;
|
|
5
5
|
retirements?: Record<string, ModelRetirement | null>;
|
|
6
|
+
hidden?: string[];
|
|
6
7
|
};
|
|
7
8
|
export declare const CODEX_GIT_GRANT = "-c \"sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir); tkr_gitdir=$(git rev-parse --absolute-git-dir); if [ \"$tkr_gitdir\" != \"$tkr_common\" ]; then printf '\"%s\",' \"$tkr_gitdir\"; fi; printf '\"%s/objects\",\"%s/refs\",\"%s/logs\"' \"$tkr_common\" \"$tkr_common\" \"$tkr_common\")]\"";
|
|
8
9
|
export declare const CODEX_TRUST_DIALOG: TrustDialog;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -61,23 +61,29 @@ function codexSessionDayDirs(sessions, sinceMs) {
|
|
|
61
61
|
// Fails OPEN to { models: [] } on missing/corrupt cache. Version drift observed (installed CLI
|
|
62
62
|
// 0.143.0 vs cache client_version 0.144.0, 2026-07-10) — hence the defensive try/catch and no shape
|
|
63
63
|
// assumptions. CODEX_HOME is codex's own relocation env. fetchedAt is the honest codex knowledge age.
|
|
64
|
-
// v2.6.4 T2 (B1a): each
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
64
|
+
// v2.6.4 T2 (B1a): each entry's `upgrade` is the CLI's own retirement notice; its successor is whatever
|
|
65
|
+
// that notice names — never a fixed id in this file. A listed row with upgrade: null records known clean;
|
|
66
|
+
// a malformed or absent `upgrade` records nothing, so that id stays unknown. v2.6.9 (queue row 64): the CLI
|
|
67
|
+
// HIDES a model before it retires it (0.162.0, 2026-10-08: gpt-5.5 visibility "hide" with a dated
|
|
68
|
+
// upgrade), so a valid dated notice is read from hidden rows too; a hidden row still never lists, and a
|
|
69
|
+
// hidden row without a dated notice records nothing (delisted is not known clean). `hidden` names every
|
|
70
|
+
// visibility "hide" row (D-1526: routing evidence). An unreadable cache returns neither (unknown, never clean).
|
|
68
71
|
export function readCodexModelsCache(path) {
|
|
69
72
|
const p = path ?? join(process.env.CODEX_HOME || join(homedir(), ".codex"), "models_cache.json");
|
|
70
73
|
try {
|
|
71
74
|
const d = JSON.parse(readFileSync(p, "utf8"));
|
|
72
|
-
const
|
|
73
|
-
.filter((m) =>
|
|
75
|
+
const rows = (d.models ?? [])
|
|
76
|
+
.filter((m) => typeof m?.slug === "string" && m.slug.length > 0 && MODEL_ID_RE.test(m.slug));
|
|
77
|
+
const listed = rows.filter((m) => m.visibility === "list");
|
|
74
78
|
const retirements = {};
|
|
75
|
-
for (const m of
|
|
79
|
+
for (const m of rows) {
|
|
76
80
|
const notice = codexRetirement(m.upgrade);
|
|
77
|
-
if (notice !==
|
|
78
|
-
|
|
81
|
+
if (notice === undefined || (notice === null && m.visibility !== "list"))
|
|
82
|
+
continue;
|
|
83
|
+
retirements[m.slug] = notice;
|
|
79
84
|
}
|
|
80
|
-
|
|
85
|
+
const hidden = rows.filter((m) => m.visibility === "hide").map((m) => m.slug);
|
|
86
|
+
return { models: listed.map((m) => m.slug), fetchedAt: typeof d.fetched_at === "string" ? d.fetched_at : undefined, retirements, hidden };
|
|
81
87
|
}
|
|
82
88
|
catch {
|
|
83
89
|
return { models: [] };
|
|
@@ -305,6 +311,8 @@ export const codex = {
|
|
|
305
311
|
listModelsFetchedAt: () => readCodexModelsCache().fetchedAt,
|
|
306
312
|
// v2.6.4 T2 (B1a): the cache's per-model retirement notices, persisted beside models in doctor.json.
|
|
307
313
|
listModelsRetirements: () => readCodexModelsCache().retirements,
|
|
314
|
+
// v2.6.9 (D-1526): the ids the cache marks hidden, persisted beside models as routing evidence.
|
|
315
|
+
listModelsHidden: () => readCodexModelsCache().hidden,
|
|
308
316
|
collectUsage(cwd, sinceMs) {
|
|
309
317
|
try {
|
|
310
318
|
const real = realpathSync(cwd);
|
|
@@ -497,13 +497,15 @@ function storedRetirement(record, model) {
|
|
|
497
497
|
return { retiresAt: n.retiresAt };
|
|
498
498
|
return typeof n.successor === "string" && MODEL_ID_RE.test(n.successor) ? { retiresAt: n.retiresAt, successor: n.successor } : undefined;
|
|
499
499
|
}
|
|
500
|
-
// Configured
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
500
|
+
// Configured models the CLI lists, plus configured models it stopped listing that still carry a dated
|
|
501
|
+
// notice (the CLI hides a model before retiring it); any other unlisted id carries the bare tombstone
|
|
502
|
+
// advisory instead. The CLI's notice is quoted as-is: its successor is the CLI's upgrade target, never
|
|
503
|
+
// a tier or benchmark claim, and no model name or visibility is read as one. The notice never changes routing;
|
|
504
|
+
// the CLI's hide does (D-1526), so a hidden id's lint says it is not routed instead of "routing unchanged".
|
|
505
|
+
function retirementLints(id, configured, listed, hiddenIds, record, nowMs) {
|
|
504
506
|
const lints = [];
|
|
505
507
|
const unknown = [];
|
|
506
|
-
for (const model of
|
|
508
|
+
for (const model of configured) {
|
|
507
509
|
const notice = storedRetirement(record, model);
|
|
508
510
|
if (notice === undefined) {
|
|
509
511
|
unknown.push(model);
|
|
@@ -511,12 +513,14 @@ function retirementLints(id, listedConfigured, record, nowMs) {
|
|
|
511
513
|
}
|
|
512
514
|
if (!notice)
|
|
513
515
|
continue; // listed with no notice: known clean
|
|
516
|
+
const hidden = listed.includes(model) ? "" : " (the CLI no longer lists it)";
|
|
517
|
+
const routing = hiddenIds.includes(model) && !listed.includes(model) ? "not routed while the CLI hides it" : "routing unchanged";
|
|
514
518
|
const successor = notice.successor
|
|
515
519
|
? `successor ${notice.successor} (named by the CLI — not a tier claim; classify it per benchmark policy)`
|
|
516
520
|
: "no successor named";
|
|
517
521
|
lints.push(nowMs >= Date.parse(notice.retiresAt)
|
|
518
|
-
? `${id}: ${model} retired ${notice.retiresAt} per the CLI's notice; ${successor} — tombstone it (${model}: null overlay) or verify the id (advisory — routing
|
|
519
|
-
: `${id}: ${model} retires ${notice.retiresAt} per the CLI's notice; ${successor} — tombstone it (${model}: null overlay) before then (advisory — routing
|
|
522
|
+
? `${id}: ${model} retired ${notice.retiresAt} per the CLI's notice${hidden}; ${successor} — tombstone it (${model}: null overlay) or verify the id (advisory — ${routing})`
|
|
523
|
+
: `${id}: ${model} retires ${notice.retiresAt} per the CLI's notice${hidden}; ${successor} — tombstone it (${model}: null overlay) before then (advisory — ${routing})`);
|
|
520
524
|
}
|
|
521
525
|
if (unknown.length) {
|
|
522
526
|
const why = record && typeof record === "object"
|
|
@@ -555,19 +559,25 @@ export function modelLints(cfg, health, adapters, opts) {
|
|
|
555
559
|
}
|
|
556
560
|
const h = health[id];
|
|
557
561
|
const detected = h?.models ?? []; // MANDATORY default: pre-v1.5 files lack a populated models array
|
|
562
|
+
const configured = Object.keys(cfg.tiers[id]?.models ?? {});
|
|
563
|
+
// a configured id the CLI stopped listing but still carries a dated notice for gets the dated lint, not the bare one
|
|
564
|
+
const noticed = (model) => !!adapter.listModelsRetirements && !!storedRetirement(h?.modelRetirements, model);
|
|
558
565
|
if (detected.length === 0) {
|
|
559
566
|
if (h?.installed)
|
|
560
567
|
lints.push(`${id}: no detection data — run tickmarkr doctor`);
|
|
568
|
+
// D-1526: a cache holding only hidden rows lists nothing, yet its dated notices are still the CLI's word
|
|
569
|
+
if (adapter.listModelsRetirements)
|
|
570
|
+
lints.push(...retirementLints(id, configured.filter(noticed), detected, h?.modelsHidden ?? [], h?.modelRetirements, nowMs));
|
|
561
571
|
continue; // no data to diff or age
|
|
562
572
|
}
|
|
563
|
-
const configured = Object.keys(cfg.tiers[id]?.models ?? {});
|
|
564
573
|
for (const model of configured) {
|
|
565
|
-
if (!detected.includes(model)) {
|
|
574
|
+
if (!detected.includes(model) && !noticed(model)) {
|
|
566
575
|
lints.push(`${id}: tiers lists ${model} — CLI no longer reports it; tombstone it (${model}: null overlay) or verify the id`);
|
|
567
576
|
}
|
|
568
577
|
}
|
|
569
|
-
if (adapter.listModelsRetirements)
|
|
570
|
-
lints.push(...retirementLints(id, configured.filter((m) => detected.includes(m)), h?.modelRetirements, nowMs));
|
|
578
|
+
if (adapter.listModelsRetirements) {
|
|
579
|
+
lints.push(...retirementLints(id, configured.filter((m) => detected.includes(m) || noticed(m)), detected, h?.modelsHidden ?? [], h?.modelRetirements, nowMs));
|
|
580
|
+
}
|
|
571
581
|
const extra = collapseUnclassified(detected, new Set(configured));
|
|
572
582
|
if (extra.length) {
|
|
573
583
|
const shown = extra.slice(0, cap).map((row) => row.model).join(", ");
|
|
@@ -49,7 +49,6 @@ const VENDORED_MODEL_WINDOW_CLAIMS = [
|
|
|
49
49
|
{ modelId: "gpt-6-sol", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
|
|
50
50
|
{ modelId: "gpt-6-luna", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
|
|
51
51
|
{ modelId: "gpt-5.6-sol", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
|
|
52
|
-
{ modelId: "gpt-5.5", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
|
|
53
52
|
{ modelId: "gpt-5.6-terra", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
|
|
54
53
|
{ modelId: "gpt-5.6-luna", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
|
|
55
54
|
{ modelId: "composer-2.5", window: 200_000, source: CURSOR_SOURCE, readDate: READ_DATE },
|
|
@@ -107,14 +107,18 @@ function modelListValidated(h) {
|
|
|
107
107
|
return !!h?.modelsDetectedAt && Array.isArray(h.models);
|
|
108
108
|
}
|
|
109
109
|
export function invalidConfiguredModels(cfg, adapterId, h) {
|
|
110
|
+
const configured = Object.keys(cfg.tiers[adapterId]?.models ?? {});
|
|
111
|
+
// v2.6.9 (queue row 64, D-1526): an id the CLI's own list marks hidden is positive evidence it stopped offering
|
|
112
|
+
// it — out whatever the overlap below. The fail-open that follows covers only ids with no such evidence.
|
|
113
|
+
const hidden = new Set(h?.modelsHidden ?? []);
|
|
114
|
+
const hiddenConfigured = configured.filter((m) => hidden.has(m) && !(h?.models ?? []).includes(m));
|
|
110
115
|
if (!modelListValidated(h))
|
|
111
|
-
return
|
|
116
|
+
return hiddenConfigured;
|
|
112
117
|
const listed = new Set(h.models);
|
|
113
|
-
const configured = Object.keys(cfg.tiers[adapterId]?.models ?? {});
|
|
114
118
|
// Fail open until the CLI list intersects at least one configured model — proves the list is authoritative
|
|
115
119
|
// for this adapter's tier namespace (MODEL-04: unrelated detected ids must not invalidate routing).
|
|
116
120
|
if (!configured.some((m) => listed.has(m)))
|
|
117
|
-
return
|
|
121
|
+
return hiddenConfigured;
|
|
118
122
|
return configured.filter((m) => !listed.has(m));
|
|
119
123
|
}
|
|
120
124
|
export function modelAliasExclusions(cfg, adapters, health) {
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export interface AuthHealth {
|
|
|
51
51
|
modelAuth?: Record<string, ModelAuth>;
|
|
52
52
|
modelIdentities?: Record<string, string>;
|
|
53
53
|
modelRetirements?: Record<string, ModelRetirement | null>;
|
|
54
|
+
modelsHidden?: string[];
|
|
54
55
|
codexCommit?: CodexCommitStatus;
|
|
55
56
|
launchServices?: LaunchServicesRecord;
|
|
56
57
|
}
|
|
@@ -206,6 +207,7 @@ export interface WorkerAdapter {
|
|
|
206
207
|
listModels?(): Promise<string[]>;
|
|
207
208
|
listModelsFetchedAt?(): string | undefined;
|
|
208
209
|
listModelsRetirements?(): Record<string, ModelRetirement | null> | undefined;
|
|
210
|
+
listModelsHidden?(): string[] | undefined;
|
|
209
211
|
collectUsage?(cwd: string, sinceMs: number): TokenUsage | undefined;
|
|
210
212
|
contextUsage?(session: SessionRef): ContextUsage | null;
|
|
211
213
|
readSessionTranscript?(session: SessionRef): {
|
|
@@ -600,6 +600,9 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
600
600
|
const retirements = a.listModelsRetirements?.();
|
|
601
601
|
if (retirements)
|
|
602
602
|
health[a.id].modelRetirements = retirements;
|
|
603
|
+
const hidden = a.listModelsHidden?.(); // v2.6.9 (D-1526): the CLI's hidden flag, read by routing
|
|
604
|
+
if (hidden)
|
|
605
|
+
health[a.id].modelsHidden = hidden;
|
|
603
606
|
}
|
|
604
607
|
catch { /* fail open: leave models as-is, doctor stays healthy */ }
|
|
605
608
|
}
|
package/dist/config/config.js
CHANGED
|
@@ -551,20 +551,21 @@ export const DEFAULT_CONFIG = {
|
|
|
551
551
|
// astra=frontier and luna=cheap follow the provider's own descriptions in the local cache (read
|
|
552
552
|
// 2026-09-29: "Frontier intelligence for the most demanding work" / "Fast and affordable model for
|
|
553
553
|
// easier tasks"); sol=frontier keeps the shipped workhorse-family mapping (gpt-5.6-sol above) by
|
|
554
|
-
// continuity. Learned routing or an overlay may retier on evidence. gpt-5.5
|
|
555
|
-
// CLI
|
|
556
|
-
//
|
|
554
|
+
// continuity. Learned routing or an overlay may retier on evidence. v2.6.9 (queue row 64): gpt-5.5 left
|
|
555
|
+
// the seed — the CLI hides it and announces its retirement (2026-10-14T19:00:00Z); its successor is
|
|
556
|
+
// whatever the CLI's notice names (doctor quotes it), never a fixed id here.
|
|
557
|
+
// Windows: the same snapshot lists context_window=272000 for the six still seeded — the budget the CLI launches
|
|
557
558
|
// with, and the authority for these seeds. The API's published 1,050,000 is a different surface and is
|
|
558
559
|
// not contradicted. A later cache listing more ids (gpt-6.1-sol) does not widen this sealed set.
|
|
559
560
|
codex: {
|
|
560
561
|
vendor: "openai", channel: "sub",
|
|
561
562
|
models: {
|
|
562
563
|
"gpt-6-astra": "frontier", "gpt-6-sol": "frontier", "gpt-6-luna": "cheap",
|
|
563
|
-
"gpt-5.6-sol": "frontier", "gpt-5.
|
|
564
|
+
"gpt-5.6-sol": "frontier", "gpt-5.6-terra": "mid", "gpt-5.6-luna": "cheap",
|
|
564
565
|
},
|
|
565
566
|
windows: {
|
|
566
567
|
"gpt-6-astra": 272_000, "gpt-6-sol": 272_000, "gpt-6-luna": 272_000,
|
|
567
|
-
"gpt-5.6-sol": 272_000, "gpt-5.
|
|
568
|
+
"gpt-5.6-sol": 272_000, "gpt-5.6-terra": 272_000, "gpt-5.6-luna": 272_000,
|
|
568
569
|
},
|
|
569
570
|
},
|
|
570
571
|
// grok-4.5 (xAI, released 2026-07-08) → mid: AA Intelligence 54 (#4), Terminal-Bench 2.1 83.3%
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -253,8 +253,8 @@ export declare function verifyIntegrationTipCached(intWt: string, commands: Reco
|
|
|
253
253
|
type SuitePidProbe = (pid: number) => number | undefined;
|
|
254
254
|
/** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership
|
|
255
255
|
* rules remain testable on hosts that forbid process inspection; production supplies cwd and the
|
|
256
|
-
* inherited TICKMARKR_SUITE_PARENT marker from
|
|
257
|
-
export declare function countLiveSuites(snapshot: string, repoRoot: string, daemonPid
|
|
256
|
+
* inherited TICKMARKR_SUITE_PARENT marker from one batched async probe (suite-census.ts, queue row 103). */
|
|
257
|
+
export declare function countLiveSuites(snapshot: string, repoRoot: string, daemonPid: number, cwdForPid: (pid: number) => string | undefined, suiteParentForPid: SuitePidProbe): number;
|
|
258
258
|
export declare const setLiveSuiteCountForTests: (probe: (repoRoot: string) => Promise<number>) => void;
|
|
259
259
|
export declare const resetLiveSuiteCountForTests: () => void;
|
|
260
260
|
/** Live full-suite roots attributable to this repository or this daemon. Ancestors are excluded so
|
package/dist/run/daemon.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createRequire } from "node:module";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { batchedProcessProbes, runnerPids } from "./suite-census.js";
|
|
12
13
|
import { stringify } from "yaml";
|
|
13
14
|
import { BOOTSTRAP_FAILURE_RE, classifyDeadChannel, classifyTransientCapacity, trailerPattern, writePrompt } from "../adapters/prompt.js";
|
|
14
15
|
import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
|
|
@@ -1243,48 +1244,14 @@ async function observeWorkerProcessTree(marker, cwd) {
|
|
|
1243
1244
|
}
|
|
1244
1245
|
return tree.size === 0 ? "empty" : "running";
|
|
1245
1246
|
}
|
|
1246
|
-
function processCwd(pid) {
|
|
1247
|
-
try {
|
|
1248
|
-
return realpathSync(readlinkSync(`/proc/${pid}/cwd`));
|
|
1249
|
-
}
|
|
1250
|
-
catch { /* Darwin has no /proc */ }
|
|
1251
|
-
try {
|
|
1252
|
-
const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
|
|
1253
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
|
|
1254
|
-
});
|
|
1255
|
-
const path = out.split("\n").find((line) => line.startsWith("n"))?.slice(1);
|
|
1256
|
-
return path ? realpathSync(path) : undefined;
|
|
1257
|
-
}
|
|
1258
|
-
catch {
|
|
1259
|
-
return undefined;
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
function processSuiteParent(pid) {
|
|
1263
|
-
try {
|
|
1264
|
-
const env = readFileSync(`/proc/${pid}/environ`, "utf8").split("\0");
|
|
1265
|
-
const value = env.find((entry) => entry.startsWith(`${SUITE_PARENT_ENV}=`))?.slice(SUITE_PARENT_ENV.length + 1);
|
|
1266
|
-
return value && /^\d+$/.test(value) ? Number(value) : undefined;
|
|
1267
|
-
}
|
|
1268
|
-
catch { /* Darwin has no /proc process environments */ }
|
|
1269
|
-
try {
|
|
1270
|
-
const out = execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], {
|
|
1271
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
|
|
1272
|
-
});
|
|
1273
|
-
const value = new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(out)?.[1];
|
|
1274
|
-
return value ? Number(value) : undefined;
|
|
1275
|
-
}
|
|
1276
|
-
catch {
|
|
1277
|
-
return undefined;
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
1247
|
const pathAtOrBelow = (root, candidate) => {
|
|
1281
1248
|
const rel = relative(root, candidate);
|
|
1282
1249
|
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
1283
1250
|
};
|
|
1284
1251
|
/** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership
|
|
1285
1252
|
* rules remain testable on hosts that forbid process inspection; production supplies cwd and the
|
|
1286
|
-
* inherited TICKMARKR_SUITE_PARENT marker from
|
|
1287
|
-
export function countLiveSuites(snapshot, repoRoot, daemonPid
|
|
1253
|
+
* inherited TICKMARKR_SUITE_PARENT marker from one batched async probe (suite-census.ts, queue row 103). */
|
|
1254
|
+
export function countLiveSuites(snapshot, repoRoot, daemonPid, cwdForPid, suiteParentForPid) {
|
|
1288
1255
|
const rows = [];
|
|
1289
1256
|
for (const line of snapshot.split("\n")) {
|
|
1290
1257
|
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
@@ -1344,7 +1311,11 @@ export async function liveSuiteCount(repoRoot) {
|
|
|
1344
1311
|
if (liveSuiteCountForTests)
|
|
1345
1312
|
return liveSuiteCountForTests(repoRoot);
|
|
1346
1313
|
const snapshot = await shGit("ps -Aww -o pid=,ppid=,state=,command=", repoRoot, 15_000);
|
|
1347
|
-
|
|
1314
|
+
if (snapshot.code !== 0)
|
|
1315
|
+
return 0;
|
|
1316
|
+
// row 103: one batched async probe per snapshot — a synchronous lsof/ps per pid held this loop 44–46 s
|
|
1317
|
+
const probes = await batchedProcessProbes(runnerPids(snapshot.stdout));
|
|
1318
|
+
return countLiveSuites(snapshot.stdout, repoRoot, process.pid, probes.cwd, probes.suiteParent);
|
|
1348
1319
|
}
|
|
1349
1320
|
const OBSERVE_CHUNK_BYTES = 64 * 1024;
|
|
1350
1321
|
const OBSERVE_BUDGET_BYTES = 256 * 1024 * 1024;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue row 103 (D-1553): the live-suite census asks each runner-looking process for its cwd and its inherited
|
|
3
|
+
* TICKMARKR_SUITE_PARENT marker. It used to ask one pid at a time with a synchronous `lsof` and `ps`, which held the
|
|
4
|
+
* daemon's event loop 44–46 s under a full suite (the stall traces recorded for queue row 8). Here every pid is answered by /proc where it
|
|
5
|
+
* exists (Linux, child-free), else by ONE async `lsof` and ONE async `ps eww`, plus one more `lsof` for suite parents
|
|
6
|
+
* not already probed — the census asks for a parent's cwd too. The answers feed countLiveSuites' existing lookups.
|
|
7
|
+
*/
|
|
8
|
+
export type ProbeExec = (file: string, args: readonly string[]) => Promise<string>;
|
|
9
|
+
/** The pids of every live (non-zombie) row of a `ps -o pid=,ppid=,state=,command=` snapshot whose command is a runner. */
|
|
10
|
+
export declare function runnerPids(snapshot: string): number[];
|
|
11
|
+
/** The /proc reads, injectable so the Linux branch is testable anywhere; each throws where /proc has no entry. */
|
|
12
|
+
export interface ProcReader {
|
|
13
|
+
cwd(pid: number): string;
|
|
14
|
+
environ(pid: number): string;
|
|
15
|
+
}
|
|
16
|
+
export interface CensusProbes {
|
|
17
|
+
cwd: (pid: number) => string | undefined;
|
|
18
|
+
suiteParent: (pid: number) => number | undefined;
|
|
19
|
+
}
|
|
20
|
+
export declare function batchedProcessProbes(pids: readonly number[], exec?: ProbeExec, proc?: ProcReader): Promise<CensusProbes>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
3
|
+
import { isRunnerCommand } from "./lease.js";
|
|
4
|
+
import { SUITE_PARENT_ENV } from "./git.js";
|
|
5
|
+
// A pid that exits between the snapshot and the probe makes lsof/ps exit non-zero while still printing every other
|
|
6
|
+
// row, so an ordinary non-zero exit (a numeric code, no signal) keeps whatever was printed. D-1564: a timeout, a kill, a
|
|
7
|
+
// spawn failure or an output-cap overflow is no answer — the base's execFileSync threw there and discarded any partial
|
|
8
|
+
// output, so these answer nothing even when they buffered rows first.
|
|
9
|
+
// D-1561: execFile can also throw SYNCHRONOUSLY (EPERM, EAGAIN under fork pressure); the base's per-pid try/catch answered
|
|
10
|
+
// that as "unknown", so this probe never throws or rejects either — it answers nothing.
|
|
11
|
+
const probeExec = (file, args) => new Promise((resolve) => {
|
|
12
|
+
try {
|
|
13
|
+
execFile(file, [...args], { encoding: "utf8", timeout: 15_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => resolve(error && (error.killed || error.signal || typeof error.code !== "number") ? "" : String(stdout ?? "")));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
resolve("");
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
/** The pids of every live (non-zombie) row of a `ps -o pid=,ppid=,state=,command=` snapshot whose command is a runner. */
|
|
20
|
+
export function runnerPids(snapshot) {
|
|
21
|
+
const pids = [];
|
|
22
|
+
for (const line of snapshot.split("\n")) {
|
|
23
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
24
|
+
if (match && !match[3].startsWith("Z") && isRunnerCommand(match[4]))
|
|
25
|
+
pids.push(Number(match[1]));
|
|
26
|
+
}
|
|
27
|
+
return pids;
|
|
28
|
+
}
|
|
29
|
+
const procReader = {
|
|
30
|
+
cwd: (pid) => realpathSync(readlinkSync(`/proc/${pid}/cwd`)),
|
|
31
|
+
environ: (pid) => readFileSync(`/proc/${pid}/environ`, "utf8"),
|
|
32
|
+
};
|
|
33
|
+
export async function batchedProcessProbes(pids, exec = probeExec, proc = procReader) {
|
|
34
|
+
// the census never rejects, whatever exec does (queue row 103's closed case table, D-1561)
|
|
35
|
+
const ask = async (file, args) => { try {
|
|
36
|
+
return await exec(file, args);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return "";
|
|
40
|
+
} };
|
|
41
|
+
const cwds = new Map();
|
|
42
|
+
const parents = new Map();
|
|
43
|
+
const parentIn = (text) => new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(text)?.[1];
|
|
44
|
+
// D-1567 batch poisoning: lsof and ps fail the WHOLE command for one -p member they cannot take (a marker of 22 digits
|
|
45
|
+
// prints as 1e+21, 310 nines as Infinity; ps refuses one above int32), erasing every other pid's answer. The base probed
|
|
46
|
+
// per pid, so such a pid lost only its own answer: it stays unanswered here and never joins a batch.
|
|
47
|
+
const probeable = (pid) => Number.isSafeInteger(pid) && pid > 0 && pid <= 2_147_483_647;
|
|
48
|
+
const probeCwds = async (wanted) => {
|
|
49
|
+
const rest = [];
|
|
50
|
+
for (const pid of wanted.filter(probeable)) {
|
|
51
|
+
try {
|
|
52
|
+
cwds.set(pid, proc.cwd(pid));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
rest.push(pid);
|
|
56
|
+
} // Darwin has no /proc
|
|
57
|
+
}
|
|
58
|
+
if (rest.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
let pid;
|
|
61
|
+
for (const line of (await ask("lsof", ["-a", "-p", rest.join(","), "-d", "cwd", "-Fn"])).split("\n")) {
|
|
62
|
+
if (line.startsWith("p"))
|
|
63
|
+
pid = Number(line.slice(1));
|
|
64
|
+
else if (line.startsWith("n") && pid !== undefined) {
|
|
65
|
+
try {
|
|
66
|
+
cwds.set(pid, realpathSync(line.slice(1)));
|
|
67
|
+
}
|
|
68
|
+
catch { /* the directory is gone */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const probeParents = async (wanted) => {
|
|
73
|
+
const rest = [];
|
|
74
|
+
for (const pid of wanted.filter(probeable)) {
|
|
75
|
+
try {
|
|
76
|
+
// the base's exact rule (7c8009772 daemon.ts:1418-1423, D-1558): ONE NUL-delimited entry, its WHOLE value digits —
|
|
77
|
+
// a joined string would let another variable's text, or a value with trailing junk, pass for the marker
|
|
78
|
+
const value = proc.environ(pid).split("\0").find((entry) => entry.startsWith(`${SUITE_PARENT_ENV}=`))?.slice(SUITE_PARENT_ENV.length + 1);
|
|
79
|
+
if (value && /^\d+$/.test(value))
|
|
80
|
+
parents.set(pid, Number(value));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
rest.push(pid);
|
|
84
|
+
} // Darwin has no /proc process environments
|
|
85
|
+
}
|
|
86
|
+
if (rest.length === 0)
|
|
87
|
+
return;
|
|
88
|
+
for (const line of (await ask("ps", ["eww", "-p", rest.join(","), "-o", "pid=,command="])).split("\n")) {
|
|
89
|
+
const row = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
90
|
+
const value = row ? parentIn(row[2]) : undefined;
|
|
91
|
+
if (row && value)
|
|
92
|
+
parents.set(Number(row[1]), Number(value));
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const unique = [...new Set(pids)];
|
|
96
|
+
await probeCwds(unique);
|
|
97
|
+
await probeParents(unique);
|
|
98
|
+
await probeCwds([...new Set(parents.values())].filter((pid) => !cwds.has(pid)));
|
|
99
|
+
return { cwd: (pid) => cwds.get(pid), suiteParent: (pid) => parents.get(pid) };
|
|
100
|
+
}
|
|
@@ -44,6 +44,18 @@ function ledgerChannelId(line) {
|
|
|
44
44
|
const slash = head.indexOf("/");
|
|
45
45
|
return slash === -1 ? undefined : `${head.slice(0, slash)}:${head.slice(slash + 1)}`;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Space picks exactly as Enter does in every single-select overlay (operator field report 2026-10-08,
|
|
49
|
+
* "can't choose here space key doesn't select" — the reach picker even OPENS on Space). Space keeps its
|
|
50
|
+
* own meaning only where it has one: the multi-select chains (candidates, prefer) toggle on it, the text
|
|
51
|
+
* stages (add-model, the classify note) type it, and the review diff confirms only on `y`. The lift
|
|
52
|
+
* listing is a confirm, not a pick: it clears a deny entry for every channel that entry covers, so only
|
|
53
|
+
* Enter lifts — a stray Space there does nothing.
|
|
54
|
+
*/
|
|
55
|
+
const spacePicks = (overlay) => overlay.kind === "classify"
|
|
56
|
+
? overlay.stage !== "note"
|
|
57
|
+
: overlay.kind === "presets" || overlay.kind === "assign" || overlay.kind === "poolmode"
|
|
58
|
+
|| overlay.kind === "judge" || overlay.kind === "reach";
|
|
47
59
|
const REACH_CHOICES = [
|
|
48
60
|
{ id: "in", label: "in", gloss: "every seat may route here" },
|
|
49
61
|
{ id: "out-workers", label: "out · workers", gloss: "workers skip it; judge/review/consult unaffected" },
|
|
@@ -991,6 +1003,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
991
1003
|
return true;
|
|
992
1004
|
};
|
|
993
1005
|
if (overlay) {
|
|
1006
|
+
const pick = key.return || (input === " " && spacePicks(overlay));
|
|
994
1007
|
if (overlay.kind === "presets") {
|
|
995
1008
|
const rowCount = modeOptions.length + (overlay.home ? 1 : 0);
|
|
996
1009
|
if (key.escape) {
|
|
@@ -1015,7 +1028,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
1015
1028
|
bump();
|
|
1016
1029
|
return;
|
|
1017
1030
|
}
|
|
1018
|
-
if (
|
|
1031
|
+
if (pick) {
|
|
1019
1032
|
if (overlay.home && overlay.at === modeOptions.length) {
|
|
1020
1033
|
setOverlay(null); // custom → the browser IS the full editor
|
|
1021
1034
|
return;
|
|
@@ -1065,8 +1078,10 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
1065
1078
|
finish({ kind: "discard" });
|
|
1066
1079
|
return;
|
|
1067
1080
|
}
|
|
1081
|
+
// the review screen holds every staged edit by definition — `q` takes the same two presses
|
|
1082
|
+
// as every other screen, never one keystroke to lose the session (`n` is the explicit discard)
|
|
1068
1083
|
if (input === "q") {
|
|
1069
|
-
|
|
1084
|
+
guardedQuit(armed);
|
|
1070
1085
|
return;
|
|
1071
1086
|
}
|
|
1072
1087
|
if (input === "y") {
|
|
@@ -1097,7 +1112,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
1097
1112
|
bump();
|
|
1098
1113
|
return;
|
|
1099
1114
|
}
|
|
1100
|
-
if (
|
|
1115
|
+
if (pick) {
|
|
1101
1116
|
const channel = CHANNELS[overlay.channelAt];
|
|
1102
1117
|
ui.channelByAdapter[overlay.adapter] = channel;
|
|
1103
1118
|
// OBS-508: a bulk stage waiting on the one first-touch channel answer resumes here.
|
|
@@ -1125,7 +1140,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
1125
1140
|
bump();
|
|
1126
1141
|
return;
|
|
1127
1142
|
}
|
|
1128
|
-
if (
|
|
1143
|
+
if (pick) {
|
|
1129
1144
|
// OBS-508: keep the suggested band → the evidence note arrives pre-typed; override the
|
|
1130
1145
|
// band → the note starts empty (the suggestion argues for a DIFFERENT tier).
|
|
1131
1146
|
const chosen = TIERS[overlay.tierAt];
|
|
@@ -1208,7 +1223,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
1208
1223
|
bump();
|
|
1209
1224
|
return;
|
|
1210
1225
|
}
|
|
1211
|
-
if (
|
|
1226
|
+
if (pick && rows[overlay.at]) {
|
|
1212
1227
|
const shape = rows[overlay.at].id;
|
|
1213
1228
|
ui.map = { ...ui.map, [shape]: { pin: { via: overlay.adapter, model: overlay.model } } };
|
|
1214
1229
|
setOverlay(null);
|
|
@@ -1305,7 +1320,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
1305
1320
|
bump();
|
|
1306
1321
|
return;
|
|
1307
1322
|
}
|
|
1308
|
-
if (
|
|
1323
|
+
if (pick) {
|
|
1309
1324
|
const { shape, chain } = overlay.picker;
|
|
1310
1325
|
// a pool is the shape's WHOLE declaration: pin and prefer leave with it (schema
|
|
1311
1326
|
// exclusivity — pin+pool and pool+prefer both fail config load)
|
|
@@ -1383,7 +1398,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
1383
1398
|
bump();
|
|
1384
1399
|
return;
|
|
1385
1400
|
}
|
|
1386
|
-
if (
|
|
1401
|
+
if (pick) {
|
|
1387
1402
|
const choice = REACH_CHOICES[overlay.at].id;
|
|
1388
1403
|
setOverlay(null);
|
|
1389
1404
|
applyReach(overlay.target, choice);
|
|
@@ -1420,7 +1435,8 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
1420
1435
|
bump();
|
|
1421
1436
|
return;
|
|
1422
1437
|
}
|
|
1423
|
-
|
|
1438
|
+
// Space picks too (spacePicks): a judge picker that swallowed it read as "can't choose"
|
|
1439
|
+
if (pick && rows[overlay.at]) {
|
|
1424
1440
|
// single-select: one seat or back to the config default — never a chain
|
|
1425
1441
|
const picked = rows[overlay.at];
|
|
1426
1442
|
ui.judgeSeat = picked === judgeKeepRow ? null : picked;
|
|
@@ -2045,7 +2061,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
|
|
|
2045
2061
|
const rows = [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, ui.filter));
|
|
2046
2062
|
const { visible, start, above, below } = windowRows(rows, overlay.at, capacity);
|
|
2047
2063
|
const selectedSeat = ui.judgeSeat ?? judgeKeepRow;
|
|
2048
|
-
return (_jsxs(OverlayPanel, { title: "pick \u00B7 judge", width: bodyW, children: [_jsx(Text, { dimColor: true, children: "one seat judges acceptance criteria
|
|
2064
|
+
return (_jsxs(OverlayPanel, { title: "pick \u00B7 judge", width: bodyW, children: [_jsx(Text, { dimColor: true, wrap: "truncate", children: clip("Space or Enter picks · Esc keeps · one seat judges acceptance criteria — failover stays runtime (GATE-09)", bodyW - 4) }), _jsx(SearchRow, { filter: ui.filter, active: true }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((label, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: start + index === overlay.at }), label === selectedSeat ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: start + index === overlay.at, children: ` ${clip(label, bodyW - 10)}` })] }, label))), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
|
|
2049
2065
|
})();
|
|
2050
2066
|
const modelVisibilityLine = () => {
|
|
2051
2067
|
const hidden = hiddenModelCount();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tickmarkr",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Spec in, verified work out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -114,6 +114,9 @@
|
|
|
114
114
|
"scripts/export-public.sh",
|
|
115
115
|
"scripts/verify-export.sh",
|
|
116
116
|
"tests/scripts/verify-export.test.ts",
|
|
117
|
+
"scripts/check-analysis.mjs",
|
|
118
|
+
"scripts/atlas.mjs",
|
|
119
|
+
"tests/docs/analysis-library.test.ts",
|
|
117
120
|
".github/workflows/ci.yml",
|
|
118
121
|
"**/*.local.*"
|
|
119
122
|
]
|