tickmarkr 2.1.2 → 2.1.3
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/claude-code.js +18 -3
- package/dist/adapters/pi.d.ts +6 -0
- package/dist/adapters/pi.js +99 -18
- package/dist/cli/commands/beat.js +28 -8
- package/dist/cli/commands/init.js +28 -1
- package/dist/cli/commands/status.js +17 -15
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/gates/baseline.d.ts +10 -0
- package/dist/gates/baseline.js +46 -3
- package/dist/run/git.d.ts +5 -0
- package/dist/run/git.js +52 -4
- package/dist/run/lock.d.ts +1 -0
- package/dist/run/lock.js +23 -13
- package/dist/run/supervision.d.ts +9 -3
- package/dist/run/supervision.js +77 -21
- package/dist/tui/cockpit/derive.js +5 -10
- package/package.json +2 -2
- package/skills/tickmarkr-auto/SKILL.md +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +37 -14
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +79 -30
|
@@ -209,7 +209,7 @@ export const claudeCode = {
|
|
|
209
209
|
channels: (cfg) => channelsFromConfig("claude-code", cfg),
|
|
210
210
|
// v1.65 T3: every flag the command builders below hardcode — doctor checks `claude --help` still
|
|
211
211
|
// lists each (all present on claude 2.x, verified 2026-07-22). Advisory only, never routing.
|
|
212
|
-
hardcodedFlags: { binary: "claude", flags: ["-p", "--model", "--permission-mode", "--strict-mcp-config", "--mcp-config", "--output-format", "-r"] },
|
|
212
|
+
hardcodedFlags: { binary: "claude", flags: ["-p", "--model", "--permission-mode", "--strict-mcp-config", "--mcp-config", "--output-format", "-r", "--prompt-suggestions"] },
|
|
213
213
|
// --strict-mcp-config --mcp-config '{"mcpServers":{}}': pin the MCP surface to empty so fresh-worktree
|
|
214
214
|
// workers/gates don't load project .mcp.json servers (herdr scrapes dialogs as idle — v1.4 incident,
|
|
215
215
|
// memory tickmarkr-worker-mcp-dialog-stall). Live-verified 2026-07-10 on claude 2.1.205 (operator check):
|
|
@@ -223,10 +223,25 @@ export const claudeCode = {
|
|
|
223
223
|
// HYG-03 / OBS-137: the residual first-entry dialog is workspace trust, not MCP config loading.
|
|
224
224
|
// Claude's only store is global last-writer-wins ~/.claude.json, so tickmarkr still does not seed it;
|
|
225
225
|
// the daemon safely answers only the exact adapter-declared dialog once per slot.
|
|
226
|
-
|
|
226
|
+
//
|
|
227
|
+
// v2.1.3 T7 --prompt-suggestions false: claude paints a PREDICTED next prompt into the editor after
|
|
228
|
+
// a turn. On a pane it is pixel-identical to a seat's own unsubmitted draft, so every occupancy
|
|
229
|
+
// reader downstream — the input-box occupiedMatch above, the stall watchdog that sweeps for a
|
|
230
|
+
// parked turn — reads the vendor's ghost text as work in progress and waits out its window on a
|
|
231
|
+
// worker that is idle. Suppressed at the SOURCE instead: the two builders that seed a real terminal
|
|
232
|
+
// are exactly these (interactive, resume); the -p builder has no editor to paint into and is out of
|
|
233
|
+
// scope. Live-checked against claude 2.1.246: the flag parses and `false` is an allowed choice
|
|
234
|
+
// ("Allowed choices are true, false, 1, 0, yes, no, on, off").
|
|
235
|
+
// Placement is NOT free: --mcp-config above is variadic and eats the next positional (2026-07-10
|
|
236
|
+
// live check ate the prompt), and --prompt-suggestions takes an OPTIONAL value — appended directly
|
|
237
|
+
// before the prompt it would swallow it the same way. So the setting's value is always followed by
|
|
238
|
+
// another flag, never by the prompt positional.
|
|
239
|
+
interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
|
|
227
240
|
trustDialog: CLAUDE_TRUST_DIALOG,
|
|
228
241
|
inputBox: CLAUDE_INPUT_BOX,
|
|
229
|
-
|
|
242
|
+
// A resumed attempt lands in the same painted editor, so it carries the same ghost-text suppression
|
|
243
|
+
// and the same value-then-flag placement.
|
|
244
|
+
resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
|
|
230
245
|
invoke(task, _cwd, a, ctx) {
|
|
231
246
|
return { command: this.headlessCommand(ctx.promptFile, a.model) };
|
|
232
247
|
},
|
package/dist/adapters/pi.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import { type WorkerAdapter } from "./types.js";
|
|
2
2
|
export declare function parsePiModels(raw: string): string[];
|
|
3
|
+
export interface ServedModelDrift {
|
|
4
|
+
pinned: string;
|
|
5
|
+
served: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function readPiServedModels(): ServedModelDrift[];
|
|
8
|
+
export declare function servedModelNote(drifts?: ServedModelDrift[]): string;
|
|
3
9
|
export declare const pi: WorkerAdapter;
|
package/dist/adapters/pi.js
CHANGED
|
@@ -32,6 +32,92 @@ export function parsePiModels(raw) {
|
|
|
32
32
|
.map(([provider, model]) => `${provider}/${model}`)
|
|
33
33
|
.filter((id) => MODEL_ID_RE.test(id));
|
|
34
34
|
}
|
|
35
|
+
// Newest-first by mtime, capped — mtime picks WHICH files to scan, never a record's cursor.
|
|
36
|
+
// Shared by collectUsage (spend) and readPiServedModels (v2.1.3 T7): same store, same bounds.
|
|
37
|
+
function sessionFileStats(dir) {
|
|
38
|
+
return readdirSync(dir)
|
|
39
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
40
|
+
.map((f) => {
|
|
41
|
+
const path = join(dir, f);
|
|
42
|
+
try {
|
|
43
|
+
return { f: path, m: statSync(path).mtimeMs };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined; // a stat failure just drops that file
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
.filter((x) => x !== undefined);
|
|
50
|
+
}
|
|
51
|
+
function newestSessionFiles(entries) {
|
|
52
|
+
return [...entries].sort((a, b) => b.m - a.m).slice(0, MAX_SESSION_FILES).map((x) => x.f);
|
|
53
|
+
}
|
|
54
|
+
// ponytail: three distinct pairs is the note's ceiling — raise it if a real store ever shows more.
|
|
55
|
+
const MAX_DRIFT_PAIRS = 3;
|
|
56
|
+
export function readPiServedModels() {
|
|
57
|
+
const root = join(homedir(), ".pi", "agent", "sessions");
|
|
58
|
+
let files;
|
|
59
|
+
try {
|
|
60
|
+
files = newestSessionFiles(readdirSync(root).flatMap((slug) => {
|
|
61
|
+
try {
|
|
62
|
+
return sessionFileStats(join(root, slug));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return []; // not a directory / unreadable ⇒ skip
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return []; // no store ⇒ nothing to report
|
|
71
|
+
}
|
|
72
|
+
const out = [];
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = readFileSync(file, "utf8").slice(0, MAX_SESSION_BYTES);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const line of text.split("\n")) {
|
|
83
|
+
if (!line.trim())
|
|
84
|
+
continue;
|
|
85
|
+
let recRaw;
|
|
86
|
+
try {
|
|
87
|
+
recRaw = JSON.parse(line);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (recRaw === null || typeof recRaw !== "object" || Array.isArray(recRaw))
|
|
93
|
+
continue;
|
|
94
|
+
const rec = recRaw;
|
|
95
|
+
if (rec.type !== "message" || rec.message?.role !== "assistant")
|
|
96
|
+
continue;
|
|
97
|
+
const pinned = rec.message?.model;
|
|
98
|
+
const served = rec.message?.responseModel;
|
|
99
|
+
// absent responseModel === the two agree. Never a mismatch, never an alarm.
|
|
100
|
+
if (typeof served !== "string" || typeof pinned !== "string" || served === pinned)
|
|
101
|
+
continue;
|
|
102
|
+
if (!MODEL_ID_RE.test(served) || !MODEL_ID_RE.test(pinned))
|
|
103
|
+
continue;
|
|
104
|
+
const key = `${pinned}\u0000${served}`;
|
|
105
|
+
if (seen.has(key))
|
|
106
|
+
continue;
|
|
107
|
+
seen.add(key);
|
|
108
|
+
out.push({ pinned, served });
|
|
109
|
+
if (out.length >= MAX_DRIFT_PAIRS)
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
// The probe's note clause, or "" when the store reports no discrepancy at all.
|
|
116
|
+
export function servedModelNote(drifts = readPiServedModels()) {
|
|
117
|
+
if (drifts.length === 0)
|
|
118
|
+
return "";
|
|
119
|
+
return `served-model drift: ${drifts.map((d) => `pinned ${d.pinned} served ${d.served}`).join(", ")}`;
|
|
120
|
+
}
|
|
35
121
|
export const pi = {
|
|
36
122
|
id: "pi",
|
|
37
123
|
// FLEET-04: cross-vendor review honesty — GLM's provider (pi's own label is "zai"; either is
|
|
@@ -42,10 +128,17 @@ export const pi = {
|
|
|
42
128
|
const h = probeVersion("pi");
|
|
43
129
|
if (!h.installed)
|
|
44
130
|
return h;
|
|
131
|
+
// The store is local and independently useful: preserve its advisory even when the network/auth
|
|
132
|
+
// capability call below fails. A failed model listing must not erase evidence of a substitution.
|
|
133
|
+
const drift = servedModelNote();
|
|
45
134
|
const r = spawnSync("pi", ["--list-models"], { encoding: "utf8", timeout: 15000 });
|
|
46
|
-
if (r.error || r.status !== 0)
|
|
47
|
-
return h;
|
|
48
|
-
|
|
135
|
+
if (r.error || r.status !== 0) {
|
|
136
|
+
return drift ? { ...h, note: `${h.note ? `${h.note}; ` : ""}${drift}` } : h;
|
|
137
|
+
}
|
|
138
|
+
// v2.1.3 T7: the probe is the production caller that already reads this store, and doctor/fleet
|
|
139
|
+
// already render its note — so the served-model discrepancy is reported where it is already read.
|
|
140
|
+
const note = `auth verified via pi --list-models (free; auth-filtered by pi)${drift ? `; ${drift}` : ""}`;
|
|
141
|
+
return { ...h, servable: parsePiModels(r.stdout || ""), note };
|
|
49
142
|
},
|
|
50
143
|
channels: (cfg) => channelsFromConfig("pi", cfg),
|
|
51
144
|
// v1.65 T3: every flag the command builders below hardcode — verified in `pi --help` 2026-07-22.
|
|
@@ -84,25 +177,13 @@ export const pi = {
|
|
|
84
177
|
const real = realpathSync(cwd);
|
|
85
178
|
const slug = "-" + real.replaceAll("/", "-") + "--";
|
|
86
179
|
const dir = join(homedir(), ".pi", "agent", "sessions", slug);
|
|
87
|
-
const files =
|
|
88
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
89
|
-
.map((f) => {
|
|
90
|
-
try {
|
|
91
|
-
return { f, m: statSync(join(dir, f)).mtimeMs };
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
})
|
|
97
|
-
.filter((x) => x !== undefined)
|
|
98
|
-
.sort((a, b) => b.m - a.m)
|
|
99
|
-
.slice(0, MAX_SESSION_FILES);
|
|
180
|
+
const files = newestSessionFiles(sessionFileStats(dir));
|
|
100
181
|
let input = 0, output = 0, kept = false;
|
|
101
182
|
let cacheRead, cacheWrite;
|
|
102
|
-
for (const
|
|
183
|
+
for (const f of files) {
|
|
103
184
|
let text;
|
|
104
185
|
try {
|
|
105
|
-
text = readFileSync(
|
|
186
|
+
text = readFileSync(f, "utf8").slice(0, MAX_SESSION_BYTES);
|
|
106
187
|
}
|
|
107
188
|
catch {
|
|
108
189
|
continue;
|
|
@@ -20,31 +20,51 @@ import { SUPERVISION_BEAT_MS, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSuper
|
|
|
20
20
|
// evidence: stop calling it and the tier ages into STALE on its own.
|
|
21
21
|
export async function beat(argv, cwd = process.cwd()) {
|
|
22
22
|
const standDown = argv.includes("--stand-down");
|
|
23
|
-
const
|
|
23
|
+
const seat = seatOf(argv);
|
|
24
|
+
const named = argv.find((a, i) => !a.startsWith("--") && argv[i - 1] !== "--seat");
|
|
24
25
|
if (!isTier(named)) {
|
|
25
|
-
throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> [--stand-down] — got ${named ? `\`${named}\`` : "no tier"}`);
|
|
26
|
+
throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> [--stand-down] — got ${named ? `\`${named}\`` : "no tier"}`);
|
|
27
|
+
}
|
|
28
|
+
// SUP-05: NO SEAT, NO BEAT — and the refusal comes before any write, so a refused invocation leaves
|
|
29
|
+
// the tier exactly as it found it. This verb is one-shot: the pid it would otherwise record has
|
|
30
|
+
// already exited by the time anyone reads the record, so tier + pid + instant is a beat nobody can
|
|
31
|
+
// attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
|
|
32
|
+
// board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
|
|
33
|
+
// reads as coverage — worse than ABSENT, because ABSENT sends someone to look.
|
|
34
|
+
if (!seat) {
|
|
35
|
+
throw new Error(`${named} needs --seat <identity> — a beat that names no seat arms a tier nobody occupies` +
|
|
36
|
+
" (pass the seat's own pane id or agent name)");
|
|
26
37
|
}
|
|
27
38
|
if (standDown)
|
|
28
|
-
return standDownTier(cwd, named);
|
|
39
|
+
return standDownTier(cwd, named, seat);
|
|
29
40
|
// Clear a stand-down marker left by an earlier session BEFORE beating: a valid marker outranks every
|
|
30
41
|
// beat that does not strictly follow it, and two writes landing in the same millisecond do not. An
|
|
31
42
|
// uncleared marker would render DISARMED while this verb claimed ARMED, so the removal is unguarded
|
|
32
43
|
// too — `force` makes the ordinary "no marker" case a no-op, and anything else is a real failure.
|
|
33
44
|
rmSync(supervisionStandDownPath(cwd, named), { force: true, recursive: true });
|
|
34
|
-
beatSupervision(cwd, named);
|
|
35
|
-
return `${named} ARMED — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
|
|
45
|
+
beatSupervision(cwd, named, seat);
|
|
46
|
+
return `${named} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
|
|
47
|
+
}
|
|
48
|
+
/** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
|
|
49
|
+
function seatOf(argv) {
|
|
50
|
+
const inline = argv.find((a) => a.startsWith("--seat="))?.slice("--seat=".length);
|
|
51
|
+
const spaced = argv[argv.indexOf("--seat") + 1];
|
|
52
|
+
const seat = (inline ?? (argv.includes("--seat") ? spaced : undefined))?.trim();
|
|
53
|
+
return seat && !seat.startsWith("--") ? seat : undefined;
|
|
36
54
|
}
|
|
37
55
|
// Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
|
|
38
56
|
// so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
|
|
39
57
|
// renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
|
|
40
58
|
// deliberate hand-off as a death.
|
|
41
|
-
function standDownTier(repoRoot, tier) {
|
|
59
|
+
function standDownTier(repoRoot, tier, seat) {
|
|
42
60
|
tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
|
|
43
61
|
const p = supervisionStandDownPath(repoRoot, tier);
|
|
44
62
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
45
63
|
mkdirSync(dirname(p), { recursive: true });
|
|
46
|
-
|
|
64
|
+
// The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
|
|
65
|
+
// a hand-off anyone can act on, and on a seat tier the reader rejects an anonymous one outright.
|
|
66
|
+
writeFileSync(tmp, JSON.stringify({ tier, seat, pid: process.pid, disarmedAt: new Date().toISOString() }) + "\n");
|
|
47
67
|
renameSync(tmp, p);
|
|
48
|
-
return `${tier} DISARMED —
|
|
68
|
+
return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
|
|
49
69
|
}
|
|
50
70
|
const isTier = (v) => SUPERVISION_TIERS.includes(v ?? "");
|
|
@@ -109,7 +109,7 @@ Before \`tickmarkr compile\` or \`tickmarkr run\`: run \`tickmarkr version\`, re
|
|
|
109
109
|
|
|
110
110
|
### Tip-verify-before-green
|
|
111
111
|
|
|
112
|
-
A run is green only when the run-end event exists in the journal
|
|
112
|
+
A run is green only when ALL of these hold: the run-end event exists in the journal, tip verify is not "failed", AND the run-end summary's \`failed\`, \`human\`, \`blocked\` and \`pending\` buckets are every one of them empty. The shorter "run-end plus tip verify" form is NOT sufficient: a run ending \`done=[T1,T3,T4] human=[T2] tipVerify=passed\` is a closed PARTIAL run — three of four delivered, one parked — and calling it green is how a park becomes invisible. Never report green to the operator, tab titles, or records until every clause holds; when one does not, name the bucket that is not empty.
|
|
113
113
|
|
|
114
114
|
### Verified handoffs
|
|
115
115
|
|
|
@@ -203,6 +203,24 @@ function skillDrift(cwd) {
|
|
|
203
203
|
}
|
|
204
204
|
// Name what drifted. "stale" alone sends the reader to diff three directories by hand, and the file that
|
|
205
205
|
// matters is usually the one they never knew shipped.
|
|
206
|
+
// The GENERATED GUIDANCE BLOCK goes stale by exactly the shape skillDrift exists for: the package
|
|
207
|
+
// moves, the rendered copy does not, and nothing in the repo changes to hint at it. Worse than a skill,
|
|
208
|
+
// because a doc the host reads as law can be gitignored — this project's own AGENTS.md kept two
|
|
209
|
+
// superseded preflight rules (patch-version drift tolerated, a machine-wide process match required)
|
|
210
|
+
// through the very release that fixed the tracked copy, and a consultation was launched under the stale
|
|
211
|
+
// text with nobody able to see it. Same rule as skillDrift: a doc that was never written is not drift.
|
|
212
|
+
const docsDrift = (cwd) => hostTargets(cwd).map(({ docPath }) => docPath).filter((docPath) => {
|
|
213
|
+
if (!existsSync(docPath))
|
|
214
|
+
return false;
|
|
215
|
+
const current = readFileSync(docPath, "utf8");
|
|
216
|
+
const begin = current.indexOf(DOCS_BEGIN);
|
|
217
|
+
if (begin < 0)
|
|
218
|
+
return false; // no block installed here — --docs writes one, that is not drift
|
|
219
|
+
const end = current.indexOf(DOCS_END, begin);
|
|
220
|
+
if (end < 0)
|
|
221
|
+
return true; // a truncated block is stale by definition
|
|
222
|
+
return current.slice(begin, end + DOCS_END.length) !== AGENT_DOCS.trimEnd();
|
|
223
|
+
});
|
|
206
224
|
const describeDrift = (d) => [
|
|
207
225
|
d.missing.length ? `missing ${d.missing.join(", ")}` : "",
|
|
208
226
|
d.modified.length ? `modified ${d.modified.join(", ")}` : "",
|
|
@@ -361,6 +379,15 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
|
|
|
361
379
|
notes.push(`stale ${join(d.dir, "SKILL.md")} — ${describeDrift(d)}; run tickmarkr init --agent --force to refresh it`);
|
|
362
380
|
}
|
|
363
381
|
}
|
|
382
|
+
// Same reasoning, one layer up — and the refresh command is `--agent --force`, the same one the skill
|
|
383
|
+
// note names. `--docs` governs only the APPEND path for a repo with no block yet; replacing an existing
|
|
384
|
+
// block is gated on force (see the marked-region branch in installAgentFiles). Naming `--docs` here
|
|
385
|
+
// would send the reader to a flag that silently does nothing for the case they are in.
|
|
386
|
+
if (!(values.agent && values.force)) {
|
|
387
|
+
for (const docPath of docsDrift(cwd)) {
|
|
388
|
+
notes.push(`stale ${docPath} — its tickmarkr guidance block does not match this version; run tickmarkr init --agent --force to refresh it`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
364
391
|
const input = io.input ?? process.stdin;
|
|
365
392
|
const output = io.output ?? process.stdout;
|
|
366
393
|
const interactive = input.isTTY === true && output.isTTY === true && !(values.yes ?? false);
|
|
@@ -8,6 +8,7 @@ import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../.
|
|
|
8
8
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
9
9
|
import { foldActivity } from "../../run/activity.js";
|
|
10
10
|
import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
|
|
11
|
+
import { isPidLive } from "../../run/lock.js";
|
|
11
12
|
import { normalizeGateOutcome } from "../../run/outcome.js";
|
|
12
13
|
import { desiredPanes } from "../../run/reconcile.js";
|
|
13
14
|
import { normalizeStallSnapshot } from "../../run/stall.js";
|
|
@@ -617,6 +618,12 @@ const daemonPid = (events) => {
|
|
|
617
618
|
}
|
|
618
619
|
return undefined;
|
|
619
620
|
};
|
|
621
|
+
const daemonLiveness = (events) => {
|
|
622
|
+
const pid = daemonPid(events);
|
|
623
|
+
if (pid === undefined)
|
|
624
|
+
return { state: "unknown" };
|
|
625
|
+
return { state: isPidLive(pid) ? "alive" : "dead", pid };
|
|
626
|
+
};
|
|
620
627
|
const terminalFailureCause = (events) => {
|
|
621
628
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
622
629
|
const e = events[i];
|
|
@@ -792,27 +799,19 @@ const keepPanesSetting = (cwd) => {
|
|
|
792
799
|
return DEFAULT_CONFIG.visibility.keepPanes;
|
|
793
800
|
}
|
|
794
801
|
};
|
|
795
|
-
const liveness = (events, now = Date.now()) => {
|
|
802
|
+
const liveness = (events, daemon, now = Date.now()) => {
|
|
796
803
|
const last = events.at(-1);
|
|
797
804
|
if (!last)
|
|
798
805
|
return "last event unknown · daemon pid unknown";
|
|
799
806
|
const age = fmtAge(now - Date.parse(last.ts));
|
|
800
|
-
const pid = daemonPid(events);
|
|
801
807
|
const cause = terminalFailureCause(events);
|
|
802
|
-
if (
|
|
808
|
+
if (daemon.state === "unknown")
|
|
803
809
|
return `last event ${age} ago · daemon pid unknown${cause ? ` · ${cause}` : ""}`;
|
|
804
810
|
// a dead pid after run-end is a clean exit, not a crash — "dead" is only alarming (red) while
|
|
805
811
|
// the run is still incomplete (operator's crash indicator)
|
|
806
812
|
const ended = events.some((e) => e.event === "run-end");
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
process.kill(pid, 0);
|
|
810
|
-
state = "alive";
|
|
811
|
-
} // no throw ⇒ alive
|
|
812
|
-
catch (k) {
|
|
813
|
-
state = k.code === "ESRCH" ? (ended ? "finished" : "dead") : "alive";
|
|
814
|
-
} // EPERM ⇒ alive
|
|
815
|
-
return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
|
|
813
|
+
const state = daemon.state === "alive" ? "alive" : ended ? "finished" : "dead";
|
|
814
|
+
return `last event ${age} ago · daemon pid ${daemon.pid} ${state}${cause ? ` · ${cause}` : ""}`;
|
|
816
815
|
};
|
|
817
816
|
// Resume's shared comparator remains the fail-closed baseline. Status may additionally accept the
|
|
818
817
|
// daemon's audited graph-rehash release, but only when its `from` names that baseline identity (or
|
|
@@ -899,6 +898,9 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
899
898
|
const record = readRunRecord(cwd, g, namedRunId);
|
|
900
899
|
const runId = record?.runId;
|
|
901
900
|
const events = record?.events ?? [];
|
|
901
|
+
// The header and every live-phase consumer share this exact process reading. Proved death retires
|
|
902
|
+
// stale phase markers; unknown identity deliberately does not, preserving pre-identity journals.
|
|
903
|
+
const daemon = daemonLiveness(events);
|
|
902
904
|
const comparable = record?.comparable ?? false;
|
|
903
905
|
const rehashAt = record?.rehashAt;
|
|
904
906
|
const assignments = new Map();
|
|
@@ -972,7 +974,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
972
974
|
? workerPaneLocators(events, runId, keepPanesSetting(cwd))
|
|
973
975
|
: new Map();
|
|
974
976
|
const taskIds = new Set(g.tasks.map((task) => task.id));
|
|
975
|
-
const phases = comparable ? livePhases(events) : new Map();
|
|
977
|
+
const phases = comparable && daemon.state !== "dead" ? livePhases(events) : new Map();
|
|
976
978
|
for (const taskId of phases.keys())
|
|
977
979
|
if (!taskIds.has(taskId))
|
|
978
980
|
phases.delete(taskId);
|
|
@@ -1022,7 +1024,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1022
1024
|
});
|
|
1023
1025
|
const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
|
|
1024
1026
|
const header = runId
|
|
1025
|
-
? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
|
|
1027
|
+
? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, daemon, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
|
|
1026
1028
|
: `tickmarkr status${zone}${divider}no runs yet${divider}${done}/${total} done`;
|
|
1027
1029
|
const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
|
|
1028
1030
|
return {
|
|
@@ -1046,7 +1048,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1046
1048
|
const tally = tipPhase
|
|
1047
1049
|
? `${progressTone(`${progressGlyph} ${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
|
|
1048
1050
|
: done === total && total > 0 ? ok(`${done}/${total} done`) : information(`${done}/${total} done`);
|
|
1049
|
-
const live = liveness(events, now)
|
|
1051
|
+
const live = liveness(events, daemon, now)
|
|
1050
1052
|
.replace(/\bdead\b/u, fail("dead"))
|
|
1051
1053
|
.replace(/\bfinished\b/u, dim("finished"))
|
|
1052
1054
|
.replace(/\balive\b/u, running("alive"))
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type CommandResult = string | {
|
|
|
5
5
|
};
|
|
6
6
|
export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
|
|
7
7
|
export declare const COMMANDS: CommandMap;
|
|
8
|
-
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|overseer|watch (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
|
|
8
|
+
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
|
|
9
9
|
export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
|
|
10
10
|
out: string;
|
|
11
11
|
code: number;
|
package/dist/cli/index.js
CHANGED
|
@@ -43,7 +43,7 @@ usage: tickmarkr <command>
|
|
|
43
43
|
profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
|
|
44
44
|
ui open the Fleet Studio TUI (full-screen tabbed cockpit)
|
|
45
45
|
unlock remove a stale/garbage run lock (refuses if the holder is alive)
|
|
46
|
-
beat <tier> record one supervision beat for orchestrator|overseer|watch (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop
|
|
46
|
+
beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop
|
|
47
47
|
approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume`;
|
|
48
48
|
// pure, testable dispatcher: resolves a command, forwards argv, shapes the result — no side effects.
|
|
49
49
|
// unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `tickmarkr`); a handler throw becomes
|
package/dist/gates/baseline.d.ts
CHANGED
|
@@ -12,6 +12,12 @@ export interface BaselineCommand {
|
|
|
12
12
|
missingCommand?: boolean;
|
|
13
13
|
/** What this command actually took at capture, on a pristine tree. Absent in pre-v1.90 baselines. */
|
|
14
14
|
durationMs?: number;
|
|
15
|
+
/** Sum of the per-file durations named by the runner; null when its output names none. */
|
|
16
|
+
fileDurationSumMs?: number | null;
|
|
17
|
+
/** fileDurationSumMs / durationMs — average implied file concurrency, not a configured fork count. */
|
|
18
|
+
impliedParallelism?: number | null;
|
|
19
|
+
/** The slowest per-file entry named by the runner; null when per-file timing is unavailable. */
|
|
20
|
+
longestFile?: BaselineFileDuration | null;
|
|
15
21
|
/** The ceiling that measurement implies, persisted so every later battery uses the same number. */
|
|
16
22
|
ceilingMs?: number;
|
|
17
23
|
/**
|
|
@@ -20,6 +26,10 @@ export interface BaselineCommand {
|
|
|
20
26
|
*/
|
|
21
27
|
infra?: true;
|
|
22
28
|
}
|
|
29
|
+
export interface BaselineFileDuration {
|
|
30
|
+
file: string;
|
|
31
|
+
durationMs: number;
|
|
32
|
+
}
|
|
23
33
|
export interface Baseline {
|
|
24
34
|
commands: Record<string, BaselineCommand>;
|
|
25
35
|
warnings?: BaselineWarning[];
|
package/dist/gates/baseline.js
CHANGED
|
@@ -24,6 +24,12 @@ const SUMMARY_FAIL_RE = /^\s*(?:Tests?\s+(?:Files?\s+)?(?:\d+|#)\s+failed|#\s+fa
|
|
|
24
24
|
const ERROR_ANCHOR_RE = /^\s*(?:Error|[A-Za-z_$][\w$]*Error):\s+\S/;
|
|
25
25
|
const TSC_ERROR_RE = /^\s*\S.*\((?:\d+|#),(?:\d+|#)\):\s+error\s+[A-Z]+(?:\d+|#):/i; // tsc
|
|
26
26
|
const LINTER_ERROR_RE = /^\s*(?:\d+|#):(?:\d+|#)\s+error\s+\S/; // eslint stylish
|
|
27
|
+
// T5: package.json selecting oxlint's stylish format is this repository's local remedy because the
|
|
28
|
+
// existing LINTER_ERROR_RE already reads it. PATH_FIRST_LINTER_ERROR_RE is the shipped fix: it reads
|
|
29
|
+
// the default `path:line:column: error …` form for every user without requiring their script to change.
|
|
30
|
+
// Keep the path at line start — accepting a position mid-sentence turns ordinary runner prose into a
|
|
31
|
+
// manufactured fresh failure.
|
|
32
|
+
const PATH_FIRST_LINTER_ERROR_RE = /^\s*\S+:(?:\d+|#):(?:\d+|#):\s+error\b(?:\s+\S)?/;
|
|
27
33
|
// The failure shapes non-Vitest runners name their tests with: pytest's short summary
|
|
28
34
|
// (`FAILED tests/t.py::test_x - AssertionError`, `ERROR tests/t.py::fixture`), go test
|
|
29
35
|
// (`--- FAIL: TestFoo (0.00s)`), TAP / node:test (`not ok 1 - name`, whose summary line lands in
|
|
@@ -97,7 +103,7 @@ const namesFailureEitherForm = (l) => {
|
|
|
97
103
|
return stripped !== undefined && namesFailure(stripped);
|
|
98
104
|
};
|
|
99
105
|
const isFailureShaped = (l) => namesFailure(l) || SUMMARY_FAIL_RE.test(l) || ERROR_ANCHOR_RE.test(l)
|
|
100
|
-
|| TSC_ERROR_RE.test(l) || LINTER_ERROR_RE.test(l) || X_GLYPH_FAIL_RE.test(l);
|
|
106
|
+
|| TSC_ERROR_RE.test(l) || LINTER_ERROR_RE.test(l) || PATH_FIRST_LINTER_ERROR_RE.test(l) || X_GLYPH_FAIL_RE.test(l);
|
|
101
107
|
const VOCAB_RE = /\b(?:error|fail(?:ed|ure|ing)?)\b/i;
|
|
102
108
|
// T9 — the infra/regression discriminator. A runner that died because the MACHINE ran out of
|
|
103
109
|
// processes, file descriptors or memory never finished asking the question, so its nonzero exit is
|
|
@@ -128,6 +134,33 @@ export function classifyFailureOutput(output) {
|
|
|
128
134
|
return lines.some(isInfraLine) ? "infra" : undefined;
|
|
129
135
|
}
|
|
130
136
|
const normalizeLine = (l) => l.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
137
|
+
// Vitest's default reporter names file durations as
|
|
138
|
+
// `✓ |project| tests/example.test.ts (12 tests) 1.23s` (❯ for a red file). The runner may be
|
|
139
|
+
// turbo-prefixed, but a test-name timing has no parenthesized file tally and therefore cannot enter
|
|
140
|
+
// this measurement. These are observations of runner output, not a promise that every runner exposes
|
|
141
|
+
// them — callers record null, never zero, when no line matches.
|
|
142
|
+
const FILE_DURATION_RE = /^\s*(?:(?:[\w@./-]+:\s*)*)[✓✔×❯]\s+(?:\|[^|\r\n]+\|\s+)?(\S+)\s+\([^\r\n)]*\)\s+(\d+(?:\.\d+)?)\s*(ms|s|m)\b/;
|
|
143
|
+
const durationUnitMs = (unit) => unit === "m" ? 60_000 : unit === "s" ? 1_000 : 1;
|
|
144
|
+
function fileTiming(output, wallClockMs) {
|
|
145
|
+
const files = [];
|
|
146
|
+
for (const line of output.split("\n")) {
|
|
147
|
+
const match = FILE_DURATION_RE.exec(line.replace(ANSI_RE, ""));
|
|
148
|
+
if (!match)
|
|
149
|
+
continue;
|
|
150
|
+
const durationMs = Number(match[2]) * durationUnitMs(match[3]);
|
|
151
|
+
if (Number.isFinite(durationMs))
|
|
152
|
+
files.push({ file: match[1], durationMs });
|
|
153
|
+
}
|
|
154
|
+
if (!files.length)
|
|
155
|
+
return { fileDurationSumMs: null, impliedParallelism: null, longestFile: null };
|
|
156
|
+
const fileDurationSumMs = files.reduce((sum, entry) => sum + entry.durationMs, 0);
|
|
157
|
+
const longestFile = files.reduce((longest, entry) => entry.durationMs > longest.durationMs ? entry : longest);
|
|
158
|
+
return {
|
|
159
|
+
fileDurationSumMs,
|
|
160
|
+
impliedParallelism: wallClockMs > 0 ? fileDurationSumMs / wallClockMs : null,
|
|
161
|
+
longestFile,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
131
164
|
// A failing command whose output holds no shape any runner here names. The marker is content-free and
|
|
132
165
|
// constant: downstream consumers (tip verify journals fingerprint counts) still see that the command
|
|
133
166
|
// failed, while no line of the output — narration, status strip, stack trace — can enter the set. Being
|
|
@@ -363,16 +396,26 @@ export async function captureBaseline(cwd, commands) {
|
|
|
363
396
|
console.error(`tickmarkr: baseline capture for "${name}" was killed at its ${CAPTURE_CEILING_MS}ms ceiling — `
|
|
364
397
|
+ `it recorded NO fingerprints, so nothing is forgiven and every gate will treat a pre-existing `
|
|
365
398
|
+ `failure as a fresh one. Raise the ceiling or shorten the command.`);
|
|
366
|
-
base.commands[name] = {
|
|
399
|
+
base.commands[name] = {
|
|
400
|
+
infra: true,
|
|
401
|
+
fingerprints: [],
|
|
402
|
+
durationMs,
|
|
403
|
+
fileDurationSumMs: null,
|
|
404
|
+
impliedParallelism: null,
|
|
405
|
+
longestFile: null,
|
|
406
|
+
ceilingMs: effectiveCeilingMs({ durationMs }),
|
|
407
|
+
};
|
|
367
408
|
continue;
|
|
368
409
|
}
|
|
410
|
+
const raw = (r.stdout + "\n" + r.stderr).split(cwd).join("");
|
|
369
411
|
base.commands[name] = {
|
|
370
412
|
exitCode: r.code,
|
|
371
413
|
// a command that exits 0 has no failures to fingerprint — recording any would be a lie the
|
|
372
414
|
// compare step then has to forgive
|
|
373
|
-
fingerprints: r.code === 0 ? [] : fingerprint(
|
|
415
|
+
fingerprints: r.code === 0 ? [] : fingerprint(raw),
|
|
374
416
|
missingCommand: missingConfiguredCommand(cmd, r),
|
|
375
417
|
durationMs,
|
|
418
|
+
...fileTiming(raw, durationMs),
|
|
376
419
|
ceilingMs: effectiveCeilingMs({ durationMs }),
|
|
377
420
|
};
|
|
378
421
|
}
|
package/dist/run/git.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { ROUTING_ENV_SEAMS } from "../route/router.js";
|
|
2
3
|
export { ROUTING_ENV_SEAMS };
|
|
3
4
|
export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
|
|
@@ -42,6 +43,10 @@ export interface ShResult {
|
|
|
42
43
|
timedOut?: boolean;
|
|
43
44
|
durationMs?: number;
|
|
44
45
|
}
|
|
46
|
+
export declare const setSpawnForTests: (fn: typeof spawn) => void;
|
|
47
|
+
export declare const resetSpawnForTests: () => void;
|
|
48
|
+
export declare const SPAWN_ATTEMPT_LIMIT = 4;
|
|
49
|
+
export declare const SPAWN_RETRY_BACKOFF_MS = 50;
|
|
45
50
|
export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
|
|
46
51
|
export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
|
|
47
52
|
export declare function shOk(cmd: string, cwd: string): Promise<string>;
|
package/dist/run/git.js
CHANGED
|
@@ -64,6 +64,31 @@ export const runWithForkBudget = (concurrency, fn) => forkBudget.run(String(deri
|
|
|
64
64
|
export const resolvedForkCap = () => forkBudget.getStore() ?? DEFAULT_FORK_CAP;
|
|
65
65
|
/** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
|
|
66
66
|
export const DEFAULT_SHELL_TIMEOUT_MS = 600000;
|
|
67
|
+
/**
|
|
68
|
+
* OBS-688: every gate, every baseline capture and every tip verification reaches the machine through
|
|
69
|
+
* this one seam, and the seam took its spawn from the standard library directly — so the one failure
|
|
70
|
+
* it must handle, the kernel REFUSING the fork, was unreachable from a fixture and could only be
|
|
71
|
+
* described. It is injectable here for exactly that reason; production always holds `spawn` itself.
|
|
72
|
+
* Undefined-until-set, never captured at module load: the standard library binding stays read at
|
|
73
|
+
* CALL time, exactly as before this seam existed, so a suite that mocks `node:child_process` without
|
|
74
|
+
* a `spawn` export still imports this module (tests/adapters/pi-auth.test.ts does).
|
|
75
|
+
*/
|
|
76
|
+
let spawnChild;
|
|
77
|
+
export const setSpawnForTests = (fn) => { spawnChild = fn; };
|
|
78
|
+
export const resetSpawnForTests = () => { spawnChild = undefined; };
|
|
79
|
+
/**
|
|
80
|
+
* A refusal is NOT evidence about the work: the command never started, so nothing ran, and a retry
|
|
81
|
+
* cannot repeat a side effect. That argument is the whole safety case for retrying here, and it
|
|
82
|
+
* holds for exactly one closed case — the kernel refused the fork for a temporary resource shortage
|
|
83
|
+
* (EAGAIN: the fork table is full, which a gate burst does to a box twice in one night). Every
|
|
84
|
+
* other spawn error is a standing fact about the machine — a missing interpreter above all — and
|
|
85
|
+
* retrying it buys nothing while delaying every genuine failure by the whole backoff, so it returns
|
|
86
|
+
* on the first read. The retry is also gated on the child having produced NO byte and never having
|
|
87
|
+
* emitted `spawn`: past either, a command has run and re-running it is a side effect, never a retry.
|
|
88
|
+
*/
|
|
89
|
+
const RETRYABLE_SPAWN_CODE = "EAGAIN";
|
|
90
|
+
export const SPAWN_ATTEMPT_LIMIT = 4;
|
|
91
|
+
export const SPAWN_RETRY_BACKOFF_MS = 50;
|
|
67
92
|
// stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
|
|
68
93
|
// (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
|
|
69
94
|
function shell(cmd, cwd, timeoutMs, login) {
|
|
@@ -77,14 +102,14 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
77
102
|
// OBS-110: apply the run's own fork cap only when the operator has not already set one.
|
|
78
103
|
if (!(FORK_CAP_ENV in env))
|
|
79
104
|
env[FORK_CAP_ENV] = resolvedForkCap();
|
|
80
|
-
|
|
105
|
+
const attempt = () => new Promise((resolve) => {
|
|
81
106
|
const startedAt = Date.now();
|
|
82
107
|
// detached: bash gets its own process group so a timeout can kill the whole tree —
|
|
83
108
|
// SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
|
|
84
109
|
// open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
|
|
85
|
-
const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
110
|
+
const p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
86
111
|
let stdout = "", stderr = "";
|
|
87
|
-
let timedOut = false, done = false;
|
|
112
|
+
let timedOut = false, done = false, started = false;
|
|
88
113
|
const finish = (code, err) => {
|
|
89
114
|
if (done)
|
|
90
115
|
return;
|
|
@@ -101,14 +126,37 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
101
126
|
p.kill("SIGKILL");
|
|
102
127
|
}
|
|
103
128
|
}, timeoutMs);
|
|
129
|
+
p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
|
|
104
130
|
p.stdout.on("data", (d) => (stdout += d));
|
|
105
131
|
p.stderr.on("data", (d) => (stderr += d));
|
|
106
|
-
p.on("error", (e) =>
|
|
132
|
+
p.on("error", (e) => {
|
|
133
|
+
if (!done && !started && !stdout && !stderr && e.code === RETRYABLE_SPAWN_CODE) {
|
|
134
|
+
done = true;
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
resolve({ refused: e });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
finish(127, String(e));
|
|
140
|
+
});
|
|
107
141
|
p.on("close", (code) => finish(code ?? 1));
|
|
108
142
|
// "close" waits for stdio to drain; a surviving pipe-holder must not outlive the timeout
|
|
109
143
|
p.on("exit", (code) => { if (timedOut)
|
|
110
144
|
finish(code ?? 1); });
|
|
111
145
|
});
|
|
146
|
+
return (async () => {
|
|
147
|
+
const startedAt = Date.now();
|
|
148
|
+
for (let n = 1;; n++) {
|
|
149
|
+
const r = await attempt();
|
|
150
|
+
if (!("refused" in r))
|
|
151
|
+
return r;
|
|
152
|
+
// Bounded, and the bound is what makes a persisting shortage a REPORTED failure rather than a
|
|
153
|
+
// wedged daemon: past it the caller gets the refusal's own text under exit 127, as before.
|
|
154
|
+
if (n >= SPAWN_ATTEMPT_LIMIT) {
|
|
155
|
+
return { code: 127, stdout: "", stderr: String(r.refused), durationMs: Date.now() - startedAt };
|
|
156
|
+
}
|
|
157
|
+
await new Promise((wake) => setTimeout(wake, SPAWN_RETRY_BACKOFF_MS * n));
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
112
160
|
}
|
|
113
161
|
export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
|
|
114
162
|
return shell(cmd, cwd, timeoutMs, true);
|