tickmarkr 1.72.0 → 1.73.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/cli/commands/approve.js +21 -3
- package/dist/cli/commands/resume.js +10 -2
- package/dist/cli/commands/status.d.ts +2 -0
- package/dist/cli/commands/status.js +219 -30
- package/dist/cli/commands/ui.d.ts +6 -2
- package/dist/cli/commands/ui.js +2 -4
- package/dist/config/config.d.ts +4 -0
- package/dist/config/config.js +6 -1
- package/dist/drivers/herdr.d.ts +4 -0
- package/dist/drivers/herdr.js +72 -8
- package/dist/drivers/subprocess.d.ts +3 -3
- package/dist/drivers/subprocess.js +10 -5
- package/dist/route/preference.d.ts +2 -1
- package/dist/route/preference.js +8 -2
- package/dist/run/daemon.d.ts +1 -0
- package/dist/run/daemon.js +139 -4
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +55 -0
- package/dist/run/journal.d.ts +6 -1
- package/dist/run/journal.js +28 -1
- package/dist/tui/ink/components.d.ts +18 -0
- package/dist/tui/ink/components.js +30 -2
- package/dist/tui/ink/studio-app.d.ts +59 -0
- package/dist/tui/ink/studio-app.js +320 -0
- package/package.json +1 -1
- package/dist/tui/app.d.ts +0 -72
- package/dist/tui/app.js +0 -334
- package/dist/tui/engine.d.ts +0 -52
- package/dist/tui/engine.js +0 -92
- package/dist/tui/frame.d.ts +0 -22
- package/dist/tui/frame.js +0 -56
- package/dist/tui/input.d.ts +0 -41
- package/dist/tui/input.js +0 -100
- package/dist/tui/views/consult-dossier.d.ts +0 -49
- package/dist/tui/views/consult-dossier.js +0 -169
- package/dist/tui/views/diff-modal.d.ts +0 -9
- package/dist/tui/views/diff-modal.js +0 -19
- package/dist/tui/views/fleet-view.d.ts +0 -91
- package/dist/tui/views/fleet-view.js +0 -337
- package/dist/tui/views/preview-view.d.ts +0 -7
- package/dist/tui/views/preview-view.js +0 -80
- package/dist/tui/views/profile-view.d.ts +0 -15
- package/dist/tui/views/profile-view.js +0 -93
- package/dist/tui/views/routing-view.d.ts +0 -48
- package/dist/tui/views/routing-view.js +0 -234
- package/dist/tui/views/runs-view.d.ts +0 -70
- package/dist/tui/views/runs-view.js +0 -387
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { userInfo } from "node:os";
|
|
2
|
-
import {
|
|
2
|
+
import { GATE_NAMES } from "../../graph/schema.js";
|
|
3
|
+
import { ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal } from "../../run/journal.js";
|
|
3
4
|
// GATE-08 (v1.12): approve a parked human gate so the next `tickmarkr resume <runId>` dispatches it.
|
|
4
5
|
//
|
|
5
6
|
// The approval is a JOURNAL EVENT (task-approved) carrying who and when — it touches ONLY the
|
|
@@ -31,15 +32,32 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
31
32
|
}
|
|
32
33
|
// OBS-18: only the most recent task-human for this task decides whether this approval grants a
|
|
33
34
|
// fresh attempt budget. The closed daemon-issued kind, never a human prose string, controls release.
|
|
34
|
-
const
|
|
35
|
+
const events = journal.read();
|
|
36
|
+
let lastHumanIndex = -1;
|
|
37
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
38
|
+
if (events[i].event === "task-human" && events[i].taskId === taskId) {
|
|
39
|
+
lastHumanIndex = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const lastHuman = events[lastHumanIndex];
|
|
35
44
|
const capPark = lastHuman?.data.kind === ATTEMPT_CAP_RELEASE;
|
|
45
|
+
const gateFailPark = lastHuman?.data.kind === "gate-fail";
|
|
46
|
+
const failedGate = gateFailPark
|
|
47
|
+
? events.slice(0, lastHumanIndex).reverse().find((e) => e.event === "gate-result" && e.taskId === taskId && e.data.pass === false
|
|
48
|
+
&& typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate))?.data.gate
|
|
49
|
+
: undefined;
|
|
50
|
+
if (gateFailPark && !failedGate) {
|
|
51
|
+
throw new Error(`task ${taskId} is parked on gate-fail but has no failed gate result — refusing to infer one`);
|
|
52
|
+
}
|
|
36
53
|
journal.append("task-approved", taskId, {
|
|
37
54
|
by,
|
|
38
55
|
...(reason ? { reason } : {}),
|
|
39
56
|
via: "cli",
|
|
40
57
|
...(capPark ? { release: ATTEMPT_CAP_RELEASE } : {}),
|
|
58
|
+
...(failedGate ? { release: GATE_SATISFIED_RELEASE, gate: failedGate } : {}),
|
|
41
59
|
});
|
|
42
|
-
return `approved ${taskId} in ${runId} — by ${by}; run \`tickmarkr resume ${runId}\` to dispatch it`;
|
|
60
|
+
return `approved ${taskId} in ${runId} — by ${by}; run \`tickmarkr resume ${runId}\` to ${failedGate ? "continue past the approved gate" : "dispatch it"}`;
|
|
43
61
|
}
|
|
44
62
|
// hand-parsed argv — no CLI framework (house style). Flags --by <name> and --reason <text>; positionals
|
|
45
63
|
// are runId then taskId. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
|
|
@@ -8,17 +8,25 @@ const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.b
|
|
|
8
8
|
export async function resume(argv, cwd = process.cwd()) {
|
|
9
9
|
const runId = argv[0];
|
|
10
10
|
if (!runId)
|
|
11
|
-
throw new Error("usage: tickmarkr resume <run-id>");
|
|
11
|
+
throw new Error("usage: tickmarkr resume <run-id> [--graph-changed] [--retry-failed]");
|
|
12
12
|
// T3: --graph-changed is the operator's audited release of the engagement-identity guard (Sol #2 /
|
|
13
13
|
// Fable F2) — the daemon refuses a mismatched/unbound journal unless this is set, then journals a
|
|
14
14
|
// graph-rehash event naming both hashes. Strip the flag before runId resolution so a bare id still wins.
|
|
15
15
|
const graphChanged = argv.includes("--graph-changed");
|
|
16
|
+
const retryFailed = argv.includes("--retry-failed");
|
|
16
17
|
const cfg = loadConfig(cwd);
|
|
17
18
|
const collisions = denyPreferCollisions(cfg);
|
|
18
19
|
if (collisions.length) {
|
|
19
20
|
throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
|
|
20
21
|
}
|
|
21
|
-
const s = await runDaemon(cwd, {
|
|
22
|
+
const s = await runDaemon(cwd, {
|
|
23
|
+
runId,
|
|
24
|
+
resume: true,
|
|
25
|
+
graphChanged,
|
|
26
|
+
retryFailed,
|
|
27
|
+
driver: pickDriver(cfg),
|
|
28
|
+
narrate: (event) => console.log(formatJournalNarration(event)),
|
|
29
|
+
});
|
|
22
30
|
const out = `resumed ${s.runId} — ${formatSummary(s)}`;
|
|
23
31
|
return { out, code: summaryGreen(s) ? 0 : 2 };
|
|
24
32
|
}
|
|
@@ -3,6 +3,8 @@ import { type JournalEvent } from "../../run/journal.js";
|
|
|
3
3
|
export type StatusOpts = {
|
|
4
4
|
iterations?: number;
|
|
5
5
|
sleep?: (ms: number) => Promise<void>;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
readWorkerOutput?: (taskId: string, attempt: number, runId: string) => Promise<string | undefined>;
|
|
6
8
|
};
|
|
7
9
|
export declare const GATE_KEYS: {
|
|
8
10
|
readonly build: "B";
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
|
|
2
|
+
import { HerdrDriver } from "../../drivers/herdr.js";
|
|
3
|
+
import { formatOwnedName } from "../../drivers/types.js";
|
|
2
4
|
import { blockedTasks, graphDefinitionHash, loadGraph } from "../../graph/graph.js";
|
|
3
5
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
4
6
|
import { foldActivity } from "../../run/activity.js";
|
|
5
7
|
import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
|
|
8
|
+
import { normalizeStallSnapshot } from "../../run/stall.js";
|
|
6
9
|
// ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
|
|
7
10
|
const REFRESH_MS = 2000;
|
|
8
11
|
const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
|
|
@@ -10,6 +13,87 @@ const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states n
|
|
|
10
13
|
// first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
|
|
11
14
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
15
|
export const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
|
|
16
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
17
|
+
const ASCII_SPINNER = ["|", "/", "-", "\\"];
|
|
18
|
+
const SAVE_TERMINAL_TITLE = "\x1b[22;0t";
|
|
19
|
+
const RESTORE_TERMINAL_TITLE = "\x1b[23;0t";
|
|
20
|
+
const taskPhase = (value) => {
|
|
21
|
+
if (value === "worker" || value === "gates" || value === "judge" || value === "review" || value === "merge")
|
|
22
|
+
return value;
|
|
23
|
+
if (typeof value !== "string" || !value.startsWith("gate:"))
|
|
24
|
+
return undefined;
|
|
25
|
+
return GATE_NAMES.includes(value.slice("gate:".length)) ? value : undefined;
|
|
26
|
+
};
|
|
27
|
+
const phaseGate = (phase) => {
|
|
28
|
+
if (phase === "judge")
|
|
29
|
+
return "acceptance";
|
|
30
|
+
if (phase === "review")
|
|
31
|
+
return "review";
|
|
32
|
+
return phase.startsWith("gate:") ? phase.slice("gate:".length) : undefined;
|
|
33
|
+
};
|
|
34
|
+
// A phase is live only between its append-only start marker and the matching real outcome. A newer
|
|
35
|
+
// start for the same task supersedes the prior phase (gates → named gate, worker retry → worker).
|
|
36
|
+
// No clock participates in this fold: time only decorates the derived live interval at render time.
|
|
37
|
+
const livePhases = (events) => {
|
|
38
|
+
const live = new Map();
|
|
39
|
+
for (let order = 0; order < events.length; order++) {
|
|
40
|
+
const event = events[order];
|
|
41
|
+
if (event.event === "run-start" || event.event === "run-resume" || event.event === "run-end" || event.event === "superseded") {
|
|
42
|
+
live.clear();
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (event.event === "phase-start" && event.taskId) {
|
|
46
|
+
const phase = taskPhase(event.data.phase);
|
|
47
|
+
const startedAt = Date.parse(event.ts);
|
|
48
|
+
const attempt = typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt) && event.data.attempt >= 0
|
|
49
|
+
? event.data.attempt
|
|
50
|
+
: 0;
|
|
51
|
+
if (phase && Number.isFinite(startedAt))
|
|
52
|
+
live.set(event.taskId, { taskId: event.taskId, phase, startedAt, order, attempt });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!event.taskId)
|
|
56
|
+
continue;
|
|
57
|
+
const active = live.get(event.taskId);
|
|
58
|
+
if (!active)
|
|
59
|
+
continue;
|
|
60
|
+
const gate = phaseGate(active.phase);
|
|
61
|
+
const matched = (active.phase === "worker" && event.event === "worker-result")
|
|
62
|
+
|| (active.phase === "gates" && event.event === "gate-result")
|
|
63
|
+
|| (gate !== undefined && event.event === "gate-result" && event.data.gate === gate)
|
|
64
|
+
|| (active.phase === "merge" && event.event === "merge")
|
|
65
|
+
|| event.event === "task-done"
|
|
66
|
+
|| event.event === "task-failed"
|
|
67
|
+
|| event.event === "task-human"
|
|
68
|
+
|| event.event === "task-approved"
|
|
69
|
+
|| event.event === "task-dispatch";
|
|
70
|
+
if (matched)
|
|
71
|
+
live.delete(event.taskId);
|
|
72
|
+
}
|
|
73
|
+
return live;
|
|
74
|
+
};
|
|
75
|
+
const phaseLabel = (phase) => phase.startsWith("gate:") ? `gate ${phase.slice("gate:".length)}` : phase;
|
|
76
|
+
const fmtElapsed = (ms) => {
|
|
77
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
78
|
+
if (seconds < 60)
|
|
79
|
+
return `${seconds}s`;
|
|
80
|
+
const minutes = Math.floor(seconds / 60);
|
|
81
|
+
if (minutes < 60)
|
|
82
|
+
return `${minutes}m${seconds % 60}s`;
|
|
83
|
+
const hours = Math.floor(minutes / 60);
|
|
84
|
+
return `${hours}h${minutes % 60}m${seconds % 60}s`;
|
|
85
|
+
};
|
|
86
|
+
const workerOutputAge = (phase, now, worker) => now - (worker?.phaseStartedAt === phase.startedAt && worker.hasOutput ? worker.lastOutputAt : phase.startedAt);
|
|
87
|
+
const phaseDetail = (phase, now, worker) => {
|
|
88
|
+
const elapsed = fmtElapsed(now - phase.startedAt);
|
|
89
|
+
if (phase.phase !== "worker")
|
|
90
|
+
return `${phaseLabel(phase.phase)} · ${elapsed} elapsed`;
|
|
91
|
+
const age = fmtElapsed(workerOutputAge(phase, now, worker));
|
|
92
|
+
const output = worker?.phaseStartedAt === phase.startedAt && worker.hasOutput
|
|
93
|
+
? `last output ${age} ago`
|
|
94
|
+
: `no output ${age}`;
|
|
95
|
+
return `${phaseLabel(phase.phase)} · ${elapsed} elapsed · ${output}`;
|
|
96
|
+
};
|
|
13
97
|
const attemptStartIdx = (events, taskId) => {
|
|
14
98
|
let idx = -1;
|
|
15
99
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -114,11 +198,11 @@ const terminalFailureCause = (events) => {
|
|
|
114
198
|
}
|
|
115
199
|
return undefined;
|
|
116
200
|
};
|
|
117
|
-
const liveness = (events) => {
|
|
201
|
+
const liveness = (events, now = Date.now()) => {
|
|
118
202
|
const last = events.at(-1);
|
|
119
203
|
if (!last)
|
|
120
204
|
return "last event unknown · daemon pid unknown";
|
|
121
|
-
const age = fmtAge(
|
|
205
|
+
const age = fmtAge(now - Date.parse(last.ts));
|
|
122
206
|
const pid = daemonPid(events);
|
|
123
207
|
const cause = terminalFailureCause(events);
|
|
124
208
|
if (pid === undefined)
|
|
@@ -136,7 +220,7 @@ const liveness = (events) => {
|
|
|
136
220
|
} // EPERM ⇒ alive
|
|
137
221
|
return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
|
|
138
222
|
};
|
|
139
|
-
const renderFrame = (cwd) => {
|
|
223
|
+
const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map()) => {
|
|
140
224
|
const g = loadGraph(cwd);
|
|
141
225
|
const runId = Journal.latestRunId(cwd, { withJournal: true });
|
|
142
226
|
const assignments = new Map();
|
|
@@ -179,6 +263,12 @@ const renderFrame = (cwd) => {
|
|
|
179
263
|
const width = process.stdout.columns ?? 120;
|
|
180
264
|
const done = effective.tasks.filter((t) => t.status === "done").length;
|
|
181
265
|
const ended = comparable && runHasEnded(events);
|
|
266
|
+
const taskIds = new Set(g.tasks.map((task) => task.id));
|
|
267
|
+
const phases = comparable ? livePhases(events) : new Map();
|
|
268
|
+
for (const taskId of phases.keys())
|
|
269
|
+
if (!taskIds.has(taskId))
|
|
270
|
+
phases.delete(taskId);
|
|
271
|
+
const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
|
|
182
272
|
const cells = g.tasks.map((t) => {
|
|
183
273
|
const st = replayed?.get(t.id) ?? t.status;
|
|
184
274
|
const failureKind = comparable ? recordedTaskFailureKind(events, t.id) : undefined;
|
|
@@ -188,27 +278,34 @@ const renderFrame = (cwd) => {
|
|
|
188
278
|
const redTier = terminal && (ended
|
|
189
279
|
|| (failureKind !== undefined && isQualityFailureParkKind(failureKind))
|
|
190
280
|
|| (st === "failed" && failureKind === undefined));
|
|
191
|
-
const
|
|
192
|
-
const
|
|
281
|
+
const livePhase = phases.get(t.id);
|
|
282
|
+
const isStarved = !livePhase && starved.has(t.id);
|
|
283
|
+
const phrase = livePhase ? phaseDetail(livePhase, now, workerLiveness.get(t.id)) : isStarved ? undefined : activity.cells.get(t.id);
|
|
193
284
|
const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
|
|
194
285
|
const channel = assignments.get(t.id) ?? "-";
|
|
195
286
|
const ctx = contexts.get(t.id);
|
|
196
287
|
const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
|
|
197
|
-
return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
|
|
288
|
+
return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
|
|
198
289
|
});
|
|
199
290
|
if (!unicode) {
|
|
200
|
-
// machine/CI surface —
|
|
201
|
-
|
|
291
|
+
// machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
|
|
292
|
+
// use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
|
|
293
|
+
const rows = cells.map(({ t, st, label, assignCol, livePhase, states }) => {
|
|
202
294
|
const chain = gateChain(states, false);
|
|
203
|
-
const prefix = ` ${taskBox(st)} ${t.id} `;
|
|
204
|
-
const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
|
|
295
|
+
const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${taskBox(st)} ${t.id} `;
|
|
296
|
+
const suffix = ` ${chain} ${livePhase ? "running" : String(st)}${label} ${assignCol}`;
|
|
205
297
|
return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
|
|
206
298
|
});
|
|
207
299
|
const header = runId
|
|
208
|
-
? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
|
|
300
|
+
? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
|
|
209
301
|
: `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
|
|
210
302
|
const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
|
|
211
|
-
return
|
|
303
|
+
return {
|
|
304
|
+
content: [header, legendLine, ...rows].join("\n"),
|
|
305
|
+
...(hotPhase ? { hotPhase } : {}),
|
|
306
|
+
...(runId ? { runId } : {}),
|
|
307
|
+
workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
|
|
308
|
+
};
|
|
212
309
|
}
|
|
213
310
|
// TTY: cockpit frame composed through src/brand.ts (CLI-DESIGN.md) — dominant run title,
|
|
214
311
|
// dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
|
|
@@ -219,7 +316,7 @@ const renderFrame = (cwd) => {
|
|
|
219
316
|
const gaugeCells = 10;
|
|
220
317
|
const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
|
|
221
318
|
const gauge = (fill ? (anyFailed ? fail : ok)("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
|
|
222
|
-
const live = liveness(events)
|
|
319
|
+
const live = liveness(events, now)
|
|
223
320
|
.replace(/\bdead\b/, fail("dead"))
|
|
224
321
|
.replace(/\bfinished\b/, dim("finished"))
|
|
225
322
|
.replace(/\balive\b/, ok("alive"))
|
|
@@ -241,7 +338,7 @@ const renderFrame = (cwd) => {
|
|
|
241
338
|
// aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
|
|
242
339
|
// and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
|
|
243
340
|
const taskVerdict = (c) => c.st === "done" ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
|
|
244
|
-
const statusWord = (c) => c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
|
|
341
|
+
const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
|
|
245
342
|
const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
|
|
246
343
|
// plain-text status suffixes for width math only — starved / failed gate names / approval hint
|
|
247
344
|
const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
|
|
@@ -251,9 +348,11 @@ const renderFrame = (cwd) => {
|
|
|
251
348
|
const goalW = Math.max(8, ...goals.map((s) => s.length));
|
|
252
349
|
const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
|
|
253
350
|
const rows = cells.map((c, i) => {
|
|
254
|
-
const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx } = c;
|
|
351
|
+
const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx, livePhase } = c;
|
|
255
352
|
const word = statusWord(c);
|
|
256
|
-
const
|
|
353
|
+
const staleWorker = livePhase?.phase === "worker"
|
|
354
|
+
&& workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
|
|
355
|
+
const stWord = staleWorker ? warn(word) : st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
|
|
257
356
|
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
258
357
|
const f = failedGates(states);
|
|
259
358
|
const human = humanGateSuffix(t, st, states);
|
|
@@ -261,7 +360,10 @@ const renderFrame = (cwd) => {
|
|
|
261
360
|
(isStarved ? dot + fail("starved") : "") +
|
|
262
361
|
(f.length ? dot + fail(f.join(", ")) : "") +
|
|
263
362
|
(human ? dot + warn("awaiting approval") : "");
|
|
264
|
-
const
|
|
363
|
+
const taskLabel = `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`;
|
|
364
|
+
const line1 = livePhase
|
|
365
|
+
? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
|
|
366
|
+
: ` ${statusRow(taskVerdict(c), taskLabel)}`;
|
|
265
367
|
// activity already names its channel for in-flight attempts — never repeat it
|
|
266
368
|
const detail = [
|
|
267
369
|
...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
|
|
@@ -272,28 +374,115 @@ const renderFrame = (cwd) => {
|
|
|
272
374
|
}).flat();
|
|
273
375
|
if (!nowLine.length)
|
|
274
376
|
rows.pop(); // cards end blank-separated; drop the dangling one
|
|
275
|
-
return
|
|
377
|
+
return {
|
|
378
|
+
content: [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n"),
|
|
379
|
+
...(hotPhase ? { hotPhase } : {}),
|
|
380
|
+
...(runId ? { runId } : {}),
|
|
381
|
+
workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
|
|
382
|
+
};
|
|
276
383
|
};
|
|
277
384
|
export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
278
385
|
// cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
|
|
279
|
-
if (!argv.includes("--watch"))
|
|
280
|
-
|
|
386
|
+
if (!argv.includes("--watch")) {
|
|
387
|
+
const { content } = renderFrame(cwd, opts.now?.() ?? Date.now());
|
|
388
|
+
return visual() ? BANNER + content : content;
|
|
389
|
+
}
|
|
281
390
|
const iterations = opts.iterations ?? Infinity;
|
|
282
391
|
const sleep = opts.sleep ?? defaultSleep;
|
|
392
|
+
const now = opts.now ?? Date.now;
|
|
283
393
|
const bounded = Number.isFinite(iterations);
|
|
284
394
|
const frames = [];
|
|
285
395
|
const sep = "\n---\n";
|
|
286
396
|
const tty = visual();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
397
|
+
const workerLiveness = new Map();
|
|
398
|
+
const herdr = HerdrDriver.available() ? new HerdrDriver() : undefined;
|
|
399
|
+
const readWorkerOutput = opts.readWorkerOutput ?? (herdr
|
|
400
|
+
? async (taskId, attempt, runId) => {
|
|
401
|
+
const name = formatOwnedName({ role: "worker", taskId, attempt, runId });
|
|
402
|
+
try {
|
|
403
|
+
return await herdr.read({ id: name, name, cwd }, 80);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
: undefined);
|
|
410
|
+
const observeWorkerOutput = async (active, runId, observedAt) => {
|
|
411
|
+
const activeIds = new Set(active.map((phase) => phase.taskId));
|
|
412
|
+
for (const taskId of workerLiveness.keys())
|
|
413
|
+
if (!activeIds.has(taskId))
|
|
414
|
+
workerLiveness.delete(taskId);
|
|
415
|
+
if (!readWorkerOutput || !runId)
|
|
416
|
+
return;
|
|
417
|
+
await Promise.all(active.map(async (phase) => {
|
|
418
|
+
const output = await readWorkerOutput(phase.taskId, phase.attempt, runId);
|
|
419
|
+
if (output === undefined)
|
|
420
|
+
return;
|
|
421
|
+
const snapshot = normalizeStallSnapshot(output);
|
|
422
|
+
const prior = workerLiveness.get(phase.taskId);
|
|
423
|
+
if (!prior || prior.phaseStartedAt !== phase.startedAt) {
|
|
424
|
+
const hasOutput = snapshot.trim().length > 0;
|
|
425
|
+
workerLiveness.set(phase.taskId, {
|
|
426
|
+
phaseStartedAt: phase.startedAt,
|
|
427
|
+
snapshot,
|
|
428
|
+
hasOutput,
|
|
429
|
+
lastOutputAt: hasOutput ? observedAt : phase.startedAt,
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (snapshot !== prior.snapshot && snapshot.trim().length > 0) {
|
|
434
|
+
prior.hasOutput = true;
|
|
435
|
+
prior.lastOutputAt = observedAt;
|
|
436
|
+
}
|
|
437
|
+
prior.snapshot = snapshot;
|
|
438
|
+
}));
|
|
439
|
+
};
|
|
440
|
+
let titleSaved = false;
|
|
441
|
+
const restoreTitle = () => {
|
|
442
|
+
if (!titleSaved)
|
|
443
|
+
return;
|
|
444
|
+
titleSaved = false;
|
|
445
|
+
process.stdout.write(RESTORE_TERMINAL_TITLE);
|
|
446
|
+
};
|
|
447
|
+
const updateTitle = (hotPhase, nowMs) => {
|
|
448
|
+
if (!hotPhase) {
|
|
449
|
+
if (titleSaved) {
|
|
450
|
+
process.removeListener("exit", restoreTitle);
|
|
451
|
+
restoreTitle();
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!titleSaved) {
|
|
456
|
+
process.stdout.write(SAVE_TERMINAL_TITLE);
|
|
457
|
+
titleSaved = true;
|
|
458
|
+
process.once("exit", restoreTitle);
|
|
459
|
+
}
|
|
460
|
+
process.stdout.write(`\x1b]0;⏳ ${hotPhase.taskId} ${phaseLabel(hotPhase.phase)} ${fmtElapsed(nowMs - hotPhase.startedAt)}\x07`);
|
|
461
|
+
};
|
|
462
|
+
try {
|
|
463
|
+
for (let i = 0; i < iterations; i++) {
|
|
464
|
+
const nowMs = now();
|
|
465
|
+
const frame = renderFrame(cwd, nowMs, i, workerLiveness);
|
|
466
|
+
if (tty) {
|
|
467
|
+
updateTitle(frame.hotPhase, nowMs);
|
|
468
|
+
process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
process.stdout.write(frame.content + sep);
|
|
472
|
+
}
|
|
473
|
+
if (bounded)
|
|
474
|
+
frames.push(frame.content);
|
|
475
|
+
if (i + 1 < iterations) {
|
|
476
|
+
await observeWorkerOutput(frame.workerPhases, frame.runId, nowMs);
|
|
477
|
+
await sleep(REFRESH_MS);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
if (titleSaved) {
|
|
483
|
+
process.removeListener("exit", restoreTitle);
|
|
484
|
+
restoreTitle();
|
|
485
|
+
}
|
|
297
486
|
}
|
|
298
487
|
return bounded ? frames.join(sep) : "";
|
|
299
488
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
type StudioIO = {
|
|
2
|
+
input: NodeJS.ReadStream;
|
|
3
|
+
output: NodeJS.WriteStream;
|
|
4
|
+
};
|
|
5
|
+
export declare function ui(_argv: string[], io?: Partial<StudioIO>): Promise<string | {
|
|
3
6
|
out: string;
|
|
4
7
|
code: number;
|
|
5
8
|
}>;
|
|
9
|
+
export {};
|
package/dist/cli/commands/ui.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { StudioApp } from "../../tui/app.js";
|
|
2
1
|
const NON_TTY_MSG = "tickmarkr ui: studio requires a TTY — use `tickmarkr fleet --print` or `tickmarkr status --watch` for line-mode output";
|
|
3
2
|
export async function ui(_argv, io = {}) {
|
|
4
3
|
const input = io.input ?? process.stdin;
|
|
@@ -6,8 +5,7 @@ export async function ui(_argv, io = {}) {
|
|
|
6
5
|
if (input.isTTY !== true || output.isTTY !== true) {
|
|
7
6
|
return { out: NON_TTY_MSG, code: 1 };
|
|
8
7
|
}
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
await app.exited;
|
|
8
|
+
const { runStudioInk } = await import("../../tui/ink/studio-app.js");
|
|
9
|
+
await runStudioInk({ input, output });
|
|
12
10
|
return "ui: closed";
|
|
13
11
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -109,6 +109,10 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
109
109
|
deny: z.ZodOptional<z.ZodObject<{
|
|
110
110
|
adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
111
111
|
models: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
112
|
+
workers: z.ZodOptional<z.ZodObject<{
|
|
113
|
+
adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
114
|
+
models: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
115
|
+
}, z.core.$strip>>;
|
|
112
116
|
}, z.core.$strip>>;
|
|
113
117
|
}, z.core.$strip>;
|
|
114
118
|
tiers: z.ZodRecord<z.ZodString, z.ZodObject<{
|
package/dist/config/config.js
CHANGED
|
@@ -51,6 +51,9 @@ const PrefBlockSchema = z.object({
|
|
|
51
51
|
adapters: z.array(z.string()).optional(),
|
|
52
52
|
models: z.array(z.string()).optional(),
|
|
53
53
|
});
|
|
54
|
+
const DenyBlockSchema = PrefBlockSchema.extend({
|
|
55
|
+
workers: PrefBlockSchema.optional(),
|
|
56
|
+
});
|
|
54
57
|
// v1.20 REC-02: operator-maintained price table for the usage/cost report (src/report/cost.ts). Two
|
|
55
58
|
// channel economics, never conflated (spec cost model): API = tokens × per-Mtok rate; sub = flat plan
|
|
56
59
|
// amortized over a windows/month RANGE (+ an API-equivalent counterfactual when the model has a rate).
|
|
@@ -125,7 +128,7 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
125
128
|
// Pre-v1.21 doctor.json lacks per-model verdicts. Keep legacy unknown-is-routable behavior opt-in.
|
|
126
129
|
allowUnverifiedModels: z.boolean(),
|
|
127
130
|
allow: PrefBlockSchema.optional(),
|
|
128
|
-
deny:
|
|
131
|
+
deny: DenyBlockSchema.optional(),
|
|
129
132
|
}),
|
|
130
133
|
tiers: z.record(z.string(), TierEntrySchema),
|
|
131
134
|
pricing: z.record(z.string(), z.number()),
|
|
@@ -471,6 +474,8 @@ export function configTemplate(overlay) {
|
|
|
471
474
|
# deny: # optional fleet denylist; deny beats allow on conflict
|
|
472
475
|
# models:
|
|
473
476
|
# - pi:zai/glm-5.2 # OBS-57: pi passes run-start probe but hangs at finish without TICKMARKR_RESULT — remove after no-trailer demotion ships (v1.46 provider-outage taxonomy)
|
|
477
|
+
# workers: # optional worker-only deny; flat adapters/models still deny every role
|
|
478
|
+
# models: [kimi:kimi-code/k3]
|
|
474
479
|
# # incident-born deny/pin entries MUST name OBS id + root cause + removal condition (see docs/codebase/CONVENTIONS.md)
|
|
475
480
|
# # entry grammar: adapter id | model id | adapter:model (entries in either list accept all three forms)
|
|
476
481
|
# # a hint pinning a denied channel FAILS at plan time (RoutingError) — never silent reroute
|
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -12,12 +12,16 @@ export declare class HerdrDriver implements ExecutorDriver {
|
|
|
12
12
|
private groups;
|
|
13
13
|
private groupSerial;
|
|
14
14
|
private deliverySerial;
|
|
15
|
+
private dispatchLeases;
|
|
16
|
+
private deliveredPanes;
|
|
15
17
|
private ws;
|
|
16
18
|
private callerPane;
|
|
17
19
|
private watches;
|
|
18
20
|
constructor(bin?: string, workersPerTab?: number);
|
|
19
21
|
private serial;
|
|
20
22
|
private deliveryQueue;
|
|
23
|
+
private reserveDispatch;
|
|
24
|
+
private verifyPaneIdentityBinding;
|
|
21
25
|
private deliveryMatches;
|
|
22
26
|
static available(): boolean;
|
|
23
27
|
private herdr;
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -25,8 +25,11 @@ export class HerdrDriver {
|
|
|
25
25
|
// grouped slot()/close() mutate shared group state across awaits — serialize them so two
|
|
26
26
|
// concurrent first members can never both create the group tab (mergeSerial idiom, daemon.ts)
|
|
27
27
|
groupSerial = Promise.resolve();
|
|
28
|
-
// OBS-
|
|
28
|
+
// OBS-120: canonical dispatch slots hold this chain from allocation through verified binding and
|
|
29
|
+
// delivery. Legacy/manual slots still serialize each delivery on the same chain (OBS-119).
|
|
29
30
|
deliverySerial = Promise.resolve();
|
|
31
|
+
dispatchLeases = new WeakMap();
|
|
32
|
+
deliveredPanes = new WeakMap();
|
|
30
33
|
// VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
|
|
31
34
|
// operator's env before the driver is built). Required at slot() time, never in the constructor —
|
|
32
35
|
// pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
|
|
@@ -47,6 +50,41 @@ export class HerdrDriver {
|
|
|
47
50
|
this.deliverySerial = p.catch(() => undefined);
|
|
48
51
|
return p;
|
|
49
52
|
}
|
|
53
|
+
// OBS-120: ExecutorDriver exposes slot() and run() separately, so a canonical tickmarkr slot
|
|
54
|
+
// carries a lease across that API seam. The next dispatch cannot allocate until this slot's first
|
|
55
|
+
// run either delivers or fails. Allocation errors release immediately; no failed dispatch poisons
|
|
56
|
+
// the queue. The daemon calls run() directly after preparing the command for every owned slot.
|
|
57
|
+
reserveDispatch(allocate) {
|
|
58
|
+
let release;
|
|
59
|
+
const held = new Promise((resolve) => { release = resolve; });
|
|
60
|
+
const start = this.deliverySerial.then(() => undefined, () => undefined);
|
|
61
|
+
this.deliverySerial = start.then(() => held);
|
|
62
|
+
return start.then(async () => {
|
|
63
|
+
let slot;
|
|
64
|
+
try {
|
|
65
|
+
slot = await allocate();
|
|
66
|
+
this.dispatchLeases.set(slot, { release });
|
|
67
|
+
return slot;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (slot) {
|
|
71
|
+
try {
|
|
72
|
+
await this.close(slot);
|
|
73
|
+
}
|
|
74
|
+
catch { /* allocation already failed closed; cleanup is best-effort */ }
|
|
75
|
+
}
|
|
76
|
+
release();
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async verifyPaneIdentityBinding(slot) {
|
|
82
|
+
const paneId = await this.namedPaneId(slot.name);
|
|
83
|
+
if (paneId !== slot.id) {
|
|
84
|
+
throw new Error(`herdr pane identity binding verification failed for ${slot.name}: allocated ${slot.id}, resolved ${paneId ?? "none"} — refusing delivery`);
|
|
85
|
+
}
|
|
86
|
+
return paneId;
|
|
87
|
+
}
|
|
50
88
|
// ponytail: narrow panes hard-wrap the input line — collapse whitespace before comparing.
|
|
51
89
|
deliveryMatches(transcript, cmd) {
|
|
52
90
|
const norm = (s) => s.replace(/\s+/g, "");
|
|
@@ -78,8 +116,12 @@ export class HerdrDriver {
|
|
|
78
116
|
return null;
|
|
79
117
|
}
|
|
80
118
|
}
|
|
81
|
-
//
|
|
119
|
+
// Before delivery, resolve fresh via the durable name because pane ids can compact. After delivery,
|
|
120
|
+
// pin the verified target so early liveness cannot drift to a label rebound onto another pane.
|
|
82
121
|
async paneId(slot) {
|
|
122
|
+
const delivered = this.deliveredPanes.get(slot);
|
|
123
|
+
if (delivered)
|
|
124
|
+
return delivered;
|
|
83
125
|
return await this.namedPaneId(slot.name) ?? slot.id;
|
|
84
126
|
}
|
|
85
127
|
// VIS-09 P43-03: runtime width for the layout gate (43-MEASUREMENT.md licensing condition 2).
|
|
@@ -104,12 +146,18 @@ export class HerdrDriver {
|
|
|
104
146
|
// reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
|
|
105
147
|
// those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
|
|
106
148
|
const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
|
|
149
|
+
const allocate = opts?.group
|
|
150
|
+
? () => this.serial(() => this.groupSlot(cwd, resolved, opts.group))
|
|
151
|
+
: () => this.tabSlot(cwd, resolved, opts?.label);
|
|
152
|
+
// Production dispatch names are canonical even when the gate call site supplies the already-
|
|
153
|
+
// formatted name rather than SlotOpts.owned. Hold one lease across slot() → run(); legacy/manual
|
|
154
|
+
// slots retain their existing allocation-only semantics for compatibility.
|
|
155
|
+
if (parseOwnedName(resolved))
|
|
156
|
+
return this.reserveDispatch(allocate);
|
|
107
157
|
// group wins if both are set (a group tab is already stage-labeled; passing both is a caller bug).
|
|
108
158
|
// label (without group) → dedicated labeled tab via tabSlot's third param: no groups-map entry, no
|
|
109
159
|
// refcount, no groupSerial, no degrade latch — dedicated tabs have no shared state to guard (SUP-01).
|
|
110
|
-
|
|
111
|
-
return this.serial(() => this.groupSlot(cwd, resolved, opts.group));
|
|
112
|
-
return this.tabSlot(cwd, resolved, opts?.label); // label undefined → defaults to name (today's behavior)
|
|
160
|
+
return allocate(); // label undefined → defaults to name (today's behavior)
|
|
113
161
|
}
|
|
114
162
|
// today's per-slot tab path, plus the VIS-04 orphan reap
|
|
115
163
|
// label defaults to the slot name; group tabs pass the STAGE name instead — a first-member label
|
|
@@ -320,10 +368,26 @@ export class HerdrDriver {
|
|
|
320
368
|
// cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
|
|
321
369
|
// transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
|
|
322
370
|
async run(slot, cmd) {
|
|
323
|
-
|
|
371
|
+
const lease = this.dispatchLeases.get(slot);
|
|
372
|
+
if (lease) {
|
|
373
|
+
this.dispatchLeases.delete(slot);
|
|
374
|
+
try {
|
|
375
|
+
const paneId = await this.verifyPaneIdentityBinding(slot);
|
|
376
|
+
await this.deliver(slot, cmd, paneId);
|
|
377
|
+
this.deliveredPanes.set(slot, paneId);
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
lease.release();
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
return this.deliveryQueue(async () => {
|
|
385
|
+
const paneId = await this.paneId(slot);
|
|
386
|
+
await this.deliver(slot, cmd, paneId);
|
|
387
|
+
this.deliveredPanes.set(slot, paneId);
|
|
388
|
+
});
|
|
324
389
|
}
|
|
325
|
-
async deliver(slot, cmd) {
|
|
326
|
-
const pane = await this.paneId(slot);
|
|
390
|
+
async deliver(slot, cmd, pane) {
|
|
327
391
|
let transcript = "";
|
|
328
392
|
for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
|
|
329
393
|
if (attempt > 0) {
|