@vellumai/assistant 0.12.4-dev.202609232215.634e2d2 → 0.12.4-dev.202609232312.d1432a4
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/package.json +1 -1
- package/src/daemon/event-loop-watchdog.test.ts +19 -0
- package/src/daemon/event-loop-watchdog.ts +16 -2
- package/src/monitoring/__tests__/proc-fixtures.ts +28 -0
- package/src/monitoring/__tests__/proc-wait-state.test.ts +12 -13
- package/src/monitoring/__tests__/process-usage.test.ts +243 -0
- package/src/monitoring/proc-wait-state.ts +54 -7
- package/src/monitoring/process-memory.ts +4 -3
- package/src/monitoring/process-usage.ts +215 -0
- package/src/monitoring/resource-sample-types.ts +8 -0
- package/src/monitoring/resource-sampler.ts +13 -1
package/package.json
CHANGED
|
@@ -60,6 +60,15 @@ function captureWith(conversationCount: number): StallCapture {
|
|
|
60
60
|
events: null,
|
|
61
61
|
deltas: null,
|
|
62
62
|
disk: null,
|
|
63
|
+
processes: [
|
|
64
|
+
...Array.from({ length: 6 }, (_, i) => ({
|
|
65
|
+
pid: 100 + i,
|
|
66
|
+
name: i === 0 ? "memory-worker" : `chrome-${i}`,
|
|
67
|
+
cpuPct: 150 - i * 10,
|
|
68
|
+
rssMb: 800,
|
|
69
|
+
})),
|
|
70
|
+
{ pid: 1, name: "daemon", cpuPct: 4, rssMb: 900 },
|
|
71
|
+
],
|
|
63
72
|
activeConversations: Array.from(
|
|
64
73
|
{ length: conversationCount },
|
|
65
74
|
(_, i) => ({
|
|
@@ -236,6 +245,16 @@ describe("buildBlockTelemetryDetail", () => {
|
|
|
236
245
|
expect(detail.stall_capture!.waitState!.epoll!.pipes).toBe(1);
|
|
237
246
|
expect(detail.stall_capture!.waitState!.children).toHaveLength(1);
|
|
238
247
|
expect(detail.activity.length).toBeGreaterThanOrEqual(4);
|
|
248
|
+
expect(
|
|
249
|
+
detail.stall_capture!.sample.processes!.length,
|
|
250
|
+
).toBeGreaterThanOrEqual(3);
|
|
251
|
+
expect(detail.stall_capture!.sample.processes![0]!.name).toBe(
|
|
252
|
+
"memory-worker",
|
|
253
|
+
);
|
|
254
|
+
// Trimming keeps the daemon for comparison.
|
|
255
|
+
expect(
|
|
256
|
+
detail.stall_capture!.sample.processes!.some((p) => p.name === "daemon"),
|
|
257
|
+
).toBe(true);
|
|
239
258
|
expect(detail.activity[0]).toMatchObject({
|
|
240
259
|
kind: "turn",
|
|
241
260
|
conversationType: "standard",
|
|
@@ -124,8 +124,9 @@ type BlockTelemetryDetail = {
|
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
126
|
* Ordered steps that shrink an oversize report, least diagnostic loss first.
|
|
127
|
-
* The activity groups, the newest section-trail
|
|
128
|
-
* wait state are what attribute a block, so they
|
|
127
|
+
* The activity groups, the busiest processes, the newest section-trail
|
|
128
|
+
* entries, and the capture's wait state are what attribute a block, so they
|
|
129
|
+
* go last.
|
|
129
130
|
*/
|
|
130
131
|
const TRIM_STEPS: Array<{
|
|
131
132
|
name: string;
|
|
@@ -175,6 +176,19 @@ const TRIM_STEPS: Array<{
|
|
|
175
176
|
d.section_trail = d.section_trail.slice(0, 3);
|
|
176
177
|
},
|
|
177
178
|
},
|
|
179
|
+
{
|
|
180
|
+
// Keeps the daemon, which the sampler always lists last, for comparison.
|
|
181
|
+
name: "processes_3",
|
|
182
|
+
apply: (d) => {
|
|
183
|
+
const processes = d.stall_capture?.sample.processes;
|
|
184
|
+
if (processes) {
|
|
185
|
+
const daemon = processes.find((p) => p.name === "daemon");
|
|
186
|
+
const kept = processes.slice(0, 3);
|
|
187
|
+
d.stall_capture!.sample.processes =
|
|
188
|
+
daemon && !kept.includes(daemon) ? [...kept, daemon] : kept;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
},
|
|
178
192
|
{
|
|
179
193
|
name: "activity_4",
|
|
180
194
|
apply: (d) => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Fixture builders for tests that read a fake `/proc` tree. */
|
|
2
|
+
|
|
3
|
+
/** A `/proc/<pid>/stat` line; times in clock ticks, rss in pages. */
|
|
4
|
+
export function stat(
|
|
5
|
+
pid: number,
|
|
6
|
+
comm: string,
|
|
7
|
+
state: string,
|
|
8
|
+
ppid: number,
|
|
9
|
+
startTicks: number,
|
|
10
|
+
usage: { utime?: number; stime?: number; rssPages?: number } = {},
|
|
11
|
+
): string {
|
|
12
|
+
// Fields 3..24: state ppid pgrp session tty tpgid flags minflt cminflt
|
|
13
|
+
// majflt cmajflt utime stime cutime cstime priority nice threads
|
|
14
|
+
// itrealvalue starttime vsize rss, then rsslim.
|
|
15
|
+
const fields = [
|
|
16
|
+
state,
|
|
17
|
+
ppid,
|
|
18
|
+
...Array(9).fill(0),
|
|
19
|
+
usage.utime ?? 0,
|
|
20
|
+
usage.stime ?? 0,
|
|
21
|
+
...Array(6).fill(0),
|
|
22
|
+
startTicks,
|
|
23
|
+
0,
|
|
24
|
+
usage.rssPages ?? 0,
|
|
25
|
+
0,
|
|
26
|
+
];
|
|
27
|
+
return `${pid} (${comm}) ${fields.join(" ")}\n`;
|
|
28
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
parseProcSyscall,
|
|
24
24
|
readStallWaitState,
|
|
25
25
|
} from "../proc-wait-state.js";
|
|
26
|
+
import { stat } from "./proc-fixtures.js";
|
|
26
27
|
|
|
27
28
|
const DAEMON = 100;
|
|
28
29
|
const EPFD = 7;
|
|
@@ -41,18 +42,6 @@ function fdLink(pid: number, fd: number, target: string): void {
|
|
|
41
42
|
symlinkSync(target, join(dir, String(fd)));
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
/** A `/proc/<pid>/stat` line: state, ppid, and start time in clock ticks. */
|
|
45
|
-
function stat(
|
|
46
|
-
pid: number,
|
|
47
|
-
comm: string,
|
|
48
|
-
state: string,
|
|
49
|
-
ppid: number,
|
|
50
|
-
startTicks: number,
|
|
51
|
-
): string {
|
|
52
|
-
const fields = [state, ppid, ...Array(17).fill(0), startTicks, 0];
|
|
53
|
-
return `${pid} (${comm}) ${fields.join(" ")}\n`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
45
|
/**
|
|
57
46
|
* A daemon whose main thread waits in epoll_wait (x64 syscall 232) on
|
|
58
47
|
* `EPFD`, which watches the given fds.
|
|
@@ -201,11 +190,21 @@ describe("readStallWaitState", () => {
|
|
|
201
190
|
|
|
202
191
|
describe("parsers", () => {
|
|
203
192
|
test("parseProcStat handles a comm containing spaces and parentheses", () => {
|
|
204
|
-
expect(
|
|
193
|
+
expect(
|
|
194
|
+
parseProcStat(
|
|
195
|
+
stat(5, "Bun (Pool) 1", "D", 4, 42, {
|
|
196
|
+
utime: 7,
|
|
197
|
+
stime: 3,
|
|
198
|
+
rssPages: 9,
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
201
|
+
).toEqual({
|
|
205
202
|
comm: "Bun (Pool) 1",
|
|
206
203
|
state: "D",
|
|
207
204
|
ppid: 4,
|
|
205
|
+
cpuTicks: 10,
|
|
208
206
|
startTicks: 42,
|
|
207
|
+
rssPages: 9,
|
|
209
208
|
});
|
|
210
209
|
expect(parseProcStat("garbage")).toBeNull();
|
|
211
210
|
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the per-process CPU and memory tracker, against a fixture proc
|
|
3
|
+
* tree. CPU is a rate between two scans, so each test writes counters, scans,
|
|
4
|
+
* advances the counters, and scans again.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { readKernelPageSizeBytes } from "../proc-wait-state.js";
|
|
13
|
+
import { createProcessUsageTracker } from "../process-usage.js";
|
|
14
|
+
import { stat } from "./proc-fixtures.js";
|
|
15
|
+
|
|
16
|
+
const DAEMON = 1;
|
|
17
|
+
const MB_PAGES = 256; // 1 MiB in 4 KiB pages
|
|
18
|
+
|
|
19
|
+
let procRoot: string;
|
|
20
|
+
|
|
21
|
+
function writeProcess(
|
|
22
|
+
pid: number,
|
|
23
|
+
comm: string,
|
|
24
|
+
cpuTicks: number,
|
|
25
|
+
opts: { startTicks?: number; rssMb?: number; cmdline?: string[] } = {},
|
|
26
|
+
): void {
|
|
27
|
+
const dir = join(procRoot, String(pid));
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
writeFileSync(
|
|
30
|
+
join(dir, "stat"),
|
|
31
|
+
stat(pid, comm, "S", 0, opts.startTicks ?? 100, {
|
|
32
|
+
utime: cpuTicks,
|
|
33
|
+
rssPages: (opts.rssMb ?? 10) * MB_PAGES,
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
writeFileSync(join(dir, "cmdline"), (opts.cmdline ?? [comm]).join("\0"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
procRoot = mkdtempSync(join(tmpdir(), "process-usage-"));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(procRoot, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("createProcessUsageTracker", () => {
|
|
48
|
+
test("reports CPU as a rate between two scans, busiest first", () => {
|
|
49
|
+
// GIVEN a daemon and a memory worker
|
|
50
|
+
const tracker = createProcessUsageTracker({ procRoot });
|
|
51
|
+
writeProcess(DAEMON, "bun", 1_000, { rssMb: 900 });
|
|
52
|
+
writeProcess(20, "bun", 5_000, {
|
|
53
|
+
rssMb: 1_200,
|
|
54
|
+
cmdline: [
|
|
55
|
+
"bun",
|
|
56
|
+
"--smol",
|
|
57
|
+
"run",
|
|
58
|
+
"/app/src/plugins/defaults/memory/worker.ts",
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// WHEN the first scan runs
|
|
63
|
+
// THEN there is no rate yet
|
|
64
|
+
expect(tracker.sample(0, DAEMON)).toBeNull();
|
|
65
|
+
|
|
66
|
+
// WHEN 2s later the worker used 3 CPU-seconds and the daemon 0.1s
|
|
67
|
+
writeProcess(DAEMON, "bun", 1_010, { rssMb: 900 });
|
|
68
|
+
writeProcess(20, "bun", 5_300, {
|
|
69
|
+
rssMb: 1_200,
|
|
70
|
+
cmdline: [
|
|
71
|
+
"bun",
|
|
72
|
+
"--smol",
|
|
73
|
+
"run",
|
|
74
|
+
"/app/src/plugins/defaults/memory/worker.ts",
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
const usage = tracker.sample(2_000, DAEMON);
|
|
78
|
+
|
|
79
|
+
// THEN the worker shows 150% of a core and leads the list
|
|
80
|
+
expect(usage).toEqual([
|
|
81
|
+
{ pid: 20, name: "memory-worker", cpuPct: 150, rssMb: 1_200 },
|
|
82
|
+
{ pid: DAEMON, name: "daemon", cpuPct: 5, rssMb: 900 },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("never reports command-line arguments", () => {
|
|
87
|
+
// GIVEN a process started with a secret on its command line
|
|
88
|
+
const tracker = createProcessUsageTracker({ procRoot });
|
|
89
|
+
const cmdline = ["bun", "run", "deploy", "--token=sk-secret"];
|
|
90
|
+
writeProcess(30, "bun", 0, { cmdline });
|
|
91
|
+
tracker.sample(0, DAEMON);
|
|
92
|
+
writeProcess(30, "bun", 100, { cmdline });
|
|
93
|
+
|
|
94
|
+
// WHEN it is reported
|
|
95
|
+
const usage = tracker.sample(1_000, DAEMON)!;
|
|
96
|
+
|
|
97
|
+
// THEN only the kernel comm is used
|
|
98
|
+
expect(usage[0]!.name).toBe("bun");
|
|
99
|
+
expect(JSON.stringify(usage)).not.toContain("secret");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a reused pid is not given its predecessor's counters", () => {
|
|
103
|
+
// GIVEN pid 40 exits and a new process takes the pid
|
|
104
|
+
const tracker = createProcessUsageTracker({ procRoot });
|
|
105
|
+
writeProcess(40, "old", 9_000, { startTicks: 100 });
|
|
106
|
+
tracker.sample(0, DAEMON);
|
|
107
|
+
writeProcess(40, "chromium", 50, { startTicks: 500 });
|
|
108
|
+
|
|
109
|
+
// WHEN the next scan runs
|
|
110
|
+
const usage = tracker.sample(1_000, DAEMON)!;
|
|
111
|
+
|
|
112
|
+
// THEN the new process is skipped until it has two readings
|
|
113
|
+
expect(usage).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the daemon is kept even when it is not among the busiest", () => {
|
|
117
|
+
// GIVEN seven busy processes and an idle, small daemon
|
|
118
|
+
const tracker = createProcessUsageTracker({ procRoot });
|
|
119
|
+
writeProcess(DAEMON, "bun", 0, { rssMb: 1 });
|
|
120
|
+
for (let pid = 50; pid < 57; pid++) {
|
|
121
|
+
writeProcess(pid, `chrome-${pid}`, 0);
|
|
122
|
+
}
|
|
123
|
+
tracker.sample(0, DAEMON);
|
|
124
|
+
writeProcess(DAEMON, "bun", 0, { rssMb: 1 });
|
|
125
|
+
for (let pid = 50; pid < 57; pid++) {
|
|
126
|
+
writeProcess(pid, `chrome-${pid}`, 100);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// WHEN the next scan runs
|
|
130
|
+
const usage = tracker.sample(1_000, DAEMON)!;
|
|
131
|
+
|
|
132
|
+
// THEN four by CPU, two by memory, plus the daemon are reported
|
|
133
|
+
expect(usage).toHaveLength(7);
|
|
134
|
+
expect(usage.at(-1)).toMatchObject({ name: "daemon", cpuPct: 0 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("scans at most once per interval and reuses the last result", () => {
|
|
138
|
+
// GIVEN two completed scans
|
|
139
|
+
const tracker = createProcessUsageTracker({ procRoot });
|
|
140
|
+
writeProcess(DAEMON, "bun", 0);
|
|
141
|
+
tracker.sample(0, DAEMON);
|
|
142
|
+
writeProcess(DAEMON, "bun", 50);
|
|
143
|
+
const first = tracker.sample(1_000, DAEMON);
|
|
144
|
+
|
|
145
|
+
// WHEN a tick arrives 250ms later with new counters
|
|
146
|
+
writeProcess(DAEMON, "bun", 999);
|
|
147
|
+
const again = tracker.sample(1_250, DAEMON);
|
|
148
|
+
|
|
149
|
+
// THEN the previous result is reused, not rescanned
|
|
150
|
+
expect(again).toBe(first);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("an unreadable /proc yields null", () => {
|
|
154
|
+
const tracker = createProcessUsageTracker({
|
|
155
|
+
procRoot: join(procRoot, "missing"),
|
|
156
|
+
});
|
|
157
|
+
expect(tracker.sample(0, DAEMON)).toBeNull();
|
|
158
|
+
expect(tracker.sample(2_000, DAEMON)).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("only allowlisted worker scripts get a worker label", () => {
|
|
162
|
+
// GIVEN the real schedule worker and a user script shaped like a worker
|
|
163
|
+
const tracker = createProcessUsageTracker({ procRoot });
|
|
164
|
+
const schedule = ["bun", "run", "/app/assistant/src/schedule/worker.ts"];
|
|
165
|
+
const lookalike = ["bun", "run", "/home/me/acme-payroll-worker.ts"];
|
|
166
|
+
writeProcess(60, "bun", 0, { cmdline: schedule });
|
|
167
|
+
writeProcess(61, "bun", 0, { cmdline: lookalike });
|
|
168
|
+
tracker.sample(0, DAEMON);
|
|
169
|
+
writeProcess(60, "bun", 20, { cmdline: schedule });
|
|
170
|
+
writeProcess(61, "bun", 10, { cmdline: lookalike });
|
|
171
|
+
|
|
172
|
+
// WHEN they are reported
|
|
173
|
+
const names = tracker.sample(1_000, DAEMON)!.map((p) => p.name);
|
|
174
|
+
|
|
175
|
+
// THEN the look-alike falls back to the kernel comm
|
|
176
|
+
expect(names).toEqual(["schedule-worker", "bun"]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("an idle process holding the most memory is still reported", () => {
|
|
180
|
+
// GIVEN six small busy processes and an idle, memory-heavy one
|
|
181
|
+
const tracker = createProcessUsageTracker({ procRoot });
|
|
182
|
+
for (let pid = 70; pid < 76; pid++) {
|
|
183
|
+
writeProcess(pid, `busy-${pid}`, 0, { rssMb: 5 });
|
|
184
|
+
}
|
|
185
|
+
writeProcess(80, "qdrant", 0, { rssMb: 2_000 });
|
|
186
|
+
tracker.sample(0, DAEMON);
|
|
187
|
+
for (let pid = 70; pid < 76; pid++) {
|
|
188
|
+
writeProcess(pid, `busy-${pid}`, 10, { rssMb: 5 });
|
|
189
|
+
}
|
|
190
|
+
writeProcess(80, "qdrant", 0, { rssMb: 2_000 });
|
|
191
|
+
|
|
192
|
+
// WHEN the next scan runs
|
|
193
|
+
const usage = tracker.sample(1_000, DAEMON)!;
|
|
194
|
+
|
|
195
|
+
// THEN the memory-heavy process takes a memory slot
|
|
196
|
+
expect(usage).toContainEqual({
|
|
197
|
+
pid: 80,
|
|
198
|
+
name: "qdrant",
|
|
199
|
+
cpuPct: 0,
|
|
200
|
+
rssMb: 2_000,
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("converts resident pages with the kernel's page size", () => {
|
|
205
|
+
// GIVEN a 64 KiB-page kernel and a process with 16384 resident pages
|
|
206
|
+
const tracker = createProcessUsageTracker({
|
|
207
|
+
procRoot,
|
|
208
|
+
pageSizeBytes: 65_536,
|
|
209
|
+
});
|
|
210
|
+
writeProcess(90, "bun", 0, { rssMb: 256 }); // 256 * 256 = 65536 pages
|
|
211
|
+
tracker.sample(0, DAEMON);
|
|
212
|
+
writeProcess(90, "bun", 0, { rssMb: 256 });
|
|
213
|
+
|
|
214
|
+
// WHEN it is reported
|
|
215
|
+
const usage = tracker.sample(1_000, DAEMON)!;
|
|
216
|
+
|
|
217
|
+
// THEN RSS is 16x what a 4 KiB assumption would give
|
|
218
|
+
expect(usage[0]!.rssMb).toBe(4_096);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("readKernelPageSizeBytes", () => {
|
|
223
|
+
function writeSelf(rssKb: number, rssPages: number): void {
|
|
224
|
+
const dir = join(procRoot, "self");
|
|
225
|
+
mkdirSync(dir, { recursive: true });
|
|
226
|
+
writeFileSync(join(dir, "status"), `Name:\tbun\nVmRSS:\t ${rssKb} kB\n`);
|
|
227
|
+
writeFileSync(join(dir, "statm"), `9000 ${rssPages} 100 1 0 500 0\n`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
test("derives 64 KiB from resident kB and pages", () => {
|
|
231
|
+
writeSelf(65_536, 1_024);
|
|
232
|
+
expect(readKernelPageSizeBytes(procRoot)).toBe(65_536);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("snaps a slightly skewed reading to the nearest page size", () => {
|
|
236
|
+
writeSelf(4_100, 1_000);
|
|
237
|
+
expect(readKernelPageSizeBytes(procRoot)).toBe(4_096);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("falls back to 4 KiB when /proc/self is unreadable", () => {
|
|
241
|
+
expect(readKernelPageSizeBytes(procRoot)).toBe(4_096);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -28,8 +28,37 @@ import { join } from "node:path";
|
|
|
28
28
|
const MAX_CHILDREN = 5;
|
|
29
29
|
const MAX_THREAD_GROUPS = 8;
|
|
30
30
|
|
|
31
|
-
/** Linux USER_HZ; `/proc/<pid>/stat`
|
|
32
|
-
const CLOCK_TICKS_PER_SECOND = 100;
|
|
31
|
+
/** Linux USER_HZ; `/proc/<pid>/stat` times are in these ticks. */
|
|
32
|
+
export const CLOCK_TICKS_PER_SECOND = 100;
|
|
33
|
+
|
|
34
|
+
/** Page sizes Linux kernels use; `/proc` page counts are in one of these. */
|
|
35
|
+
const KERNEL_PAGE_SIZES = [4096, 16384, 65536] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The kernel page size, which `/proc/<pid>/stat` rss and `statm` counts are
|
|
39
|
+
* in. It varies by kernel (arm64 kernels can use 16 or 64 KiB), so it is
|
|
40
|
+
* derived from this process's own resident size, reported both in kB
|
|
41
|
+
* (`status` VmRSS) and in pages (`statm`). Falls back to 4 KiB.
|
|
42
|
+
*/
|
|
43
|
+
export function readKernelPageSizeBytes(procRoot = "/proc"): number {
|
|
44
|
+
const status = readText(join(procRoot, "self", "status"));
|
|
45
|
+
const statm = readText(join(procRoot, "self", "statm"));
|
|
46
|
+
const rssKb = status
|
|
47
|
+
? Number(/^VmRSS:\s+(\d+)\s+kB/m.exec(status)?.[1])
|
|
48
|
+
: NaN;
|
|
49
|
+
const rssPages = statm ? Number(statm.trim().split(/\s+/)[1]) : NaN;
|
|
50
|
+
if (!(rssKb > 0) || !(rssPages > 0)) {
|
|
51
|
+
return KERNEL_PAGE_SIZES[0];
|
|
52
|
+
}
|
|
53
|
+
const measured = (rssKb * 1024) / rssPages;
|
|
54
|
+
// The two files are read at slightly different moments; snap to the
|
|
55
|
+
// nearest real page size.
|
|
56
|
+
return KERNEL_PAGE_SIZES.reduce((best, size) =>
|
|
57
|
+
Math.abs(Math.log2(size / measured)) < Math.abs(Math.log2(best / measured))
|
|
58
|
+
? size
|
|
59
|
+
: best,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
33
62
|
|
|
34
63
|
/** What `/proc/<pid>/fd/<n>` links to for an epoll instance. */
|
|
35
64
|
const EPOLL_FD_LINK = "anon_inode:[eventpoll]";
|
|
@@ -99,30 +128,48 @@ function listNumericDirs(path: string): number[] {
|
|
|
99
128
|
}
|
|
100
129
|
}
|
|
101
130
|
|
|
131
|
+
export interface ProcStat {
|
|
132
|
+
comm: string;
|
|
133
|
+
state: string;
|
|
134
|
+
ppid: number;
|
|
135
|
+
/** User plus system CPU time consumed, in clock ticks. */
|
|
136
|
+
cpuTicks: number;
|
|
137
|
+
/** Start time after boot, in clock ticks. */
|
|
138
|
+
startTicks: number;
|
|
139
|
+
/** Resident set size, in pages. */
|
|
140
|
+
rssPages: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
102
143
|
/** Parse `/proc/<pid>/stat` around the parenthesised comm, which may contain spaces. */
|
|
103
|
-
export function parseProcStat(
|
|
104
|
-
raw: string,
|
|
105
|
-
): { comm: string; state: string; ppid: number; startTicks: number } | null {
|
|
144
|
+
export function parseProcStat(raw: string): ProcStat | null {
|
|
106
145
|
const open = raw.indexOf("(");
|
|
107
146
|
const close = raw.lastIndexOf(")");
|
|
108
147
|
if (open < 0 || close < open) {
|
|
109
148
|
return null;
|
|
110
149
|
}
|
|
111
|
-
// Fields after the comm start at field 3 (state)
|
|
150
|
+
// Fields after the comm start at field 3 (state), so field N is at index
|
|
151
|
+
// N - 3: utime 14, stime 15, starttime 22, rss 24.
|
|
112
152
|
const fields = raw
|
|
113
153
|
.slice(close + 1)
|
|
114
154
|
.trim()
|
|
115
155
|
.split(/\s+/);
|
|
116
156
|
const ppid = Number(fields[1]);
|
|
157
|
+
const cpuTicks = Number(fields[11]) + Number(fields[12]);
|
|
117
158
|
const startTicks = Number(fields[19]);
|
|
118
|
-
|
|
159
|
+
const rssPages = Number(fields[21]);
|
|
160
|
+
if (
|
|
161
|
+
!fields[0] ||
|
|
162
|
+
![ppid, cpuTicks, startTicks, rssPages].every(Number.isFinite)
|
|
163
|
+
) {
|
|
119
164
|
return null;
|
|
120
165
|
}
|
|
121
166
|
return {
|
|
122
167
|
comm: raw.slice(open + 1, close),
|
|
123
168
|
state: fields[0],
|
|
124
169
|
ppid,
|
|
170
|
+
cpuTicks,
|
|
125
171
|
startTicks,
|
|
172
|
+
rssPages,
|
|
126
173
|
};
|
|
127
174
|
}
|
|
128
175
|
|
|
@@ -17,9 +17,10 @@ import {
|
|
|
17
17
|
type ProcessTableRow,
|
|
18
18
|
} from "../util/process-table.js";
|
|
19
19
|
import { deriveName, readProcessCommand } from "../util/process-tree.js";
|
|
20
|
+
import { readKernelPageSizeBytes } from "./proc-wait-state.js";
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/** Kernel page size for `/proc/<pid>/statm` pages, read on first use. */
|
|
23
|
+
let pageSizeBytes: number | undefined;
|
|
23
24
|
|
|
24
25
|
export interface SmapsRollup {
|
|
25
26
|
rssBytes: number | null;
|
|
@@ -74,7 +75,7 @@ function readProcessMemory(pid: number): SmapsRollup | null {
|
|
|
74
75
|
return null;
|
|
75
76
|
}
|
|
76
77
|
return {
|
|
77
|
-
rssBytes: residentPages *
|
|
78
|
+
rssBytes: residentPages * (pageSizeBytes ??= readKernelPageSizeBytes()),
|
|
78
79
|
pssBytes: null,
|
|
79
80
|
pssAnonBytes: null,
|
|
80
81
|
pssFileBytes: null,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-process CPU and memory in the assistant container, sampled by the
|
|
3
|
+
* resource monitor.
|
|
4
|
+
*
|
|
5
|
+
* The cgroup counters show that the container hit its CPU quota or filled
|
|
6
|
+
* its memory, not which process did it. Background work can slow the
|
|
7
|
+
* daemon without blocking its event loop: a worker, Qdrant, or a browser
|
|
8
|
+
* uses the container's shared CPU while the daemon waits for a turn on it.
|
|
9
|
+
* This tracker names the processes using the container's resources, so a
|
|
10
|
+
* slow daemon can be told apart from a starved one.
|
|
11
|
+
*
|
|
12
|
+
* CPU is a rate, so it needs two readings: each scan reads every process's
|
|
13
|
+
* cumulative CPU ticks from `/proc/<pid>/stat` and compares them with the
|
|
14
|
+
* previous scan. Memory is the resident set size from the same file.
|
|
15
|
+
*
|
|
16
|
+
* Names are metadata only: the daemon is "daemon", the assistant's own
|
|
17
|
+
* worker scripts use a fixed label from an allowlist, and any other process
|
|
18
|
+
* uses its kernel `comm` (15 bytes, no arguments). Command-line content never
|
|
19
|
+
* leaves the machine.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
CLOCK_TICKS_PER_SECOND,
|
|
27
|
+
parseProcStat,
|
|
28
|
+
readKernelPageSizeBytes,
|
|
29
|
+
} from "./proc-wait-state.js";
|
|
30
|
+
|
|
31
|
+
/** Processes reported by CPU, busiest first. */
|
|
32
|
+
const TOP_BY_CPU = 4;
|
|
33
|
+
/** Further processes reported by resident memory, largest first. */
|
|
34
|
+
const TOP_BY_MEMORY = 2;
|
|
35
|
+
/** Minimum time between scans; a shorter tick reuses the last result. */
|
|
36
|
+
const DEFAULT_MIN_SCAN_INTERVAL_MS = 1_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The assistant's own worker scripts, matched on the trailing path of a
|
|
40
|
+
* command-line argument. Only these fixed labels are ever reported; any
|
|
41
|
+
* other process is named by its kernel comm.
|
|
42
|
+
*/
|
|
43
|
+
const KNOWN_WORKERS: ReadonlyArray<{ pattern: RegExp; name: string }> = [
|
|
44
|
+
{
|
|
45
|
+
pattern: /(?:^|\/)src\/schedule\/worker\.[cm]?[jt]s$/,
|
|
46
|
+
name: "schedule-worker",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
pattern: /(?:^|\/)defaults\/memory\/worker\.[cm]?[jt]s$/,
|
|
50
|
+
name: "memory-worker",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
pattern: /(?:^|\/)src\/monitoring\/worker\.[cm]?[jt]s$/,
|
|
54
|
+
name: "monitoring-worker",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
pattern: /(?:^|\/)src\/routes\/worker\.[cm]?[jt]s$/,
|
|
58
|
+
name: "routes-worker",
|
|
59
|
+
},
|
|
60
|
+
{ pattern: /(?:^|\/)embed-worker\.mjs$/, name: "embed-worker" },
|
|
61
|
+
{ pattern: /(?:^|\/)rerank-worker\.mjs$/, name: "rerank-worker" },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export interface ProcessUsage {
|
|
65
|
+
pid: number;
|
|
66
|
+
/** "daemon", an assistant worker name, or the kernel comm. */
|
|
67
|
+
name: string;
|
|
68
|
+
/** CPU used since the previous scan, as a percentage of one core. */
|
|
69
|
+
cpuPct: number;
|
|
70
|
+
rssMb: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ProcessCounters {
|
|
74
|
+
cpuTicks: number;
|
|
75
|
+
startTicks: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ProcessUsageTracker {
|
|
79
|
+
/**
|
|
80
|
+
* Busiest processes since the previous scan, or null before the first
|
|
81
|
+
* two scans complete or when `/proc` is unreadable. `nowMs` must be read
|
|
82
|
+
* at the call, so the time between scans matches the counters' interval.
|
|
83
|
+
*/
|
|
84
|
+
sample(nowMs: number, daemonPid: number | null): ProcessUsage[] | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readText(path: string): string | null {
|
|
88
|
+
try {
|
|
89
|
+
return readFileSync(path, "utf-8");
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A telemetry-safe name: never includes command-line arguments. */
|
|
96
|
+
function processName(
|
|
97
|
+
procRoot: string,
|
|
98
|
+
pid: number,
|
|
99
|
+
comm: string,
|
|
100
|
+
daemonPid: number | null,
|
|
101
|
+
): string {
|
|
102
|
+
if (pid === daemonPid) {
|
|
103
|
+
return "daemon";
|
|
104
|
+
}
|
|
105
|
+
const cmdline = readText(join(procRoot, String(pid), "cmdline"));
|
|
106
|
+
for (const arg of cmdline?.split("\0") ?? []) {
|
|
107
|
+
const path = arg.replaceAll("\\", "/");
|
|
108
|
+
const worker = KNOWN_WORKERS.find(({ pattern }) => pattern.test(path));
|
|
109
|
+
if (worker) {
|
|
110
|
+
return worker.name;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return comm;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createProcessUsageTracker(
|
|
117
|
+
options: {
|
|
118
|
+
procRoot?: string;
|
|
119
|
+
minScanIntervalMs?: number;
|
|
120
|
+
pageSizeBytes?: number;
|
|
121
|
+
} = {},
|
|
122
|
+
): ProcessUsageTracker {
|
|
123
|
+
const procRoot = options.procRoot ?? "/proc";
|
|
124
|
+
const pageSizeBytes =
|
|
125
|
+
options.pageSizeBytes ?? readKernelPageSizeBytes(procRoot);
|
|
126
|
+
const minScanIntervalMs =
|
|
127
|
+
options.minScanIntervalMs ?? DEFAULT_MIN_SCAN_INTERVAL_MS;
|
|
128
|
+
let previous: Map<number, ProcessCounters> | null = null;
|
|
129
|
+
let previousAt = 0;
|
|
130
|
+
let last: ProcessUsage[] | null = null;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
sample(nowMs: number, daemonPid: number | null): ProcessUsage[] | null {
|
|
134
|
+
if (previous != null && nowMs - previousAt < minScanIntervalMs) {
|
|
135
|
+
return last;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let pids: number[];
|
|
139
|
+
try {
|
|
140
|
+
pids = readdirSync(procRoot)
|
|
141
|
+
.filter((entry) => /^\d+$/.test(entry))
|
|
142
|
+
.map(Number);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const elapsedSeconds = (nowMs - previousAt) / 1000;
|
|
148
|
+
const current = new Map<number, ProcessCounters>();
|
|
149
|
+
const usage: Array<ProcessUsage & { comm: string }> = [];
|
|
150
|
+
for (const pid of pids) {
|
|
151
|
+
const raw = readText(join(procRoot, String(pid), "stat"));
|
|
152
|
+
const stat = raw != null ? parseProcStat(raw) : null;
|
|
153
|
+
if (stat == null) {
|
|
154
|
+
// Exited between readdir and read.
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
current.set(pid, {
|
|
158
|
+
cpuTicks: stat.cpuTicks,
|
|
159
|
+
startTicks: stat.startTicks,
|
|
160
|
+
});
|
|
161
|
+
const before = previous?.get(pid);
|
|
162
|
+
// A reused pid starts a new process; its old counters do not apply.
|
|
163
|
+
if (before == null || before.startTicks !== stat.startTicks) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const cpuSeconds =
|
|
167
|
+
(stat.cpuTicks - before.cpuTicks) / CLOCK_TICKS_PER_SECOND;
|
|
168
|
+
usage.push({
|
|
169
|
+
pid,
|
|
170
|
+
comm: stat.comm,
|
|
171
|
+
name: "",
|
|
172
|
+
cpuPct:
|
|
173
|
+
elapsedSeconds > 0
|
|
174
|
+
? Math.round((cpuSeconds / elapsedSeconds) * 100)
|
|
175
|
+
: 0,
|
|
176
|
+
rssMb: Math.round((stat.rssPages * pageSizeBytes) / (1024 * 1024)),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hadPrevious = previous != null;
|
|
181
|
+
previous = current;
|
|
182
|
+
previousAt = nowMs;
|
|
183
|
+
if (!hadPrevious) {
|
|
184
|
+
last = null;
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The busiest by CPU, then the largest by memory (an idle process can
|
|
189
|
+
// still fill the cgroup), then the daemon for comparison.
|
|
190
|
+
const byCpu = [...usage].sort(
|
|
191
|
+
(a, b) => b.cpuPct - a.cpuPct || b.rssMb - a.rssMb,
|
|
192
|
+
);
|
|
193
|
+
const reported = byCpu.slice(0, TOP_BY_CPU);
|
|
194
|
+
const byMemory = [...usage].sort((a, b) => b.rssMb - a.rssMb);
|
|
195
|
+
for (const entry of byMemory) {
|
|
196
|
+
if (reported.length >= TOP_BY_CPU + TOP_BY_MEMORY) {
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
if (!reported.includes(entry)) {
|
|
200
|
+
reported.push(entry);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const daemon = usage.find((entry) => entry.pid === daemonPid);
|
|
204
|
+
if (daemon && !reported.includes(daemon)) {
|
|
205
|
+
reported.push(daemon);
|
|
206
|
+
}
|
|
207
|
+
// Names are read only for the reported processes.
|
|
208
|
+
last = reported.map(({ comm, ...entry }) => ({
|
|
209
|
+
...entry,
|
|
210
|
+
name: processName(procRoot, entry.pid, comm, daemonPid),
|
|
211
|
+
}));
|
|
212
|
+
return last;
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
ContainerReclaimCounters,
|
|
12
12
|
} from "../util/cgroup-memory.js";
|
|
13
13
|
import type { ActiveConversation } from "./active-conversations.js";
|
|
14
|
+
import type { ProcessUsage } from "./process-usage.js";
|
|
14
15
|
|
|
15
16
|
export interface ResourceSampleMemory {
|
|
16
17
|
currentBytes: number;
|
|
@@ -68,4 +69,11 @@ export interface ResourceSample {
|
|
|
68
69
|
* Null when nothing is processing or the database is unavailable.
|
|
69
70
|
*/
|
|
70
71
|
activeConversations: ActiveConversation[] | null;
|
|
72
|
+
/**
|
|
73
|
+
* Busiest processes in the container since the previous process scan, so
|
|
74
|
+
* a CPU- or memory-starved daemon names what it was competing with. Null
|
|
75
|
+
* until two scans have run or where `/proc` is unreadable. Absent on
|
|
76
|
+
* samples written before this field existed.
|
|
77
|
+
*/
|
|
78
|
+
processes?: ProcessUsage[] | null;
|
|
71
79
|
}
|
|
@@ -44,9 +44,14 @@ import {
|
|
|
44
44
|
listProcesses,
|
|
45
45
|
} from "../util/process-tree.js";
|
|
46
46
|
import { readActiveConversations } from "./active-conversations.js";
|
|
47
|
+
import { readDaemonHeartbeat } from "./daemon-heartbeat.js";
|
|
47
48
|
import { topProcessesByFd } from "./file-descriptors.js";
|
|
48
49
|
import { getTrackedDataFiles, readFileResidency } from "./page-cache.js";
|
|
49
50
|
import { topProcessesByMemory } from "./process-memory.js";
|
|
51
|
+
import {
|
|
52
|
+
createProcessUsageTracker,
|
|
53
|
+
type ProcessUsageTracker,
|
|
54
|
+
} from "./process-usage.js";
|
|
50
55
|
import { prunePrefixedJsonFiles } from "./prune-snapshots.js";
|
|
51
56
|
import type {
|
|
52
57
|
ResourceSample,
|
|
@@ -94,6 +99,7 @@ export function computeSampleDeltas(
|
|
|
94
99
|
export async function takeSample(
|
|
95
100
|
now: number,
|
|
96
101
|
prev: ResourceSample | null = null,
|
|
102
|
+
processUsage: ProcessUsageTracker | null = null,
|
|
97
103
|
): Promise<ResourceSample> {
|
|
98
104
|
const currentBytes = getContainerMemoryUsageBytes();
|
|
99
105
|
const limitBytes = getContainerMemoryLimitBytes();
|
|
@@ -129,6 +135,11 @@ export async function takeSample(
|
|
|
129
135
|
}
|
|
130
136
|
: null,
|
|
131
137
|
activeConversations: readActiveConversations(),
|
|
138
|
+
// Timestamped at the scan, not the tick start: the disk read above can
|
|
139
|
+
// take seconds, and the CPU rate needs the counters' real interval.
|
|
140
|
+
processes:
|
|
141
|
+
processUsage?.sample(Date.now(), readDaemonHeartbeat(now)?.pid ?? null) ??
|
|
142
|
+
null,
|
|
132
143
|
};
|
|
133
144
|
if (prev != null) {
|
|
134
145
|
sample.deltas = computeSampleDeltas(prev, sample);
|
|
@@ -252,6 +263,7 @@ export function startResourceSampler(
|
|
|
252
263
|
// Watches the daemon's event-loop heartbeat; captures the daemon main
|
|
253
264
|
// thread's kernel state mid-stall when the heartbeat goes stale.
|
|
254
265
|
const stallCapture = createStallCaptureMonitor(dataDir);
|
|
266
|
+
const processUsage = createProcessUsageTracker();
|
|
255
267
|
|
|
256
268
|
// Skip ticks while a sample is in flight: the disk measurement can take
|
|
257
269
|
// seconds (du over the workspace), and overlapping ticks would all delta
|
|
@@ -273,7 +285,7 @@ export function startResourceSampler(
|
|
|
273
285
|
const now = clock();
|
|
274
286
|
let sample: ResourceSample;
|
|
275
287
|
try {
|
|
276
|
-
sample = await takeSample(now, prevSample);
|
|
288
|
+
sample = await takeSample(now, prevSample, processUsage);
|
|
277
289
|
prevSample = sample;
|
|
278
290
|
buffer.append(sample);
|
|
279
291
|
} catch (err) {
|