@vellumai/assistant 0.11.7-dev.202608281503.cbafd6c → 0.11.7-dev.202608282003.8ce5421

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.
Files changed (35) hide show
  1. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  4. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  6. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  7. package/openapi.yaml +5 -5
  8. package/package.json +1 -1
  9. package/src/__tests__/credential-health-service.test.ts +45 -0
  10. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
  11. package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
  12. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
  13. package/src/__tests__/guardian-gateway-sim.test.ts +47 -0
  14. package/src/__tests__/guardian-gateway-sim.ts +5 -1
  15. package/src/__tests__/non-member-access-request.test.ts +12 -0
  16. package/src/channels/gateway-guardian-requests.ts +4 -3
  17. package/src/cli/commands/__tests__/browser.test.ts +9 -7
  18. package/src/credential-health/credential-health-service.ts +37 -6
  19. package/src/daemon/lifecycle.ts +10 -13
  20. package/src/oauth/scope-utils.ts +48 -0
  21. package/src/persistence/db-connection.ts +3 -21
  22. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
  23. package/src/persistence/embeddings/embedding-local.ts +7 -112
  24. package/src/runtime/access-request-helper.ts +15 -7
  25. package/src/runtime/routes/integrations/slack/channel.ts +39 -18
  26. package/src/runtime/tool-grant-request-helper.ts +4 -0
  27. package/src/security/oauth2.ts +2 -11
  28. package/src/tools/browser/__tests__/browser-status.test.ts +9 -7
  29. package/src/tools/browser/browser-execution.ts +4 -40
  30. package/src/tools/browser/browser-status-constants.ts +0 -6
  31. package/src/util/__tests__/worker-process-command.test.ts +246 -1
  32. package/src/util/platform.ts +13 -81
  33. package/src/util/process-table.ts +14 -5
  34. package/src/util/worker-ownership.ts +126 -0
  35. package/src/util/worker-process.ts +369 -20
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { resolveWorkerCommand } from "../worker-process.js";
3
+ import {
4
+ classifyOrphanAfterWait,
5
+ decideWorkerSlot,
6
+ resolveWorkerCommand,
7
+ workerKindSignature,
8
+ type WorkerProcessStatus,
9
+ } from "../worker-process.js";
4
10
 
5
11
  const entry = new URL("file:///source/monitoring/worker.ts");
6
12
 
@@ -35,3 +41,242 @@ describe("resolveWorkerCommand", () => {
35
41
  ).toEqual(["bun", "--smol", "run", "/source/monitoring/worker.ts"]);
36
42
  });
37
43
  });
44
+
45
+ describe("workerKindSignature", () => {
46
+ const schedule = new URL(
47
+ "file:////app/runtime/0.11.7/src/schedule/worker.ts",
48
+ );
49
+ const source = {
50
+ platform: "darwin" as const,
51
+ execPath: "/runtime/bun",
52
+ executableExists: () => false,
53
+ };
54
+
55
+ test("is the same for a worker of this kind from any install", () => {
56
+ const previous = new URL(
57
+ "file:////app/runtime/0.10.11/src/schedule/worker.ts",
58
+ );
59
+ expect(workerKindSignature(schedule, "schedule", source)).toEqual(
60
+ workerKindSignature(previous, "schedule", source),
61
+ );
62
+ });
63
+
64
+ test("distinguishes one worker kind from another", () => {
65
+ const monitoring = new URL(
66
+ "file:////app/runtime/0.11.7/src/monitoring/worker.ts",
67
+ );
68
+ expect(workerKindSignature(schedule, "schedule", source)).not.toEqual(
69
+ workerKindSignature(monitoring, "monitoring", source),
70
+ );
71
+ });
72
+
73
+ // The signature only guards against signalling a recycled PID, so it has to
74
+ // be specific enough that an unrelated program running its own worker.ts
75
+ // never matches.
76
+ const matches = (command: string, signature: readonly string[]): boolean =>
77
+ signature.every((part) => command.includes(part));
78
+
79
+ test("does not match an unrelated program running some other worker.ts", () => {
80
+ const signature = workerKindSignature(schedule, "schedule", source);
81
+ expect(matches("bun run /home/dev/side-project/worker.ts", signature)).toBe(
82
+ false,
83
+ );
84
+ expect(matches("node /srv/queue/src/jobs/worker.ts", signature)).toBe(
85
+ false,
86
+ );
87
+ });
88
+
89
+ test("matches the same worker from a previous install", () => {
90
+ const signature = workerKindSignature(schedule, "schedule", source);
91
+ expect(
92
+ matches(
93
+ "bun --smol run /app/runtime/0.10.11/src/schedule/worker.ts",
94
+ signature,
95
+ ),
96
+ ).toBe(true);
97
+ });
98
+
99
+ const packaged = {
100
+ platform: "win32" as const,
101
+ execPath: "/runtime/vellum-daemon.exe",
102
+ executableExists: () => true,
103
+ };
104
+
105
+ // Every packaged worker runs one executable and is told apart by the
106
+ // subcommand, so the executable alone must not identify a worker: one
107
+ // worker's slot would otherwise reclaim another's process.
108
+ test("distinguishes packaged worker kinds sharing one executable", () => {
109
+ const scheduleSig = workerKindSignature(schedule, "schedule", packaged);
110
+ expect(matches('"C:/App/vellum-worker.exe" schedule', scheduleSig)).toBe(
111
+ true,
112
+ );
113
+ expect(matches('"C:/App/vellum-worker.exe" monitoring', scheduleSig)).toBe(
114
+ false,
115
+ );
116
+ });
117
+
118
+ test("matches a packaged worker from a previous install", () => {
119
+ const scheduleSig = workerKindSignature(schedule, "schedule", packaged);
120
+ expect(matches('"C:/Prev/vellum-worker.exe" schedule', scheduleSig)).toBe(
121
+ true,
122
+ );
123
+ });
124
+ });
125
+
126
+ // Every branch that can reach a kill. `reclaim` is the only decision that
127
+ // signals a process, so each test below is really asking: could this input
128
+ // have killed something it should not have?
129
+ describe("decideWorkerSlot", () => {
130
+ const DAEMON = 900;
131
+ const WORKER = 4242;
132
+ const signature = ["src/schedule/worker.ts"];
133
+ const running = { status: "running" as const, pid: WORKER };
134
+ const ours = `bun --smol run /app/runtime/0.11.7/src/schedule/worker.ts`;
135
+ const previous = `bun --smol run /app/runtime/0.10.11/src/schedule/worker.ts`;
136
+ const alive = () => true;
137
+ const dead = () => false;
138
+
139
+ const decide = (
140
+ row: { pid: number; ppid: number; command: string } | null,
141
+ isOwnerAlive = alive,
142
+ pid1OwnsWorkers = false,
143
+ status: WorkerProcessStatus = running,
144
+ ) =>
145
+ decideWorkerSlot(
146
+ status,
147
+ row,
148
+ signature,
149
+ DAEMON,
150
+ isOwnerAlive,
151
+ pid1OwnsWorkers,
152
+ );
153
+
154
+ test("spawns when the PID file names nothing running", () => {
155
+ expect(decide(null, alive, false, { status: "not_running" })).toEqual({
156
+ action: "spawn",
157
+ });
158
+ });
159
+
160
+ test("adopts this daemon's own worker", () => {
161
+ expect(decide({ pid: WORKER, ppid: DAEMON, command: ours })).toEqual({
162
+ action: "adopt",
163
+ pid: WORKER,
164
+ });
165
+ });
166
+
167
+ test("reclaims a worker reparented to init after its daemon died", () => {
168
+ expect(decide({ pid: WORKER, ppid: 1, command: previous })).toEqual({
169
+ action: "reclaim",
170
+ pid: WORKER,
171
+ });
172
+ });
173
+
174
+ test("reclaims a worker whose owner is gone", () => {
175
+ expect(decide({ pid: WORKER, ppid: 777, command: previous }, dead)).toEqual(
176
+ {
177
+ action: "reclaim",
178
+ pid: WORKER,
179
+ },
180
+ );
181
+ });
182
+
183
+ // `isOwnerAlive` is "is the owner a daemon", not "does that PID exist". These
184
+ // workers have exactly one legitimate owner, so a recycled owner PID must not
185
+ // keep a stranded worker looking owned.
186
+ test("adopts a worker a live daemon owns", () => {
187
+ expect(decide({ pid: WORKER, ppid: 777, command: ours }, alive)).toEqual({
188
+ action: "adopt",
189
+ pid: WORKER,
190
+ });
191
+ });
192
+
193
+ test("reclaims a worker whose owner PID was recycled to a non-daemon", () => {
194
+ const ownerIsNotADaemon = () => false;
195
+ expect(
196
+ decide({ pid: WORKER, ppid: 777, command: previous }, ownerIsNotADaemon),
197
+ ).toEqual({ action: "reclaim", pid: WORKER });
198
+ });
199
+
200
+ // In a container the daemon is PID 1, so a worker parented to 1 is a live
201
+ // sibling's, not an orphan.
202
+ test("adopts a PID-1 child when PID 1 is the daemon", () => {
203
+ expect(
204
+ decide({ pid: WORKER, ppid: 1, command: ours }, alive, true),
205
+ ).toEqual({ action: "adopt", pid: WORKER });
206
+ });
207
+
208
+ // The cases below are the ones that must never reach a kill.
209
+ test("never signals an unrelated process on a recycled PID", () => {
210
+ expect(
211
+ decide({ pid: WORKER, ppid: 1, command: "/usr/bin/postgres -D /data" }),
212
+ ).toEqual({ action: "adopt", pid: WORKER });
213
+ });
214
+
215
+ test("never signals another project's worker.ts on a recycled PID", () => {
216
+ expect(
217
+ decide({
218
+ pid: WORKER,
219
+ ppid: 1,
220
+ command: "bun run /home/dev/side-project/worker.ts",
221
+ }),
222
+ ).toEqual({ action: "adopt", pid: WORKER });
223
+ });
224
+
225
+ test("never signals a different worker kind holding this slot", () => {
226
+ expect(
227
+ decide({
228
+ pid: WORKER,
229
+ ppid: 1,
230
+ command: "bun --smol run /app/runtime/0.10.11/src/monitoring/worker.ts",
231
+ }),
232
+ ).toEqual({ action: "adopt", pid: WORKER });
233
+ });
234
+
235
+ test("never signals when the process table could not be read", () => {
236
+ expect(decide(null)).toEqual({ action: "adopt", pid: WORKER });
237
+ });
238
+ });
239
+
240
+ // Escalation happens after an awaited SIGTERM grace, so the PID alone cannot
241
+ // carry identity across it: the orphan may exit mid-wait and the OS may hand
242
+ // its PID to a stranger, or the process table may simply fail to answer.
243
+ // Only a positive re-match escalates, and only a proven "gone" releases the
244
+ // slot; a live PID whose identity cannot be read resolves to reuse, because
245
+ // releasing it would spawn a second worker next to a live one.
246
+ describe("classifyOrphanAfterWait", () => {
247
+ const signature = ["src/schedule/worker.ts"];
248
+ const ours = "bun --smol run /app/runtime/0.10.11/src/schedule/worker.ts";
249
+
250
+ test("dead PID: gone, slot may be released", () => {
251
+ expect(classifyOrphanAfterWait(false, null, signature)).toBe("gone");
252
+ expect(classifyOrphanAfterWait(false, ours, signature)).toBe("gone");
253
+ });
254
+
255
+ test("still this worker: escalation proceeds", () => {
256
+ expect(classifyOrphanAfterWait(true, ours, signature)).toBe("orphan");
257
+ });
258
+
259
+ test("PID reused by a stranger: orphan exited, never SIGKILL", () => {
260
+ expect(
261
+ classifyOrphanAfterWait(true, "/usr/bin/postgres -D /data", signature),
262
+ ).toBe("gone");
263
+ });
264
+
265
+ test("PID reused by a different worker kind: never SIGKILL", () => {
266
+ expect(
267
+ classifyOrphanAfterWait(
268
+ true,
269
+ "bun --smol run /app/runtime/0.10.11/src/monitoring/worker.ts",
270
+ signature,
271
+ ),
272
+ ).toBe("gone");
273
+ });
274
+
275
+ // The fail-safe vex pinned: a live PID with an unreadable command line is
276
+ // neither killable nor releasable.
277
+ test("alive but command unreadable: identity-unreadable, reuse the slot", () => {
278
+ expect(classifyOrphanAfterWait(true, null, signature)).toBe(
279
+ "identity-unreadable",
280
+ );
281
+ });
282
+ });
@@ -1,14 +1,6 @@
1
- import { chmodSync, existsSync, mkdirSync, realpathSync } from "node:fs";
2
- import { homedir, tmpdir } from "node:os";
3
- import {
4
- basename,
5
- delimiter,
6
- dirname,
7
- isAbsolute,
8
- join,
9
- relative,
10
- sep,
11
- } from "node:path";
1
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { delimiter, dirname, isAbsolute, join, relative, sep } from "node:path";
12
4
 
13
5
  import {
14
6
  AVATAR_IMAGE_FILENAME,
@@ -16,6 +8,7 @@ import {
16
8
  resolveAvatarDir,
17
9
  } from "@vellumai/avatar-manifest";
18
10
  import { SEEDS } from "@vellumai/environments";
11
+ import { assertTestPathIsEphemeral as assertEphemeralInTests } from "@vellumai/environments/test-path-guard";
19
12
 
20
13
  import { getWorkspaceDirOverride } from "../config/env-registry.js";
21
14
 
@@ -405,78 +398,17 @@ export function getProcPidPath(name: string): string {
405
398
  //
406
399
  // A test process must never resolve the workspace (or the vellum root) to a
407
400
  // real, non-temp directory: production code exercised by a test would then
408
- // read and destructively write live state. The tmpdir redirection normally
409
- // comes from the bunfig.toml test preload, but bun only loads bunfig from the
410
- // cwd, so `bun test` run from any other directory (for example a source
411
- // checkout inside a deployed container's workspace) skips the preload and
412
- // inherits the ambient VELLUM_WORKSPACE_DIR. The containment assertion
413
- // therefore lives here, in production code, where it fires no matter how the
414
- // test process was launched.
415
- //
416
- // Containment logic mirrors src/__tests__/assert-not-live-db.ts, which cannot
417
- // be imported here (production code must not depend on test machinery).
418
-
419
- /** Lazily computed: is this process a `bun test` run? */
420
- let isTestProcess: boolean | undefined;
421
-
422
- /**
423
- * Resolve symlinks in the deepest existing ancestor of `p`, then re-append
424
- * the not-yet-created tail. This keeps the containment check honest both for
425
- * paths under a symlinked temp root (macOS /var/folders) and for symlinks
426
- * that point outside it, whether or not the leaf exists yet.
427
- */
428
- function canonicalizeForWorkspaceGuard(p: string): string {
429
- let cur = p;
430
- const tail: string[] = [];
431
- for (;;) {
432
- try {
433
- return join(realpathSync(cur), ...tail);
434
- } catch {
435
- const parent = dirname(cur);
436
- if (parent === cur) {
437
- return p;
438
- }
439
- tail.unshift(basename(cur));
440
- cur = parent;
441
- }
442
- }
443
- }
401
+ // read and destructively write live state. The containment assertion is
402
+ // shared with the gateway via @vellumai/environments/test-path-guard;
403
+ // src/__tests__/assert-not-live-db.ts keeps its own containment check
404
+ // because test machinery must not import production dependencies.
444
405
 
445
406
  function assertTestPathIsEphemeral(dir: string): void {
446
- isTestProcess ??=
447
- process.env.NODE_ENV === "test" ||
448
- process.env.BUN_TEST === "1" ||
449
- // `bun test` sets NODE_ENV=test only when unset; Bun.main being the test
450
- // file itself is the backstop signal that survives a preset NODE_ENV.
451
- (typeof Bun !== "undefined" &&
452
- /\.(test|spec)\.[cm]?[jt]sx?$/.test(Bun.main));
453
- if (!isTestProcess) {
454
- return;
455
- }
456
- // Escape hatch for the rare intentional run against a real workspace,
457
- // shared with assertTestDbIsIsolated() in persistence/db-connection.ts.
458
- // Deliberately NOT added to tools/terminal/safe-env.ts: a daemon-level
459
- // opt-out must not propagate into agent-spawned shells and disarm the
460
- // guard for tests run from there.
461
- if (process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS === "1") {
462
- return;
463
- }
464
- const tmpRoot = canonicalizeForWorkspaceGuard(tmpdir());
465
- const resolved = canonicalizeForWorkspaceGuard(dir);
466
- if (resolved !== tmpRoot && !resolved.startsWith(tmpRoot + sep)) {
467
- throw new Error(
468
- [
469
- `Refusing to use ${dir} (resolves to ${resolved}) in a test process: it is not under the temp directory (${tmpRoot}).`,
470
- "",
471
- "Tests must only touch an ephemeral workspace; a real one would expose",
472
- "live assistant state to destructive test fixtures. This usually means",
473
- "`bun test` ran from a cwd without the repo bunfig.toml, so the test",
474
- "preload that redirects VELLUM_WORKSPACE_DIR to a tmpdir never loaded.",
475
- "Run tests from the assistant package root, or set",
476
- "VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS=1 to bypass deliberately.",
477
- ].join("\n"),
478
- );
479
- }
407
+ assertEphemeralInTests(dir, {
408
+ // Shared with assertTestDbIsIsolated() in persistence/db-connection.ts.
409
+ allowEnvVar: "VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS",
410
+ runHint: "Run tests from the assistant package root.",
411
+ });
480
412
  }
481
413
 
482
414
  /**
@@ -231,6 +231,19 @@ export async function listProcessTableAsync(
231
231
  );
232
232
  }
233
233
 
234
+ /**
235
+ * The process-table row for `pid`, or null when the process is gone or the
236
+ * table cannot be read. Callers get `ppid` alongside the command line, so
237
+ * ownership and identity can be decided from a single snapshot.
238
+ */
239
+ function findProcessRow(pid: number): ProcessTableRow | null {
240
+ try {
241
+ return listProcessTable().find((row) => row.pid === pid) ?? null;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
234
247
  export function readRawProcessCommand(pid: number): string | null {
235
248
  if (process.platform === "linux") {
236
249
  try {
@@ -243,9 +256,5 @@ export function readRawProcessCommand(pid: number): string | null {
243
256
  }
244
257
  }
245
258
 
246
- try {
247
- return listProcessTable().find((row) => row.pid === pid)?.command ?? null;
248
- } catch {
249
- return null;
250
- }
259
+ return findProcessRow(pid)?.command ?? null;
251
260
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Process-tree ownership for background worker processes.
3
+ *
4
+ * A worker's parent IS its owner. The process table is therefore the
5
+ * authoritative answer to "whose worker is this": unlike a PID file it
6
+ * survives a crash, and a second owner cannot overwrite it.
7
+ *
8
+ * Nothing here is specific to one worker kind. `listWorkerProcesses` matches
9
+ * on the absolute worker path the caller passes, and `classifyWorkerOwnership`
10
+ * takes injected liveness and PID-1 predicates, so any worker whose spawner
11
+ * knows its own PID can use both.
12
+ */
13
+
14
+ import { listProcessTable, readRawProcessCommand } from "./process-table.js";
15
+
16
+ export interface WorkerProcess {
17
+ pid: number;
18
+ ppid: number;
19
+ }
20
+
21
+ /**
22
+ * What this process may do with a worker it found in the process table.
23
+ *
24
+ * - `reclaim`: parented to us, a worker we started and lost track of. Reaping
25
+ * it is what enforces one live worker per owning process.
26
+ * - `orphan`: reparented to init, or its owner is gone. Nobody else will ever
27
+ * clean it up.
28
+ * - `foreign`: owned by another live process. The memory-worker process runs
29
+ * its own backend against this same workspace and is entitled to its own
30
+ * worker; signalling it is what made the two replace each other in a loop.
31
+ *
32
+ * A parent of PID 1 is ambiguous, and resolving it wrongly in either direction
33
+ * costs something real. `docker-entrypoint.sh` execs the daemon, so PID 1 can
34
+ * BE the daemon and its child a healthy sibling's worker. Under `docker run
35
+ * --init`, and on any host, PID 1 is an init process instead, so the same
36
+ * parentage means the owner died and the worker needs reclaiming. Callers
37
+ * therefore pass what PID 1 actually is rather than inferring it from whether
38
+ * the deployment is containerized, which is true in both container shapes.
39
+ */
40
+ export type WorkerOwnership = "reclaim" | "orphan" | "foreign";
41
+
42
+ export function classifyWorkerOwnership(
43
+ worker: WorkerProcess,
44
+ selfPid: number,
45
+ isOwnerAlive: (pid: number) => boolean,
46
+ pid1OwnsWorkers: boolean,
47
+ ): WorkerOwnership {
48
+ if (worker.ppid === selfPid) {
49
+ return "reclaim";
50
+ }
51
+ if (worker.ppid <= 1) {
52
+ return pid1OwnsWorkers ? "foreign" : "orphan";
53
+ }
54
+ if (!isOwnerAlive(worker.ppid)) {
55
+ return "orphan";
56
+ }
57
+ return "foreign";
58
+ }
59
+
60
+ /**
61
+ * Command lines that identify an assistant daemon, running from source
62
+ * (`.../daemon/main.ts`) or as the packaged binary.
63
+ */
64
+ const DAEMON_PROCESS_PATTERN = /vellum-daemon|[\\/]daemon[\\/]main/;
65
+
66
+ /**
67
+ * Whether a command line belongs to an assistant daemon.
68
+ *
69
+ * For a worker with exactly one legitimate owner, "is the owner alive" is not
70
+ * the same question as "does some process still hold that PID". The OS recycles
71
+ * PIDs, and a recycled owner PID would otherwise leave a stranded worker
72
+ * looking owned, and therefore untouchable, indefinitely.
73
+ */
74
+ export function isDaemonCommand(command: string | null): boolean {
75
+ return command != null && DAEMON_PROCESS_PATTERN.test(command);
76
+ }
77
+
78
+ /**
79
+ * Whether PID 1 is an assistant daemon rather than an init process.
80
+ *
81
+ * True only where the daemon was exec'd as PID 1 (`docker-entrypoint.sh`),
82
+ * which is what makes a worker parented to 1 a live sibling's rather than an
83
+ * orphan. Under `docker run --init` PID 1 is docker-init and under launchd or
84
+ * systemd it is the system init, so both answer false and PID-1 orphans stay
85
+ * reclaimable. Unreadable means false, which keeps the reclaiming behaviour.
86
+ *
87
+ * Callers that already hold a process-table snapshot pass PID 1's command line
88
+ * so this does not read the table a second time.
89
+ */
90
+ export function pid1OwnsWorkers(pid1Command?: string | null): boolean {
91
+ if (process.pid === 1) {
92
+ return true;
93
+ }
94
+ return isDaemonCommand(
95
+ pid1Command === undefined ? readRawProcessCommand(1) : pid1Command,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Live worker processes matching `workerPath`, as `(pid, ppid)` pairs.
101
+ *
102
+ * `workerPath` is an absolute path unique to one worker of one install, so a
103
+ * sibling instance's workers never match. `model`, when given, must appear as
104
+ * a whole argv token rather than a substring, so a backend for `bge-small`
105
+ * cannot reclaim a live `bge-small-v2` worker.
106
+ *
107
+ * Raw command lines are read for matching only, never stored or logged:
108
+ * process arguments can carry secrets (see the redaction note in
109
+ * `util/process-tree.ts`).
110
+ */
111
+ export function listWorkerProcesses(
112
+ workerPath: string,
113
+ model?: string,
114
+ ): WorkerProcess[] {
115
+ try {
116
+ return listProcessTable()
117
+ .filter(
118
+ (row) =>
119
+ row.command.includes(workerPath) &&
120
+ (!model || row.command.split(/\s+/).includes(model)),
121
+ )
122
+ .map(({ pid, ppid }) => ({ pid, ppid }));
123
+ } catch {
124
+ return [];
125
+ }
126
+ }