@linzumi/cli 1.0.16 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +311 -309
- package/package.json +1 -1
- package/scripts/analyze_enter_to_typing_latency.mjs +147 -0
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Enter->typing latency analyzer for a runner log (the measurement infra
|
|
3
|
+
// behind the 2026-07-02 follow-up-latency RCA; see
|
|
4
|
+
// test/enterToTypingHeadOfLine.test.ts for the fix it motivated).
|
|
5
|
+
//
|
|
6
|
+
// For every real-thread follow-up dispatch it pairs `kandan.message_queued`
|
|
7
|
+
// with the next `codex.turn_starting` on the same thread and classifies the
|
|
8
|
+
// window:
|
|
9
|
+
// - silent : NO other runner activity for the thread in between.
|
|
10
|
+
// Pre-fix this was the head-of-line block on the
|
|
11
|
+
// `queued` message_state server ack; post-fix it
|
|
12
|
+
// should be ~0ms.
|
|
13
|
+
// - drain_blocked/* : a logged `kandan.queue_drain_blocked` gate
|
|
14
|
+
// (turn_not_idle = user sent mid-turn, etc.). These
|
|
15
|
+
// are legitimate waits, not latency bugs.
|
|
16
|
+
// - cold_spawn : a `runner.thread_process_starting` fired in the
|
|
17
|
+
// window (worker cold respawn).
|
|
18
|
+
//
|
|
19
|
+
// Usage:
|
|
20
|
+
// node scripts/analyze_enter_to_typing_latency.mjs [path-to-runner-log]
|
|
21
|
+
// Default log path: ~/.linzumi/logs/linzumi-runner.log
|
|
22
|
+
//
|
|
23
|
+
// Ran against a production runner log on 2026-07-02 this reported, over
|
|
24
|
+
// 5439 follow-up dispatches (UUID-threaded; includes local vitest threads
|
|
25
|
+
// with synthetic UUIDs, which are all in the fast bucket): 204 slower than
|
|
26
|
+
// 500ms, of which 158 were `silent` (p50 1343ms / p90 3119ms / p99 11928ms),
|
|
27
|
+
// 46 were drain_blocked/turn_not_idle, and 0 were cold spawns - the evidence
|
|
28
|
+
// that the dominant Enter->typing latency was the awaited status-paint ack,
|
|
29
|
+
// not worker respawn.
|
|
30
|
+
import { createReadStream } from 'node:fs';
|
|
31
|
+
import { createInterface } from 'node:readline';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
const logPath =
|
|
36
|
+
process.argv[2] ??
|
|
37
|
+
path.join(homedir(), '.linzumi', 'logs', 'linzumi-runner.log');
|
|
38
|
+
const slowThresholdMs = Number(process.env.SLOW_THRESHOLD_MS ?? 500);
|
|
39
|
+
|
|
40
|
+
// Real Linzumi thread ids are UUIDs; test threads use readable slugs.
|
|
41
|
+
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
42
|
+
|
|
43
|
+
function percentile(sorted, q) {
|
|
44
|
+
if (sorted.length === 0) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const threads = new Map();
|
|
51
|
+
const rows = [];
|
|
52
|
+
|
|
53
|
+
const rl = createInterface({
|
|
54
|
+
input: createReadStream(logPath),
|
|
55
|
+
crlfDelay: Infinity,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
for await (const line of rl) {
|
|
59
|
+
if (line.length > 20_000) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let entry;
|
|
63
|
+
try {
|
|
64
|
+
entry = JSON.parse(line);
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const threadId =
|
|
69
|
+
entry.thread_id ??
|
|
70
|
+
entry.kandanThreadId ??
|
|
71
|
+
entry.linzumi_thread_id ??
|
|
72
|
+
entry.threadId ??
|
|
73
|
+
entry.kandan_thread_id;
|
|
74
|
+
if (typeof threadId !== 'string' || !uuidRe.test(threadId)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const atMs = Date.parse(entry.ts);
|
|
78
|
+
if (Number.isNaN(atMs)) {
|
|
79
|
+
// A malformed/absent timestamp would otherwise mint a NaN-latency row
|
|
80
|
+
// that silently falls out of both the slow and fast buckets, making the
|
|
81
|
+
// printed totals diverge from the dispatch count.
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
let state = threads.get(threadId);
|
|
85
|
+
if (state === undefined) {
|
|
86
|
+
state = { pending: null };
|
|
87
|
+
threads.set(threadId, state);
|
|
88
|
+
}
|
|
89
|
+
const event = entry.event;
|
|
90
|
+
if (event === 'kandan.message_queued') {
|
|
91
|
+
state.pending = { atMs, seq: entry.seq, blocked: [], spawn: false };
|
|
92
|
+
} else if (state.pending !== null) {
|
|
93
|
+
if (event === 'codex.turn_starting') {
|
|
94
|
+
rows.push({
|
|
95
|
+
threadId,
|
|
96
|
+
ts: entry.ts,
|
|
97
|
+
ms: atMs - state.pending.atMs,
|
|
98
|
+
blocked: state.pending.blocked,
|
|
99
|
+
spawn: state.pending.spawn,
|
|
100
|
+
});
|
|
101
|
+
state.pending = null;
|
|
102
|
+
} else if (event === 'kandan.queue_drain_blocked') {
|
|
103
|
+
state.pending.blocked.push(entry.reason);
|
|
104
|
+
} else if (event === 'runner.thread_process_starting') {
|
|
105
|
+
state.pending.spawn = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const slow = rows.filter((row) => row.ms > slowThresholdMs);
|
|
111
|
+
const silent = slow.filter((row) => row.blocked.length === 0 && !row.spawn);
|
|
112
|
+
const spawns = slow.filter((row) => row.spawn);
|
|
113
|
+
const blockedCounts = new Map();
|
|
114
|
+
for (const row of slow) {
|
|
115
|
+
for (const reason of new Set(row.blocked)) {
|
|
116
|
+
blockedCounts.set(reason, (blockedCounts.get(reason) ?? 0) + 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const silentSorted = silent.map((row) => row.ms).sort((a, b) => a - b);
|
|
121
|
+
const fast = rows.filter((row) => row.ms <= slowThresholdMs);
|
|
122
|
+
const fastSorted = fast.map((row) => row.ms).sort((a, b) => a - b);
|
|
123
|
+
|
|
124
|
+
console.log(`log: ${logPath}`);
|
|
125
|
+
console.log(
|
|
126
|
+
`follow-up dispatches=${rows.length} slow(>${slowThresholdMs}ms)=${slow.length}`
|
|
127
|
+
);
|
|
128
|
+
console.log(
|
|
129
|
+
` silent (head-of-line on paint ack pre-fix)=${silent.length}` +
|
|
130
|
+
(silentSorted.length > 0
|
|
131
|
+
? ` p50=${percentile(silentSorted, 0.5)}ms p90=${percentile(silentSorted, 0.9)}ms p99=${percentile(silentSorted, 0.99)}ms max=${silentSorted[silentSorted.length - 1]}ms`
|
|
132
|
+
: '')
|
|
133
|
+
);
|
|
134
|
+
for (const [reason, count] of blockedCounts) {
|
|
135
|
+
console.log(` drain_blocked/${reason}=${count}`);
|
|
136
|
+
}
|
|
137
|
+
console.log(` cold_spawn=${spawns.length}`);
|
|
138
|
+
console.log(
|
|
139
|
+
`fast(<=${slowThresholdMs}ms)=${fast.length}` +
|
|
140
|
+
(fastSorted.length > 0 ? ` p50=${percentile(fastSorted, 0.5)}ms` : '')
|
|
141
|
+
);
|
|
142
|
+
if (silent.length > 0) {
|
|
143
|
+
console.log('\nslowest silent windows:');
|
|
144
|
+
for (const row of [...silent].sort((a, b) => b.ms - a.ms).slice(0, 10)) {
|
|
145
|
+
console.log(` ${row.ts} thread=${row.threadId.slice(0, 8)} ${row.ms}ms`);
|
|
146
|
+
}
|
|
147
|
+
}
|