@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.4-dev.202609232215.634e2d2",
3
+ "version": "0.12.4-dev.202609232312.d1432a4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 entries, and the capture's
128
- * wait state are what attribute a block, so they go last.
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(parseProcStat(stat(5, "Bun (Pool) 1", "D", 4, 42))).toEqual({
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` start times are in these ticks. */
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); starttime is field 22.
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
- if (!fields[0] || !Number.isFinite(ppid) || !Number.isFinite(startTicks)) {
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
- /** Linux page size assumed when converting `/proc/<pid>/statm` pages to bytes. */
22
- const PAGE_SIZE_BYTES = 4096;
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 * PAGE_SIZE_BYTES,
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) {