@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.
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
- package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/openapi.yaml +5 -5
- package/package.json +1 -1
- package/src/__tests__/credential-health-service.test.ts +45 -0
- package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
- package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
- package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
- package/src/__tests__/guardian-gateway-sim.test.ts +47 -0
- package/src/__tests__/guardian-gateway-sim.ts +5 -1
- package/src/__tests__/non-member-access-request.test.ts +12 -0
- package/src/channels/gateway-guardian-requests.ts +4 -3
- package/src/cli/commands/__tests__/browser.test.ts +9 -7
- package/src/credential-health/credential-health-service.ts +37 -6
- package/src/daemon/lifecycle.ts +10 -13
- package/src/oauth/scope-utils.ts +48 -0
- package/src/persistence/db-connection.ts +3 -21
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
- package/src/persistence/embeddings/embedding-local.ts +7 -112
- package/src/runtime/access-request-helper.ts +15 -7
- package/src/runtime/routes/integrations/slack/channel.ts +39 -18
- package/src/runtime/tool-grant-request-helper.ts +4 -0
- package/src/security/oauth2.ts +2 -11
- package/src/tools/browser/__tests__/browser-status.test.ts +9 -7
- package/src/tools/browser/browser-execution.ts +4 -40
- package/src/tools/browser/browser-status-constants.ts +0 -6
- package/src/util/__tests__/worker-process-command.test.ts +246 -1
- package/src/util/platform.ts +13 -81
- package/src/util/process-table.ts +14 -5
- package/src/util/worker-ownership.ts +126 -0
- package/src/util/worker-process.ts +369 -20
|
@@ -19,8 +19,21 @@ import {
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
|
|
22
|
-
import { getCurrentLogFilePath } from "./logger.js";
|
|
22
|
+
import { getCurrentLogFilePath, getLogger } from "./logger.js";
|
|
23
|
+
import { isProcessAlive } from "./process-liveness.js";
|
|
24
|
+
import {
|
|
25
|
+
listProcessTable,
|
|
26
|
+
type ProcessTableRow,
|
|
27
|
+
readRawProcessCommand,
|
|
28
|
+
} from "./process-table.js";
|
|
23
29
|
import { workerMemoryEnv } from "./worker-memory.js";
|
|
30
|
+
import {
|
|
31
|
+
classifyWorkerOwnership,
|
|
32
|
+
isDaemonCommand,
|
|
33
|
+
pid1OwnsWorkers,
|
|
34
|
+
} from "./worker-ownership.js";
|
|
35
|
+
|
|
36
|
+
const log = getLogger("worker-process");
|
|
24
37
|
|
|
25
38
|
export interface WorkerProcessStatus {
|
|
26
39
|
status: "running" | "not_running";
|
|
@@ -135,21 +148,40 @@ interface WorkerCommandRuntime {
|
|
|
135
148
|
executableExists: (path: string) => boolean;
|
|
136
149
|
}
|
|
137
150
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
packagedEntry: PackagedWorkerEntry | undefined,
|
|
141
|
-
runtime: WorkerCommandRuntime = {
|
|
151
|
+
function defaultWorkerCommandRuntime(): WorkerCommandRuntime {
|
|
152
|
+
return {
|
|
142
153
|
platform: process.platform,
|
|
143
154
|
execPath: process.execPath,
|
|
144
155
|
executableExists: existsSync,
|
|
145
|
-
}
|
|
146
|
-
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* How a packaged Windows runtime spawns this worker, or null when the worker
|
|
161
|
+
* runs from source. Only packaged Windows runtimes ship the executable.
|
|
162
|
+
*/
|
|
163
|
+
function packagedWorkerSpawn(
|
|
164
|
+
packagedEntry: PackagedWorkerEntry | undefined,
|
|
165
|
+
runtime: WorkerCommandRuntime,
|
|
166
|
+
): { executable: string; entry: PackagedWorkerEntry } | null {
|
|
147
167
|
if (runtime.platform === "win32" && packagedEntry) {
|
|
148
168
|
const executable = join(dirname(runtime.execPath), "vellum-worker.exe");
|
|
149
169
|
if (runtime.executableExists(executable)) {
|
|
150
|
-
return
|
|
170
|
+
return { executable, entry: packagedEntry };
|
|
151
171
|
}
|
|
152
172
|
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function resolveWorkerCommand(
|
|
177
|
+
entry: URL,
|
|
178
|
+
packagedEntry: PackagedWorkerEntry | undefined,
|
|
179
|
+
runtime: WorkerCommandRuntime = defaultWorkerCommandRuntime(),
|
|
180
|
+
): string[] {
|
|
181
|
+
const packaged = packagedWorkerSpawn(packagedEntry, runtime);
|
|
182
|
+
if (packaged) {
|
|
183
|
+
return [packaged.executable, packaged.entry];
|
|
184
|
+
}
|
|
153
185
|
return ["bun", "--smol", "run", fileURLToPath(entry)];
|
|
154
186
|
}
|
|
155
187
|
|
|
@@ -194,10 +226,310 @@ async function waitForWorkerPidFile(
|
|
|
194
226
|
return existsSync(pidPath) ? "ready" : "timeout";
|
|
195
227
|
}
|
|
196
228
|
|
|
229
|
+
/** Path separators differ by platform; compare command lines on one form. */
|
|
230
|
+
function normalizeSeparators(value: string): string {
|
|
231
|
+
return value.replaceAll("\\", "/");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether a command line carries every fragment that marks this worker. */
|
|
235
|
+
function matchesSignature(
|
|
236
|
+
command: string,
|
|
237
|
+
signature: readonly string[],
|
|
238
|
+
): boolean {
|
|
239
|
+
const normalized = normalizeSeparators(command);
|
|
240
|
+
return signature.every((part) =>
|
|
241
|
+
normalized.includes(normalizeSeparators(part)),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Fragments of a command line that together mark a process as this worker,
|
|
247
|
+
* whichever install it came from. All must be present.
|
|
248
|
+
*
|
|
249
|
+
* This is a safety guard, not the ownership test. Ownership is decided by
|
|
250
|
+
* parentage; this only keeps us from signalling a process that happens to hold
|
|
251
|
+
* a recycled PID. Three trailing path segments are specific enough
|
|
252
|
+
* (`src/schedule/worker.ts`, `defaults/memory/worker.ts`) that an unrelated
|
|
253
|
+
* program running some other `worker.ts` does not match. Packaged workers all
|
|
254
|
+
* run one executable and are told apart by the subcommand, so the signature
|
|
255
|
+
* carries both: matching on the executable alone would let one packaged
|
|
256
|
+
* worker's slot reclaim another's process.
|
|
257
|
+
*/
|
|
258
|
+
export function workerKindSignature(
|
|
259
|
+
entry: URL,
|
|
260
|
+
packagedEntry: PackagedWorkerEntry | undefined,
|
|
261
|
+
runtime: WorkerCommandRuntime = defaultWorkerCommandRuntime(),
|
|
262
|
+
): readonly string[] {
|
|
263
|
+
const packaged = packagedWorkerSpawn(packagedEntry, runtime);
|
|
264
|
+
if (packaged) {
|
|
265
|
+
// Matched as separate fragments rather than one adjacent string: a Windows
|
|
266
|
+
// command line may quote the executable path, so the two are not reliably
|
|
267
|
+
// neighbours.
|
|
268
|
+
return ["vellum-worker", packaged.entry];
|
|
269
|
+
}
|
|
270
|
+
return [
|
|
271
|
+
normalizeSeparators(fileURLToPath(entry))
|
|
272
|
+
.split("/")
|
|
273
|
+
.filter(Boolean)
|
|
274
|
+
.slice(-3)
|
|
275
|
+
.join("/"),
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* What became of the orphan after an awaited gap, from liveness plus a fresh
|
|
281
|
+
* command-line read.
|
|
282
|
+
*
|
|
283
|
+
* - `gone`: the PID is dead, or alive under a command line that is not this
|
|
284
|
+
* worker (the orphan exited and the OS recycled its PID). The slot may be
|
|
285
|
+
* released.
|
|
286
|
+
* - `orphan`: still alive and still reads as this worker. Escalation may
|
|
287
|
+
* proceed.
|
|
288
|
+
* - `identity-unreadable`: alive, but the command line could not be read, so
|
|
289
|
+
* "still the orphan" and "recycled PID" cannot be told apart. Uncertainty
|
|
290
|
+
* must resolve to reuse: no signal, and the slot must NOT be released, or
|
|
291
|
+
* a second worker would be spawned next to a live one.
|
|
292
|
+
*/
|
|
293
|
+
export type OrphanFate = "gone" | "orphan" | "identity-unreadable";
|
|
294
|
+
|
|
295
|
+
export function classifyOrphanAfterWait(
|
|
296
|
+
alive: boolean,
|
|
297
|
+
command: string | null,
|
|
298
|
+
signature: readonly string[],
|
|
299
|
+
): OrphanFate {
|
|
300
|
+
if (!alive) {
|
|
301
|
+
return "gone";
|
|
302
|
+
}
|
|
303
|
+
if (command == null) {
|
|
304
|
+
return "identity-unreadable";
|
|
305
|
+
}
|
|
306
|
+
return matchesSignature(command, signature) ? "orphan" : "gone";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Re-read the orphan's fate for `pid` from the live process table. */
|
|
310
|
+
function orphanFate(pid: number, signature: readonly string[]): OrphanFate {
|
|
311
|
+
return classifyOrphanAfterWait(
|
|
312
|
+
isProcessAlive(pid),
|
|
313
|
+
readRawProcessCommand(pid),
|
|
314
|
+
signature,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* How long an orphaned worker gets to exit after SIGTERM.
|
|
320
|
+
*
|
|
321
|
+
* Derived from the longest shutdown any worker can legitimately take, not
|
|
322
|
+
* picked. The memory worker reaps the ONNX embedding subprocess it owns before
|
|
323
|
+
* exiting and bounds that at `EMBEDDING_SHUTDOWN_BUDGET_MS + 1s`, so a shorter
|
|
324
|
+
* ceiling here would SIGKILL it mid-reap and strand the very subprocess that
|
|
325
|
+
* reap exists to collect. A test asserts this stays above that bound.
|
|
326
|
+
*/
|
|
327
|
+
export const ORPHAN_STOP_TIMEOUT_MS = 15_000;
|
|
328
|
+
|
|
329
|
+
/** Poll interval while waiting for a stopped worker to exit. */
|
|
330
|
+
const ORPHAN_STOP_POLL_INTERVAL_MS = 100;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* How long to wait for a SIGKILLed worker to actually disappear. Signal
|
|
334
|
+
* delivery and reaping are asynchronous, so a liveness probe taken straight
|
|
335
|
+
* after the kill can still see a process that is on its way out.
|
|
336
|
+
*/
|
|
337
|
+
const ORPHAN_KILL_CONFIRM_MS = 1_000;
|
|
338
|
+
|
|
339
|
+
/** Poll until `pid` is gone or `timeoutMs` elapses. */
|
|
340
|
+
async function waitForExit(pid: number, timeoutMs: number): Promise<void> {
|
|
341
|
+
const deadline = Date.now() + timeoutMs;
|
|
342
|
+
while (Date.now() < deadline && isProcessAlive(pid)) {
|
|
343
|
+
await Bun.sleep(ORPHAN_STOP_POLL_INTERVAL_MS);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Stop a worker orphaned by a previous owner and release its PID file.
|
|
349
|
+
* Reports whether it actually exited.
|
|
350
|
+
*
|
|
351
|
+
* Escalates to SIGKILL because the orphan runs a different generation's
|
|
352
|
+
* shutdown path, which this process cannot make assumptions about. Signalling
|
|
353
|
+
* can still fail outright, most plausibly EPERM for a process owned by another
|
|
354
|
+
* user, so the PID file is released only once the process is confirmed gone:
|
|
355
|
+
* dropping it while the worker still runs would let the caller spawn a second
|
|
356
|
+
* one against the same workspace. The unlink is also identity-checked, since a
|
|
357
|
+
* concurrent launcher may already have replaced the worker and deleting that
|
|
358
|
+
* successor's entry would strand it.
|
|
359
|
+
*/
|
|
360
|
+
async function stopOrphanedWorker(
|
|
361
|
+
pid: number,
|
|
362
|
+
pidPath: string,
|
|
363
|
+
workerLabel: string,
|
|
364
|
+
signature: readonly string[],
|
|
365
|
+
): Promise<boolean> {
|
|
366
|
+
try {
|
|
367
|
+
process.kill(pid, "SIGTERM");
|
|
368
|
+
} catch {
|
|
369
|
+
// Exited between the probe and the signal.
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
await waitForExit(pid, ORPHAN_STOP_TIMEOUT_MS);
|
|
373
|
+
// Identity is re-proven after every awaited gap, not carried by the PID:
|
|
374
|
+
// the orphan may have exited during the wait and the OS may have handed its
|
|
375
|
+
// PID to a stranger. Only a positive re-match escalates, and only a proven
|
|
376
|
+
// "gone" releases the slot.
|
|
377
|
+
let fate = orphanFate(pid, signature);
|
|
378
|
+
if (fate === "orphan") {
|
|
379
|
+
try {
|
|
380
|
+
process.kill(pid, "SIGKILL");
|
|
381
|
+
} catch {
|
|
382
|
+
// Best-effort.
|
|
383
|
+
}
|
|
384
|
+
await waitForExit(pid, ORPHAN_KILL_CONFIRM_MS);
|
|
385
|
+
fate = orphanFate(pid, signature);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (fate !== "gone") {
|
|
389
|
+
log.warn(
|
|
390
|
+
{ pid, pidPath, fate },
|
|
391
|
+
fate === "orphan"
|
|
392
|
+
? `${workerLabel} orphaned by a previous owner could not be stopped; reusing it rather than running a second one`
|
|
393
|
+
: `${workerLabel} is still live but its identity could not be re-read; reusing it rather than risking a duplicate`,
|
|
394
|
+
);
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
unlinkPidFileIfNames(pidPath, pid);
|
|
399
|
+
log.warn(
|
|
400
|
+
{ pid, pidPath },
|
|
401
|
+
`${workerLabel} orphaned by a previous owner was stopped so this process can start its own`,
|
|
402
|
+
);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* What to do about the process a worker's PID file currently names.
|
|
408
|
+
*
|
|
409
|
+
* - `adopt`: reuse it. Either it is this process's own worker, or it belongs
|
|
410
|
+
* to another live owner, or it is not recognisably one of our workers at
|
|
411
|
+
* all and must never be signalled.
|
|
412
|
+
* - `reclaim`: an orphan left by an owner that is gone. Stop it, then spawn.
|
|
413
|
+
* - `spawn`: nothing is holding the slot.
|
|
414
|
+
*/
|
|
415
|
+
export type WorkerSlotDecision =
|
|
416
|
+
| { action: "adopt"; pid: number }
|
|
417
|
+
| { action: "reclaim"; pid: number }
|
|
418
|
+
| { action: "spawn" };
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The whole safety decision, with no IO, so every branch is testable.
|
|
422
|
+
*
|
|
423
|
+
* `reclaim` is the only outcome that signals a process, and it requires two
|
|
424
|
+
* independent things to agree: the command line marks the process as this
|
|
425
|
+
* worker, and parentage says no daemon owns it. A missing row, an unreadable
|
|
426
|
+
* command line, and a command line that does not match all fall back to
|
|
427
|
+
* `adopt`, so an uncertain answer never costs a process its life.
|
|
428
|
+
*
|
|
429
|
+
* `isOwnerAlive` is the caller's definition of a legitimate owner. These
|
|
430
|
+
* workers have exactly one, the daemon, so passing a plain liveness probe
|
|
431
|
+
* would let a recycled owner PID keep a stranded worker looking owned forever.
|
|
432
|
+
* The embed worker passes a liveness probe instead, because two processes may
|
|
433
|
+
* each legitimately run one of its workers.
|
|
434
|
+
*/
|
|
435
|
+
export function decideWorkerSlot(
|
|
436
|
+
status: WorkerProcessStatus,
|
|
437
|
+
row: Pick<ProcessTableRow, "pid" | "ppid" | "command"> | null,
|
|
438
|
+
signature: readonly string[],
|
|
439
|
+
selfPid: number,
|
|
440
|
+
isOwnerAlive: (pid: number) => boolean,
|
|
441
|
+
pid1OwnsWorkers: boolean,
|
|
442
|
+
): WorkerSlotDecision {
|
|
443
|
+
if (status.status !== "running" || status.pid == null) {
|
|
444
|
+
return { action: "spawn" };
|
|
445
|
+
}
|
|
446
|
+
if (!row) {
|
|
447
|
+
return { action: "adopt", pid: status.pid };
|
|
448
|
+
}
|
|
449
|
+
if (!matchesSignature(row.command, signature)) {
|
|
450
|
+
return { action: "adopt", pid: status.pid };
|
|
451
|
+
}
|
|
452
|
+
const ownership = classifyWorkerOwnership(
|
|
453
|
+
row,
|
|
454
|
+
selfPid,
|
|
455
|
+
isOwnerAlive,
|
|
456
|
+
pid1OwnsWorkers,
|
|
457
|
+
);
|
|
458
|
+
return ownership === "orphan"
|
|
459
|
+
? { action: "reclaim", pid: status.pid }
|
|
460
|
+
: { action: "adopt", pid: status.pid };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The slot decision for the worker at `pidPath`, made without yielding.
|
|
465
|
+
*
|
|
466
|
+
* Callers spawn inside their own synchronous prefix, so this stays sync: an
|
|
467
|
+
* `await` here would let a caller's next tick run before the child exists.
|
|
468
|
+
*/
|
|
469
|
+
function inspectWorkerSlot(
|
|
470
|
+
pidPath: string,
|
|
471
|
+
signature: readonly string[],
|
|
472
|
+
): WorkerSlotDecision {
|
|
473
|
+
const status = probeWorkerPidFile(pidPath);
|
|
474
|
+
if (status.status !== "running" || status.pid == null) {
|
|
475
|
+
return { action: "spawn" };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// The worker's row, its owner's identity, and what PID 1 is all come from
|
|
479
|
+
// one snapshot. Reading the table is a subprocess on most platforms, so
|
|
480
|
+
// asking three separate times would be three of them per worker.
|
|
481
|
+
let table: readonly ProcessTableRow[];
|
|
482
|
+
try {
|
|
483
|
+
table = listProcessTable();
|
|
484
|
+
} catch {
|
|
485
|
+
table = [];
|
|
486
|
+
}
|
|
487
|
+
const commandOf = (pid: number): string | null =>
|
|
488
|
+
table.find((row) => row.pid === pid)?.command ?? null;
|
|
489
|
+
|
|
490
|
+
const row = table.find((entry) => entry.pid === status.pid) ?? null;
|
|
491
|
+
if (row && !matchesSignature(row.command, signature)) {
|
|
492
|
+
log.info(
|
|
493
|
+
{ pid: row.pid, pidPath },
|
|
494
|
+
"Worker PID file names a live process this runtime does not recognise as its worker; reusing it rather than signalling it",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return decideWorkerSlot(
|
|
499
|
+
status,
|
|
500
|
+
row,
|
|
501
|
+
signature,
|
|
502
|
+
process.pid,
|
|
503
|
+
(pid) => isDaemonCommand(commandOf(pid)),
|
|
504
|
+
pid1OwnsWorkers(commandOf(1)),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Stop the orphan holding the slot, then report a PID to adopt instead of
|
|
510
|
+
* spawning: either the orphan itself when it could not be stopped, or a
|
|
511
|
+
* replacement a concurrent launcher brought up while we waited.
|
|
512
|
+
*/
|
|
513
|
+
async function reclaimWorkerSlot(
|
|
514
|
+
pid: number,
|
|
515
|
+
pidPath: string,
|
|
516
|
+
workerLabel: string,
|
|
517
|
+
signature: readonly string[],
|
|
518
|
+
): Promise<number | null> {
|
|
519
|
+
if (!(await stopOrphanedWorker(pid, pidPath, workerLabel, signature))) {
|
|
520
|
+
// Still running and beyond our reach. One stale worker beats two live ones.
|
|
521
|
+
return pid;
|
|
522
|
+
}
|
|
523
|
+
const replacement = probeWorkerPidFile(pidPath);
|
|
524
|
+
return replacement.status === "running" && replacement.pid != null
|
|
525
|
+
? replacement.pid
|
|
526
|
+
: null;
|
|
527
|
+
}
|
|
528
|
+
|
|
197
529
|
/**
|
|
198
530
|
* Spawn a worker entry script as a background process and wait for it to
|
|
199
531
|
* report readiness by writing its PID file. `opts.detached` (default `true`)
|
|
200
|
-
* controls process parentage
|
|
532
|
+
* controls process parentage, see {@link SpawnWorkerProcessOptions}.
|
|
201
533
|
*
|
|
202
534
|
* If a worker is already running (per the PID file), returns its PID with
|
|
203
535
|
* `alreadyRunning: true` rather than spawning a second one. Throws
|
|
@@ -220,9 +552,21 @@ export async function spawnWorkerProcess(args: {
|
|
|
220
552
|
const pidPollIntervalMs = opts.pidPollIntervalMs ?? PID_FILE_POLL_INTERVAL_MS;
|
|
221
553
|
const detached = opts.detached ?? true;
|
|
222
554
|
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
555
|
+
const signature = workerKindSignature(args.entry, args.packagedEntry);
|
|
556
|
+
const decision = inspectWorkerSlot(args.pidPath, signature);
|
|
557
|
+
if (decision.action === "adopt") {
|
|
558
|
+
return { pid: decision.pid, alreadyRunning: true };
|
|
559
|
+
}
|
|
560
|
+
if (decision.action === "reclaim") {
|
|
561
|
+
const adopted = await reclaimWorkerSlot(
|
|
562
|
+
decision.pid,
|
|
563
|
+
args.pidPath,
|
|
564
|
+
args.workerLabel,
|
|
565
|
+
signature,
|
|
566
|
+
);
|
|
567
|
+
if (adopted != null) {
|
|
568
|
+
return { pid: adopted, alreadyRunning: true };
|
|
569
|
+
}
|
|
226
570
|
}
|
|
227
571
|
|
|
228
572
|
// Pipe the worker's stderr into the same daily log file the daemon writes
|
|
@@ -319,23 +663,28 @@ export function stopWorkerProcess(pidPath: string): WorkerProcessStatus {
|
|
|
319
663
|
// ---------------------------------------------------------------------------
|
|
320
664
|
|
|
321
665
|
/**
|
|
322
|
-
* Remove
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* successor's entry
|
|
326
|
-
* duplicate and orphan the successor.
|
|
666
|
+
* Remove `pidPath` only while it still names `pid`.
|
|
667
|
+
*
|
|
668
|
+
* A successor worker overwrites the file with its own PID at startup, so an
|
|
669
|
+
* unconditional unlink would delete the successor's entry: the liveness
|
|
670
|
+
* supervisor would then respawn a duplicate and orphan the successor.
|
|
327
671
|
*/
|
|
328
|
-
export function
|
|
672
|
+
export function unlinkPidFileIfNames(pidPath: string, pid: number): void {
|
|
329
673
|
try {
|
|
330
674
|
const raw = readFileSync(pidPath, "utf-8").trim();
|
|
331
|
-
if (parseInt(raw, 10) ===
|
|
675
|
+
if (parseInt(raw, 10) === pid) {
|
|
332
676
|
unlinkSync(pidPath);
|
|
333
677
|
}
|
|
334
678
|
} catch {
|
|
335
|
-
// best-effort
|
|
679
|
+
// best-effort: a missing or unreadable file needs no cleanup
|
|
336
680
|
}
|
|
337
681
|
}
|
|
338
682
|
|
|
683
|
+
/** Release this process's own PID file on the way out. */
|
|
684
|
+
export function cleanupWorkerPidFile(pidPath: string): void {
|
|
685
|
+
unlinkPidFileIfNames(pidPath, process.pid);
|
|
686
|
+
}
|
|
687
|
+
|
|
339
688
|
/**
|
|
340
689
|
* Watch the worker PID file and evict this process when the file stops
|
|
341
690
|
* naming it. The PID file is a worker's sole tracking handle: stop
|