@ricsam/r5d-worker 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,10 @@ Visible branch checkouts are the user/agent workbench. Their `origin` remains th
19
19
 
20
20
  Worker labels are mandatory and unique per user. Choose labels that describe host capabilities, such as `macos`, `linux`, `ios`, or `ec2-build`.
21
21
 
22
+ `r5d-worker start` keeps a lightweight supervisor process attached to the launching terminal or service. When both r5d CLIs are updated from User Settings, the connected runtime verifies the installed versions, exits with a reload signal, and the supervisor reconnects using the new worker package. Updates only run while the worker has no active commands or shells.
23
+
24
+ One-click updates use the current user's global npm prefix and never invoke `sudo`. If that prefix is not writable, User Settings provides the exact command to run manually.
25
+
22
26
  The agent runs commands through the virtual shell command:
23
27
 
24
28
  ```sh
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var cli_update_exports = {};
20
+ __export(cli_update_exports, {
21
+ getManualCliUpdateCommand: () => getManualCliUpdateCommand,
22
+ installCliUpdate: () => installCliUpdate,
23
+ parseCliVersion: () => parseCliVersion,
24
+ readInstalledCliVersion: () => readInstalledCliVersion,
25
+ runCommand: () => runCommand
26
+ });
27
+ module.exports = __toCommonJS(cli_update_exports);
28
+ const UPDATE_OUTPUT_LIMIT = 8e3;
29
+ function collectOutput(value) {
30
+ if (!value || typeof value === "number") {
31
+ return Promise.resolve("");
32
+ }
33
+ return new Response(value).text();
34
+ }
35
+ async function runCommand(argv) {
36
+ const child = Bun.spawn(argv, {
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ env: process.env
40
+ });
41
+ const [stdout, stderr, exitCode] = await Promise.all([collectOutput(child.stdout), collectOutput(child.stderr), child.exited]);
42
+ return { exitCode, stdout, stderr };
43
+ }
44
+ function parseCliVersion(cliName, output) {
45
+ const escapedName = cliName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ const match = output.trim().match(new RegExp(`(?:^|\\n)${escapedName}\\s+(\\S+)`));
47
+ return match?.[1]?.trim() || null;
48
+ }
49
+ function readInstalledCliVersion(cliName) {
50
+ try {
51
+ const result = Bun.spawnSync([cliName, "--version"], {
52
+ stdout: "pipe",
53
+ stderr: "pipe",
54
+ env: process.env
55
+ });
56
+ if (result.exitCode !== 0) {
57
+ return null;
58
+ }
59
+ return parseCliVersion(cliName, Buffer.from(result.stdout).toString("utf8"));
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ function shellQuote(value) {
65
+ return `'${value.replaceAll("'", `'\\''`)}'`;
66
+ }
67
+ function getManualCliUpdateCommand(input) {
68
+ return `npm install -g --omit=dev ${shellQuote(input.workerPackageSpec)} ${shellQuote(input.r5dctlPackageSpec)}`;
69
+ }
70
+ function commandErrorDetail(result) {
71
+ const detail = `${result.stderr.trim()}${result.stderr.trim() && result.stdout.trim() ? "\n" : ""}${result.stdout.trim()}`.trim();
72
+ if (!detail) {
73
+ return `exit code ${result.exitCode}`;
74
+ }
75
+ return detail.slice(-UPDATE_OUTPUT_LIMIT);
76
+ }
77
+ async function readVerifiedVersion(cliName, targetVersion, runner) {
78
+ const result = await runner([cliName, "--version"]);
79
+ const installedVersion = result.exitCode === 0 ? parseCliVersion(cliName, result.stdout) : null;
80
+ if (!installedVersion) {
81
+ throw new Error(`${cliName} was installed but is not runnable: ${commandErrorDetail(result)}`);
82
+ }
83
+ if (installedVersion !== targetVersion) {
84
+ throw new Error(`${cliName} ${installedVersion} was installed; expected ${targetVersion}`);
85
+ }
86
+ return installedVersion;
87
+ }
88
+ async function installCliUpdate(input, runner = runCommand) {
89
+ const installArgs = ["npm", "install", "-g", "--omit=dev", input.workerPackageSpec, input.r5dctlPackageSpec];
90
+ const result = await runner(installArgs);
91
+ if (result.exitCode !== 0) {
92
+ throw new Error(`CLI update failed: ${commandErrorDetail(result)}
93
+ Run manually: ${getManualCliUpdateCommand(input)}`);
94
+ }
95
+ const [installedWorkerVersion, installedR5dctlVersion] = await Promise.all([
96
+ readVerifiedVersion("r5d-worker", input.targetWorkerVersion, runner),
97
+ readVerifiedVersion("r5dctl", input.targetR5dctlVersion, runner)
98
+ ]);
99
+ return { installedWorkerVersion, installedR5dctlVersion };
100
+ }
101
+ // Annotate the CommonJS export names for ESM import in node:
102
+ 0 && (module.exports = {
103
+ getManualCliUpdateCommand,
104
+ installCliUpdate,
105
+ parseCliVersion,
106
+ readInstalledCliVersion,
107
+ runCommand
108
+ });
package/dist/cjs/main.cjs CHANGED
@@ -46,8 +46,11 @@ var import_node_path = __toESM(require("node:path"), 1);
46
46
  var import_node_crypto = require("node:crypto");
47
47
  var import_node_os = __toESM(require("node:os"), 1);
48
48
  var import_node_child_process = require("node:child_process");
49
+ var import_cli_update = require("./cli-update.cjs");
49
50
  var import_git_identity = require("./git-identity.cjs");
50
51
  var import_heartbeat = require("./heartbeat.cjs");
52
+ var import_process_tree = require("./process-tree.cjs");
53
+ var import_supervisor = require("./supervisor.cjs");
51
54
  const import_meta = {};
52
55
  const DEFAULT_BASE_URL = "https://r5d.dev";
53
56
  const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
@@ -1815,6 +1818,7 @@ async function executeCommand(input) {
1815
1818
  cwd,
1816
1819
  stdout: "pipe",
1817
1820
  stderr: "pipe",
1821
+ detached: true,
1818
1822
  env: {
1819
1823
  ...process.env,
1820
1824
  ...containerRegistryEnv(),
@@ -1836,7 +1840,12 @@ async function executeCommand(input) {
1836
1840
  });
1837
1841
  if (input.message.timeoutMs) {
1838
1842
  timeout = setTimeout(() => {
1839
- subprocess.kill("SIGTERM");
1843
+ void (0, import_process_tree.terminateProcessTree)(subprocess).catch((error) => {
1844
+ process.stderr.write(
1845
+ `[r5d-worker] failed to terminate timed-out command ${input.message.runId}: ${error instanceof Error ? error.message : String(error)}
1846
+ `
1847
+ );
1848
+ });
1840
1849
  }, input.message.timeoutMs);
1841
1850
  }
1842
1851
  const [stdout, stderr, exitCode] = await Promise.all([
@@ -1872,6 +1881,8 @@ async function executeCommand(input) {
1872
1881
  async function executeStreamingCommand(input) {
1873
1882
  const startedAt = Date.now();
1874
1883
  let started = false;
1884
+ let timedOut = false;
1885
+ let timeout;
1875
1886
  try {
1876
1887
  if (cancelledProcessRuns.delete(input.message.runId)) {
1877
1888
  sendWorkerMessage(input.ws, {
@@ -1912,6 +1923,7 @@ async function executeStreamingCommand(input) {
1912
1923
  cwd,
1913
1924
  stdout: "pipe",
1914
1925
  stderr: "pipe",
1926
+ detached: true,
1915
1927
  env: {
1916
1928
  ...process.env,
1917
1929
  ...containerRegistryEnv(),
@@ -1938,6 +1950,17 @@ async function executeStreamingCommand(input) {
1938
1950
  requestId: input.message.requestId,
1939
1951
  runId: input.message.runId
1940
1952
  });
1953
+ if (input.message.timeoutMs) {
1954
+ timeout = setTimeout(() => {
1955
+ timedOut = true;
1956
+ void (0, import_process_tree.terminateProcessTree)(subprocess).catch((error) => {
1957
+ process.stderr.write(
1958
+ `[r5d-worker] failed to terminate timed-out process ${input.message.runId}: ${error instanceof Error ? error.message : String(error)}
1959
+ `
1960
+ );
1961
+ });
1962
+ }, input.message.timeoutMs);
1963
+ }
1941
1964
  const [exitCode] = await Promise.all([
1942
1965
  subprocess.exited,
1943
1966
  streamCommandOutput(subprocess.stdout, (data) => {
@@ -1961,7 +1984,8 @@ async function executeStreamingCommand(input) {
1961
1984
  type: "exec_exit",
1962
1985
  runId: input.message.runId,
1963
1986
  exitCode,
1964
- durationMs: Date.now() - startedAt
1987
+ durationMs: Date.now() - startedAt,
1988
+ ...timedOut ? { timedOut: true } : {}
1965
1989
  });
1966
1990
  } catch (error) {
1967
1991
  const message = error instanceof Error ? error.message : String(error);
@@ -1981,6 +2005,9 @@ async function executeStreamingCommand(input) {
1981
2005
  });
1982
2006
  }
1983
2007
  } finally {
2008
+ if (timeout) {
2009
+ clearTimeout(timeout);
2010
+ }
1984
2011
  activeProcesses.delete(input.message.runId);
1985
2012
  cancelledProcessRuns.delete(input.message.runId);
1986
2013
  }
@@ -2358,6 +2385,9 @@ async function startWorker(options) {
2358
2385
  import_node_fs.default.mkdirSync(planRoot, { recursive: true });
2359
2386
  const manifestByProjectId = /* @__PURE__ */ new Map();
2360
2387
  let repositorySyncQueue = Promise.resolve();
2388
+ let repositorySyncInProgress = false;
2389
+ let cliUpdateInProgress = false;
2390
+ let reloadAfterClose = false;
2361
2391
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
2362
2392
  headers: {
2363
2393
  Authorization: `Bearer ${token}`
@@ -2391,6 +2421,10 @@ async function startWorker(options) {
2391
2421
  arch: process.arch,
2392
2422
  pid: process.pid,
2393
2423
  version: getWorkerVersion(),
2424
+ r5dctlVersion: (0, import_cli_update.readInstalledCliVersion)("r5dctl"),
2425
+ capabilities: {
2426
+ updateClis: true
2427
+ },
2394
2428
  projectRoot: projectsRoot,
2395
2429
  artifactRoot,
2396
2430
  planRoot
@@ -2418,32 +2452,49 @@ async function startWorker(options) {
2418
2452
  return;
2419
2453
  }
2420
2454
  if (message.type === "sync_projects") {
2455
+ if (cliUpdateInProgress) {
2456
+ sendWorkerMessage(ws, {
2457
+ type: "sync_projects_result",
2458
+ requestId: message.requestId,
2459
+ result: {
2460
+ status: "blocked",
2461
+ results: [],
2462
+ blockedBy: []
2463
+ }
2464
+ });
2465
+ return;
2466
+ }
2421
2467
  let syncResult = { status: "completed", results: [], blockedBy: [] };
2422
2468
  const executeSync = async () => {
2423
- syncResult = await syncManifestProjectsFromInternal({
2424
- baseUrl,
2425
- token,
2426
- projectsRoot,
2427
- syncRoot,
2428
- manifests: [...manifestByProjectId.values()],
2429
- projectIds: message.projectIds
2430
- });
2431
- for (const result of syncResult.results) {
2432
- if (result.status !== "synced") continue;
2433
- try {
2434
- await syncProjectPlans({
2435
- baseUrl,
2436
- token,
2437
- projectId: result.projectId,
2438
- branchName: result.branchName,
2439
- planRoot
2440
- });
2441
- } catch (error) {
2442
- process.stderr.write(
2443
- `[r5d-worker] plan sync skipped for ${result.projectId}/${result.branchName}: ${error instanceof Error ? error.message : String(error)}
2469
+ repositorySyncInProgress = true;
2470
+ try {
2471
+ syncResult = await syncManifestProjectsFromInternal({
2472
+ baseUrl,
2473
+ token,
2474
+ projectsRoot,
2475
+ syncRoot,
2476
+ manifests: [...manifestByProjectId.values()],
2477
+ projectIds: message.projectIds
2478
+ });
2479
+ for (const result of syncResult.results) {
2480
+ if (result.status !== "synced") continue;
2481
+ try {
2482
+ await syncProjectPlans({
2483
+ baseUrl,
2484
+ token,
2485
+ projectId: result.projectId,
2486
+ branchName: result.branchName,
2487
+ planRoot
2488
+ });
2489
+ } catch (error) {
2490
+ process.stderr.write(
2491
+ `[r5d-worker] plan sync skipped for ${result.projectId}/${result.branchName}: ${error instanceof Error ? error.message : String(error)}
2444
2492
  `
2445
- );
2493
+ );
2494
+ }
2446
2495
  }
2496
+ } finally {
2497
+ repositorySyncInProgress = false;
2447
2498
  }
2448
2499
  };
2449
2500
  const queued = repositorySyncQueue.then(executeSync, executeSync);
@@ -2475,6 +2526,43 @@ async function startWorker(options) {
2475
2526
  });
2476
2527
  return;
2477
2528
  }
2529
+ if (message.type === "update_clis") {
2530
+ if (cliUpdateInProgress) {
2531
+ sendWorkerMessage(ws, {
2532
+ type: "update_clis_result",
2533
+ requestId: message.requestId,
2534
+ error: "A CLI update is already in progress"
2535
+ });
2536
+ return;
2537
+ }
2538
+ if (activeProcesses.size > 0 || activePtys.size > 0 || repositorySyncInProgress) {
2539
+ sendWorkerMessage(ws, {
2540
+ type: "update_clis_result",
2541
+ requestId: message.requestId,
2542
+ error: "Worker is busy. Stop active commands and shells, then retry the CLI update."
2543
+ });
2544
+ return;
2545
+ }
2546
+ cliUpdateInProgress = true;
2547
+ try {
2548
+ const result = await (0, import_cli_update.installCliUpdate)(message);
2549
+ sendWorkerMessage(ws, {
2550
+ type: "update_clis_result",
2551
+ requestId: message.requestId,
2552
+ ...result
2553
+ });
2554
+ reloadAfterClose = true;
2555
+ ws.close(1e3, "Activating updated CLIs");
2556
+ } catch (error) {
2557
+ cliUpdateInProgress = false;
2558
+ sendWorkerMessage(ws, {
2559
+ type: "update_clis_result",
2560
+ requestId: message.requestId,
2561
+ error: error instanceof Error ? error.message : String(error)
2562
+ });
2563
+ }
2564
+ return;
2565
+ }
2478
2566
  if (message.type === "ping") {
2479
2567
  lastServerHeartbeatAt = Date.now();
2480
2568
  ws.send(JSON.stringify({ type: "pong" }));
@@ -2482,23 +2570,41 @@ async function startWorker(options) {
2482
2570
  }
2483
2571
  if (message.type === "cancel") {
2484
2572
  const active = activeProcesses.get(message.runId);
2573
+ let cancelled = true;
2574
+ let cancelMessage;
2485
2575
  if (active) {
2486
- active.process.kill("SIGTERM");
2576
+ try {
2577
+ await (0, import_process_tree.terminateProcessTree)(active.process);
2578
+ cancelMessage = `Stopped ${active.command}`;
2579
+ } catch (error) {
2580
+ cancelled = false;
2581
+ cancelMessage = `Failed to stop ${active.command}: ${error instanceof Error ? error.message : String(error)}`;
2582
+ }
2487
2583
  } else {
2488
2584
  cancelledProcessRuns.add(message.runId);
2585
+ cancelMessage = "Cancellation queued before command start";
2489
2586
  }
2490
2587
  ws.send(
2491
2588
  JSON.stringify({
2492
2589
  type: "cancel_result",
2493
2590
  requestId: message.requestId,
2494
2591
  runId: message.runId,
2495
- cancelled: true,
2496
- message: active ? `Sent SIGTERM to ${active.command}` : "Cancellation queued before command start"
2592
+ cancelled,
2593
+ message: cancelMessage
2497
2594
  })
2498
2595
  );
2499
2596
  return;
2500
2597
  }
2501
2598
  if (message.type === "pty_open") {
2599
+ if (cliUpdateInProgress) {
2600
+ sendWorkerMessage(ws, {
2601
+ type: "pty_error",
2602
+ requestId: message.requestId,
2603
+ ptyId: message.ptyId,
2604
+ error: "Worker is activating a CLI update"
2605
+ });
2606
+ return;
2607
+ }
2502
2608
  await repositorySyncQueue;
2503
2609
  const manifest = manifestByProjectId.get(message.projectId);
2504
2610
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
@@ -2520,6 +2626,16 @@ async function startWorker(options) {
2520
2626
  return;
2521
2627
  }
2522
2628
  if (message.type === "exec") {
2629
+ if (cliUpdateInProgress) {
2630
+ sendWorkerMessage(ws, {
2631
+ type: "exec_result",
2632
+ requestId: message.requestId,
2633
+ stdout: "",
2634
+ stderr: "Worker is activating a CLI update",
2635
+ exitCode: 1
2636
+ });
2637
+ return;
2638
+ }
2523
2639
  await repositorySyncQueue;
2524
2640
  const manifest = manifestByProjectId.get(message.projectId);
2525
2641
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
@@ -2540,6 +2656,15 @@ async function startWorker(options) {
2540
2656
  return;
2541
2657
  }
2542
2658
  if (message.type === "exec_start") {
2659
+ if (cliUpdateInProgress) {
2660
+ sendWorkerMessage(ws, {
2661
+ type: "exec_start_error",
2662
+ requestId: message.requestId,
2663
+ runId: message.runId,
2664
+ error: "Worker is activating a CLI update"
2665
+ });
2666
+ return;
2667
+ }
2543
2668
  await repositorySyncQueue;
2544
2669
  sendWorkerMessage(ws, {
2545
2670
  type: "exec_accepted",
@@ -2637,6 +2762,9 @@ async function startWorker(options) {
2637
2762
  const reason = event.reason ? `: ${event.reason}` : "";
2638
2763
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
2639
2764
  `);
2765
+ if (reloadAfterClose) {
2766
+ process.exit(import_supervisor.WORKER_RELOAD_EXIT_CODE);
2767
+ }
2640
2768
  if (activeProcesses.size > 0) {
2641
2769
  const delayMs = 2e3;
2642
2770
  process.stderr.write(`[r5d-worker] ${activeProcesses.size} process(es) still active; reconnecting in ${delayMs}ms
@@ -2675,7 +2803,15 @@ async function main() {
2675
2803
  printVersion();
2676
2804
  return;
2677
2805
  }
2678
- await startWorker(parsed.options);
2806
+ if (process.env[import_supervisor.WORKER_RUNTIME_ENV] === "1") {
2807
+ delete process.env[import_supervisor.WORKER_RUNTIME_ENV];
2808
+ await startWorker(parsed.options);
2809
+ return;
2810
+ }
2811
+ const exitCode = await (0, import_supervisor.superviseWorkerRuntime)(process.argv.slice(2));
2812
+ if (exitCode !== 0) {
2813
+ process.exit(exitCode);
2814
+ }
2679
2815
  }
2680
2816
  function isCliEntrypoint() {
2681
2817
  if (import_meta.main) {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "type": "commonjs"
5
5
  }
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var process_tree_exports = {};
20
+ __export(process_tree_exports, {
21
+ terminateProcessTree: () => terminateProcessTree
22
+ });
23
+ module.exports = __toCommonJS(process_tree_exports);
24
+ const DEFAULT_TERMINATION_GRACE_MS = 2e3;
25
+ const DEFAULT_FORCE_KILL_WAIT_MS = 2e3;
26
+ const PROCESS_GROUP_POLL_INTERVAL_MS = 25;
27
+ const processTerminations = /* @__PURE__ */ new WeakMap();
28
+ function errorCode(error) {
29
+ if (!error || typeof error !== "object" || !("code" in error)) {
30
+ return void 0;
31
+ }
32
+ return typeof error.code === "string" ? error.code : void 0;
33
+ }
34
+ function delay(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+ function isPosixProcessGroupRunning(processGroupId) {
38
+ try {
39
+ process.kill(-processGroupId, 0);
40
+ return true;
41
+ } catch (error) {
42
+ if (errorCode(error) === "ESRCH") {
43
+ return false;
44
+ }
45
+ if (errorCode(error) === "EPERM") {
46
+ return true;
47
+ }
48
+ throw error;
49
+ }
50
+ }
51
+ function signalPosixProcessGroup(processGroupId, signal) {
52
+ try {
53
+ process.kill(-processGroupId, signal);
54
+ } catch (error) {
55
+ if (errorCode(error) !== "ESRCH") {
56
+ throw error;
57
+ }
58
+ }
59
+ }
60
+ async function waitForPosixProcessGroupExit(processGroupId, timeoutMs) {
61
+ const deadline = Date.now() + Math.max(0, timeoutMs);
62
+ while (isPosixProcessGroupRunning(processGroupId)) {
63
+ const remainingMs = deadline - Date.now();
64
+ if (remainingMs <= 0) {
65
+ return false;
66
+ }
67
+ await delay(Math.min(PROCESS_GROUP_POLL_INTERVAL_MS, remainingMs));
68
+ }
69
+ return true;
70
+ }
71
+ async function runWindowsTaskkill(pid) {
72
+ const taskkill = Bun.spawn(["taskkill", "/PID", String(pid), "/T", "/F"], {
73
+ stdout: "ignore",
74
+ stderr: "pipe"
75
+ });
76
+ const [exitCode, stderr] = await Promise.all([
77
+ taskkill.exited,
78
+ taskkill.stderr ? new Response(taskkill.stderr).text() : Promise.resolve("")
79
+ ]);
80
+ if (exitCode !== 0 && !/not found|no running instance/i.test(stderr)) {
81
+ throw new Error(`Failed to terminate process tree ${pid}: ${stderr.trim() || `taskkill exited ${exitCode}`}`);
82
+ }
83
+ }
84
+ async function terminateProcessTreeOnce(subprocess, options) {
85
+ if (!Number.isSafeInteger(subprocess.pid) || subprocess.pid <= 0) {
86
+ throw new Error(`Cannot terminate invalid subprocess PID: ${subprocess.pid}`);
87
+ }
88
+ if (process.platform === "win32") {
89
+ await runWindowsTaskkill(subprocess.pid);
90
+ return;
91
+ }
92
+ const graceMs = options.graceMs ?? DEFAULT_TERMINATION_GRACE_MS;
93
+ const forceKillWaitMs = options.forceKillWaitMs ?? DEFAULT_FORCE_KILL_WAIT_MS;
94
+ signalPosixProcessGroup(subprocess.pid, "SIGTERM");
95
+ if (await waitForPosixProcessGroupExit(subprocess.pid, graceMs)) {
96
+ return;
97
+ }
98
+ signalPosixProcessGroup(subprocess.pid, "SIGKILL");
99
+ if (!await waitForPosixProcessGroupExit(subprocess.pid, forceKillWaitMs)) {
100
+ throw new Error(`Process group ${subprocess.pid} remained active after SIGKILL`);
101
+ }
102
+ }
103
+ function terminateProcessTree(subprocess, options = {}) {
104
+ const pending = processTerminations.get(subprocess);
105
+ if (pending) {
106
+ return pending;
107
+ }
108
+ const termination = terminateProcessTreeOnce(subprocess, options).finally(() => {
109
+ processTerminations.delete(subprocess);
110
+ });
111
+ processTerminations.set(subprocess, termination);
112
+ return termination;
113
+ }
114
+ // Annotate the CommonJS export names for ESM import in node:
115
+ 0 && (module.exports = {
116
+ terminateProcessTree
117
+ });
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var supervisor_exports = {};
20
+ __export(supervisor_exports, {
21
+ WORKER_RELOAD_EXIT_CODE: () => WORKER_RELOAD_EXIT_CODE,
22
+ WORKER_RUNTIME_ENV: () => WORKER_RUNTIME_ENV,
23
+ superviseWorkerRuntime: () => superviseWorkerRuntime
24
+ });
25
+ module.exports = __toCommonJS(supervisor_exports);
26
+ const WORKER_RUNTIME_ENV = "R5D_WORKER_INTERNAL_RUNTIME";
27
+ const WORKER_RELOAD_EXIT_CODE = 75;
28
+ function defaultRuntimeSpawner(argv, options) {
29
+ return Bun.spawn(argv, options);
30
+ }
31
+ async function superviseWorkerRuntime(argv, env = process.env, spawnRuntime = defaultRuntimeSpawner) {
32
+ if (!process.argv[1]) {
33
+ throw new Error("Cannot locate the r5d-worker entrypoint");
34
+ }
35
+ while (true) {
36
+ const runtime = spawnRuntime([process.execPath, process.argv[1], ...argv], {
37
+ stdin: "inherit",
38
+ stdout: "inherit",
39
+ stderr: "inherit",
40
+ env: {
41
+ ...env,
42
+ [WORKER_RUNTIME_ENV]: "1"
43
+ }
44
+ });
45
+ const exitCode = await runtime.exited;
46
+ if (exitCode === WORKER_RELOAD_EXIT_CODE) {
47
+ process.stdout.write("[r5d-worker] activating updated worker runtime\n");
48
+ continue;
49
+ }
50
+ return exitCode;
51
+ }
52
+ }
53
+ // Annotate the CommonJS export names for ESM import in node:
54
+ 0 && (module.exports = {
55
+ WORKER_RELOAD_EXIT_CODE,
56
+ WORKER_RUNTIME_ENV,
57
+ superviseWorkerRuntime
58
+ });
@@ -0,0 +1,80 @@
1
+ const UPDATE_OUTPUT_LIMIT = 8e3;
2
+ function collectOutput(value) {
3
+ if (!value || typeof value === "number") {
4
+ return Promise.resolve("");
5
+ }
6
+ return new Response(value).text();
7
+ }
8
+ async function runCommand(argv) {
9
+ const child = Bun.spawn(argv, {
10
+ stdout: "pipe",
11
+ stderr: "pipe",
12
+ env: process.env
13
+ });
14
+ const [stdout, stderr, exitCode] = await Promise.all([collectOutput(child.stdout), collectOutput(child.stderr), child.exited]);
15
+ return { exitCode, stdout, stderr };
16
+ }
17
+ function parseCliVersion(cliName, output) {
18
+ const escapedName = cliName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+ const match = output.trim().match(new RegExp(`(?:^|\\n)${escapedName}\\s+(\\S+)`));
20
+ return match?.[1]?.trim() || null;
21
+ }
22
+ function readInstalledCliVersion(cliName) {
23
+ try {
24
+ const result = Bun.spawnSync([cliName, "--version"], {
25
+ stdout: "pipe",
26
+ stderr: "pipe",
27
+ env: process.env
28
+ });
29
+ if (result.exitCode !== 0) {
30
+ return null;
31
+ }
32
+ return parseCliVersion(cliName, Buffer.from(result.stdout).toString("utf8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ function shellQuote(value) {
38
+ return `'${value.replaceAll("'", `'\\''`)}'`;
39
+ }
40
+ function getManualCliUpdateCommand(input) {
41
+ return `npm install -g --omit=dev ${shellQuote(input.workerPackageSpec)} ${shellQuote(input.r5dctlPackageSpec)}`;
42
+ }
43
+ function commandErrorDetail(result) {
44
+ const detail = `${result.stderr.trim()}${result.stderr.trim() && result.stdout.trim() ? "\n" : ""}${result.stdout.trim()}`.trim();
45
+ if (!detail) {
46
+ return `exit code ${result.exitCode}`;
47
+ }
48
+ return detail.slice(-UPDATE_OUTPUT_LIMIT);
49
+ }
50
+ async function readVerifiedVersion(cliName, targetVersion, runner) {
51
+ const result = await runner([cliName, "--version"]);
52
+ const installedVersion = result.exitCode === 0 ? parseCliVersion(cliName, result.stdout) : null;
53
+ if (!installedVersion) {
54
+ throw new Error(`${cliName} was installed but is not runnable: ${commandErrorDetail(result)}`);
55
+ }
56
+ if (installedVersion !== targetVersion) {
57
+ throw new Error(`${cliName} ${installedVersion} was installed; expected ${targetVersion}`);
58
+ }
59
+ return installedVersion;
60
+ }
61
+ async function installCliUpdate(input, runner = runCommand) {
62
+ const installArgs = ["npm", "install", "-g", "--omit=dev", input.workerPackageSpec, input.r5dctlPackageSpec];
63
+ const result = await runner(installArgs);
64
+ if (result.exitCode !== 0) {
65
+ throw new Error(`CLI update failed: ${commandErrorDetail(result)}
66
+ Run manually: ${getManualCliUpdateCommand(input)}`);
67
+ }
68
+ const [installedWorkerVersion, installedR5dctlVersion] = await Promise.all([
69
+ readVerifiedVersion("r5d-worker", input.targetWorkerVersion, runner),
70
+ readVerifiedVersion("r5dctl", input.targetR5dctlVersion, runner)
71
+ ]);
72
+ return { installedWorkerVersion, installedR5dctlVersion };
73
+ }
74
+ export {
75
+ getManualCliUpdateCommand,
76
+ installCliUpdate,
77
+ parseCliVersion,
78
+ readInstalledCliVersion,
79
+ runCommand
80
+ };
package/dist/mjs/main.mjs CHANGED
@@ -4,8 +4,11 @@ import path from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import os, { hostname } from "node:os";
6
6
  import { spawn as spawnChildProcess } from "node:child_process";
7
+ import { installCliUpdate, readInstalledCliVersion } from "./cli-update.mjs";
7
8
  import { configureVisibleGitIdentity } from "./git-identity.mjs";
8
9
  import { hasWorkerHeartbeatTimedOut, WORKER_HEARTBEAT_INTERVAL_MS } from "./heartbeat.mjs";
10
+ import { terminateProcessTree } from "./process-tree.mjs";
11
+ import { superviseWorkerRuntime, WORKER_RELOAD_EXIT_CODE, WORKER_RUNTIME_ENV } from "./supervisor.mjs";
9
12
  const DEFAULT_BASE_URL = "https://r5d.dev";
10
13
  const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
11
14
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
@@ -1772,6 +1775,7 @@ async function executeCommand(input) {
1772
1775
  cwd,
1773
1776
  stdout: "pipe",
1774
1777
  stderr: "pipe",
1778
+ detached: true,
1775
1779
  env: {
1776
1780
  ...process.env,
1777
1781
  ...containerRegistryEnv(),
@@ -1793,7 +1797,12 @@ async function executeCommand(input) {
1793
1797
  });
1794
1798
  if (input.message.timeoutMs) {
1795
1799
  timeout = setTimeout(() => {
1796
- subprocess.kill("SIGTERM");
1800
+ void terminateProcessTree(subprocess).catch((error) => {
1801
+ process.stderr.write(
1802
+ `[r5d-worker] failed to terminate timed-out command ${input.message.runId}: ${error instanceof Error ? error.message : String(error)}
1803
+ `
1804
+ );
1805
+ });
1797
1806
  }, input.message.timeoutMs);
1798
1807
  }
1799
1808
  const [stdout, stderr, exitCode] = await Promise.all([
@@ -1829,6 +1838,8 @@ async function executeCommand(input) {
1829
1838
  async function executeStreamingCommand(input) {
1830
1839
  const startedAt = Date.now();
1831
1840
  let started = false;
1841
+ let timedOut = false;
1842
+ let timeout;
1832
1843
  try {
1833
1844
  if (cancelledProcessRuns.delete(input.message.runId)) {
1834
1845
  sendWorkerMessage(input.ws, {
@@ -1869,6 +1880,7 @@ async function executeStreamingCommand(input) {
1869
1880
  cwd,
1870
1881
  stdout: "pipe",
1871
1882
  stderr: "pipe",
1883
+ detached: true,
1872
1884
  env: {
1873
1885
  ...process.env,
1874
1886
  ...containerRegistryEnv(),
@@ -1895,6 +1907,17 @@ async function executeStreamingCommand(input) {
1895
1907
  requestId: input.message.requestId,
1896
1908
  runId: input.message.runId
1897
1909
  });
1910
+ if (input.message.timeoutMs) {
1911
+ timeout = setTimeout(() => {
1912
+ timedOut = true;
1913
+ void terminateProcessTree(subprocess).catch((error) => {
1914
+ process.stderr.write(
1915
+ `[r5d-worker] failed to terminate timed-out process ${input.message.runId}: ${error instanceof Error ? error.message : String(error)}
1916
+ `
1917
+ );
1918
+ });
1919
+ }, input.message.timeoutMs);
1920
+ }
1898
1921
  const [exitCode] = await Promise.all([
1899
1922
  subprocess.exited,
1900
1923
  streamCommandOutput(subprocess.stdout, (data) => {
@@ -1918,7 +1941,8 @@ async function executeStreamingCommand(input) {
1918
1941
  type: "exec_exit",
1919
1942
  runId: input.message.runId,
1920
1943
  exitCode,
1921
- durationMs: Date.now() - startedAt
1944
+ durationMs: Date.now() - startedAt,
1945
+ ...timedOut ? { timedOut: true } : {}
1922
1946
  });
1923
1947
  } catch (error) {
1924
1948
  const message = error instanceof Error ? error.message : String(error);
@@ -1938,6 +1962,9 @@ async function executeStreamingCommand(input) {
1938
1962
  });
1939
1963
  }
1940
1964
  } finally {
1965
+ if (timeout) {
1966
+ clearTimeout(timeout);
1967
+ }
1941
1968
  activeProcesses.delete(input.message.runId);
1942
1969
  cancelledProcessRuns.delete(input.message.runId);
1943
1970
  }
@@ -2315,6 +2342,9 @@ async function startWorker(options) {
2315
2342
  fs.mkdirSync(planRoot, { recursive: true });
2316
2343
  const manifestByProjectId = /* @__PURE__ */ new Map();
2317
2344
  let repositorySyncQueue = Promise.resolve();
2345
+ let repositorySyncInProgress = false;
2346
+ let cliUpdateInProgress = false;
2347
+ let reloadAfterClose = false;
2318
2348
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
2319
2349
  headers: {
2320
2350
  Authorization: `Bearer ${token}`
@@ -2348,6 +2378,10 @@ async function startWorker(options) {
2348
2378
  arch: process.arch,
2349
2379
  pid: process.pid,
2350
2380
  version: getWorkerVersion(),
2381
+ r5dctlVersion: readInstalledCliVersion("r5dctl"),
2382
+ capabilities: {
2383
+ updateClis: true
2384
+ },
2351
2385
  projectRoot: projectsRoot,
2352
2386
  artifactRoot,
2353
2387
  planRoot
@@ -2375,32 +2409,49 @@ async function startWorker(options) {
2375
2409
  return;
2376
2410
  }
2377
2411
  if (message.type === "sync_projects") {
2412
+ if (cliUpdateInProgress) {
2413
+ sendWorkerMessage(ws, {
2414
+ type: "sync_projects_result",
2415
+ requestId: message.requestId,
2416
+ result: {
2417
+ status: "blocked",
2418
+ results: [],
2419
+ blockedBy: []
2420
+ }
2421
+ });
2422
+ return;
2423
+ }
2378
2424
  let syncResult = { status: "completed", results: [], blockedBy: [] };
2379
2425
  const executeSync = async () => {
2380
- syncResult = await syncManifestProjectsFromInternal({
2381
- baseUrl,
2382
- token,
2383
- projectsRoot,
2384
- syncRoot,
2385
- manifests: [...manifestByProjectId.values()],
2386
- projectIds: message.projectIds
2387
- });
2388
- for (const result of syncResult.results) {
2389
- if (result.status !== "synced") continue;
2390
- try {
2391
- await syncProjectPlans({
2392
- baseUrl,
2393
- token,
2394
- projectId: result.projectId,
2395
- branchName: result.branchName,
2396
- planRoot
2397
- });
2398
- } catch (error) {
2399
- process.stderr.write(
2400
- `[r5d-worker] plan sync skipped for ${result.projectId}/${result.branchName}: ${error instanceof Error ? error.message : String(error)}
2426
+ repositorySyncInProgress = true;
2427
+ try {
2428
+ syncResult = await syncManifestProjectsFromInternal({
2429
+ baseUrl,
2430
+ token,
2431
+ projectsRoot,
2432
+ syncRoot,
2433
+ manifests: [...manifestByProjectId.values()],
2434
+ projectIds: message.projectIds
2435
+ });
2436
+ for (const result of syncResult.results) {
2437
+ if (result.status !== "synced") continue;
2438
+ try {
2439
+ await syncProjectPlans({
2440
+ baseUrl,
2441
+ token,
2442
+ projectId: result.projectId,
2443
+ branchName: result.branchName,
2444
+ planRoot
2445
+ });
2446
+ } catch (error) {
2447
+ process.stderr.write(
2448
+ `[r5d-worker] plan sync skipped for ${result.projectId}/${result.branchName}: ${error instanceof Error ? error.message : String(error)}
2401
2449
  `
2402
- );
2450
+ );
2451
+ }
2403
2452
  }
2453
+ } finally {
2454
+ repositorySyncInProgress = false;
2404
2455
  }
2405
2456
  };
2406
2457
  const queued = repositorySyncQueue.then(executeSync, executeSync);
@@ -2432,6 +2483,43 @@ async function startWorker(options) {
2432
2483
  });
2433
2484
  return;
2434
2485
  }
2486
+ if (message.type === "update_clis") {
2487
+ if (cliUpdateInProgress) {
2488
+ sendWorkerMessage(ws, {
2489
+ type: "update_clis_result",
2490
+ requestId: message.requestId,
2491
+ error: "A CLI update is already in progress"
2492
+ });
2493
+ return;
2494
+ }
2495
+ if (activeProcesses.size > 0 || activePtys.size > 0 || repositorySyncInProgress) {
2496
+ sendWorkerMessage(ws, {
2497
+ type: "update_clis_result",
2498
+ requestId: message.requestId,
2499
+ error: "Worker is busy. Stop active commands and shells, then retry the CLI update."
2500
+ });
2501
+ return;
2502
+ }
2503
+ cliUpdateInProgress = true;
2504
+ try {
2505
+ const result = await installCliUpdate(message);
2506
+ sendWorkerMessage(ws, {
2507
+ type: "update_clis_result",
2508
+ requestId: message.requestId,
2509
+ ...result
2510
+ });
2511
+ reloadAfterClose = true;
2512
+ ws.close(1e3, "Activating updated CLIs");
2513
+ } catch (error) {
2514
+ cliUpdateInProgress = false;
2515
+ sendWorkerMessage(ws, {
2516
+ type: "update_clis_result",
2517
+ requestId: message.requestId,
2518
+ error: error instanceof Error ? error.message : String(error)
2519
+ });
2520
+ }
2521
+ return;
2522
+ }
2435
2523
  if (message.type === "ping") {
2436
2524
  lastServerHeartbeatAt = Date.now();
2437
2525
  ws.send(JSON.stringify({ type: "pong" }));
@@ -2439,23 +2527,41 @@ async function startWorker(options) {
2439
2527
  }
2440
2528
  if (message.type === "cancel") {
2441
2529
  const active = activeProcesses.get(message.runId);
2530
+ let cancelled = true;
2531
+ let cancelMessage;
2442
2532
  if (active) {
2443
- active.process.kill("SIGTERM");
2533
+ try {
2534
+ await terminateProcessTree(active.process);
2535
+ cancelMessage = `Stopped ${active.command}`;
2536
+ } catch (error) {
2537
+ cancelled = false;
2538
+ cancelMessage = `Failed to stop ${active.command}: ${error instanceof Error ? error.message : String(error)}`;
2539
+ }
2444
2540
  } else {
2445
2541
  cancelledProcessRuns.add(message.runId);
2542
+ cancelMessage = "Cancellation queued before command start";
2446
2543
  }
2447
2544
  ws.send(
2448
2545
  JSON.stringify({
2449
2546
  type: "cancel_result",
2450
2547
  requestId: message.requestId,
2451
2548
  runId: message.runId,
2452
- cancelled: true,
2453
- message: active ? `Sent SIGTERM to ${active.command}` : "Cancellation queued before command start"
2549
+ cancelled,
2550
+ message: cancelMessage
2454
2551
  })
2455
2552
  );
2456
2553
  return;
2457
2554
  }
2458
2555
  if (message.type === "pty_open") {
2556
+ if (cliUpdateInProgress) {
2557
+ sendWorkerMessage(ws, {
2558
+ type: "pty_error",
2559
+ requestId: message.requestId,
2560
+ ptyId: message.ptyId,
2561
+ error: "Worker is activating a CLI update"
2562
+ });
2563
+ return;
2564
+ }
2459
2565
  await repositorySyncQueue;
2460
2566
  const manifest = manifestByProjectId.get(message.projectId);
2461
2567
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
@@ -2477,6 +2583,16 @@ async function startWorker(options) {
2477
2583
  return;
2478
2584
  }
2479
2585
  if (message.type === "exec") {
2586
+ if (cliUpdateInProgress) {
2587
+ sendWorkerMessage(ws, {
2588
+ type: "exec_result",
2589
+ requestId: message.requestId,
2590
+ stdout: "",
2591
+ stderr: "Worker is activating a CLI update",
2592
+ exitCode: 1
2593
+ });
2594
+ return;
2595
+ }
2480
2596
  await repositorySyncQueue;
2481
2597
  const manifest = manifestByProjectId.get(message.projectId);
2482
2598
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
@@ -2497,6 +2613,15 @@ async function startWorker(options) {
2497
2613
  return;
2498
2614
  }
2499
2615
  if (message.type === "exec_start") {
2616
+ if (cliUpdateInProgress) {
2617
+ sendWorkerMessage(ws, {
2618
+ type: "exec_start_error",
2619
+ requestId: message.requestId,
2620
+ runId: message.runId,
2621
+ error: "Worker is activating a CLI update"
2622
+ });
2623
+ return;
2624
+ }
2500
2625
  await repositorySyncQueue;
2501
2626
  sendWorkerMessage(ws, {
2502
2627
  type: "exec_accepted",
@@ -2594,6 +2719,9 @@ async function startWorker(options) {
2594
2719
  const reason = event.reason ? `: ${event.reason}` : "";
2595
2720
  process.stderr.write(`[r5d-worker] disconnected (${event.code}${reason})
2596
2721
  `);
2722
+ if (reloadAfterClose) {
2723
+ process.exit(WORKER_RELOAD_EXIT_CODE);
2724
+ }
2597
2725
  if (activeProcesses.size > 0) {
2598
2726
  const delayMs = 2e3;
2599
2727
  process.stderr.write(`[r5d-worker] ${activeProcesses.size} process(es) still active; reconnecting in ${delayMs}ms
@@ -2632,7 +2760,15 @@ async function main() {
2632
2760
  printVersion();
2633
2761
  return;
2634
2762
  }
2635
- await startWorker(parsed.options);
2763
+ if (process.env[WORKER_RUNTIME_ENV] === "1") {
2764
+ delete process.env[WORKER_RUNTIME_ENV];
2765
+ await startWorker(parsed.options);
2766
+ return;
2767
+ }
2768
+ const exitCode = await superviseWorkerRuntime(process.argv.slice(2));
2769
+ if (exitCode !== 0) {
2770
+ process.exit(exitCode);
2771
+ }
2636
2772
  }
2637
2773
  function isCliEntrypoint() {
2638
2774
  if (import.meta.main) {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "type": "module"
5
5
  }
@@ -0,0 +1,93 @@
1
+ const DEFAULT_TERMINATION_GRACE_MS = 2e3;
2
+ const DEFAULT_FORCE_KILL_WAIT_MS = 2e3;
3
+ const PROCESS_GROUP_POLL_INTERVAL_MS = 25;
4
+ const processTerminations = /* @__PURE__ */ new WeakMap();
5
+ function errorCode(error) {
6
+ if (!error || typeof error !== "object" || !("code" in error)) {
7
+ return void 0;
8
+ }
9
+ return typeof error.code === "string" ? error.code : void 0;
10
+ }
11
+ function delay(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+ function isPosixProcessGroupRunning(processGroupId) {
15
+ try {
16
+ process.kill(-processGroupId, 0);
17
+ return true;
18
+ } catch (error) {
19
+ if (errorCode(error) === "ESRCH") {
20
+ return false;
21
+ }
22
+ if (errorCode(error) === "EPERM") {
23
+ return true;
24
+ }
25
+ throw error;
26
+ }
27
+ }
28
+ function signalPosixProcessGroup(processGroupId, signal) {
29
+ try {
30
+ process.kill(-processGroupId, signal);
31
+ } catch (error) {
32
+ if (errorCode(error) !== "ESRCH") {
33
+ throw error;
34
+ }
35
+ }
36
+ }
37
+ async function waitForPosixProcessGroupExit(processGroupId, timeoutMs) {
38
+ const deadline = Date.now() + Math.max(0, timeoutMs);
39
+ while (isPosixProcessGroupRunning(processGroupId)) {
40
+ const remainingMs = deadline - Date.now();
41
+ if (remainingMs <= 0) {
42
+ return false;
43
+ }
44
+ await delay(Math.min(PROCESS_GROUP_POLL_INTERVAL_MS, remainingMs));
45
+ }
46
+ return true;
47
+ }
48
+ async function runWindowsTaskkill(pid) {
49
+ const taskkill = Bun.spawn(["taskkill", "/PID", String(pid), "/T", "/F"], {
50
+ stdout: "ignore",
51
+ stderr: "pipe"
52
+ });
53
+ const [exitCode, stderr] = await Promise.all([
54
+ taskkill.exited,
55
+ taskkill.stderr ? new Response(taskkill.stderr).text() : Promise.resolve("")
56
+ ]);
57
+ if (exitCode !== 0 && !/not found|no running instance/i.test(stderr)) {
58
+ throw new Error(`Failed to terminate process tree ${pid}: ${stderr.trim() || `taskkill exited ${exitCode}`}`);
59
+ }
60
+ }
61
+ async function terminateProcessTreeOnce(subprocess, options) {
62
+ if (!Number.isSafeInteger(subprocess.pid) || subprocess.pid <= 0) {
63
+ throw new Error(`Cannot terminate invalid subprocess PID: ${subprocess.pid}`);
64
+ }
65
+ if (process.platform === "win32") {
66
+ await runWindowsTaskkill(subprocess.pid);
67
+ return;
68
+ }
69
+ const graceMs = options.graceMs ?? DEFAULT_TERMINATION_GRACE_MS;
70
+ const forceKillWaitMs = options.forceKillWaitMs ?? DEFAULT_FORCE_KILL_WAIT_MS;
71
+ signalPosixProcessGroup(subprocess.pid, "SIGTERM");
72
+ if (await waitForPosixProcessGroupExit(subprocess.pid, graceMs)) {
73
+ return;
74
+ }
75
+ signalPosixProcessGroup(subprocess.pid, "SIGKILL");
76
+ if (!await waitForPosixProcessGroupExit(subprocess.pid, forceKillWaitMs)) {
77
+ throw new Error(`Process group ${subprocess.pid} remained active after SIGKILL`);
78
+ }
79
+ }
80
+ function terminateProcessTree(subprocess, options = {}) {
81
+ const pending = processTerminations.get(subprocess);
82
+ if (pending) {
83
+ return pending;
84
+ }
85
+ const termination = terminateProcessTreeOnce(subprocess, options).finally(() => {
86
+ processTerminations.delete(subprocess);
87
+ });
88
+ processTerminations.set(subprocess, termination);
89
+ return termination;
90
+ }
91
+ export {
92
+ terminateProcessTree
93
+ };
@@ -0,0 +1,32 @@
1
+ const WORKER_RUNTIME_ENV = "R5D_WORKER_INTERNAL_RUNTIME";
2
+ const WORKER_RELOAD_EXIT_CODE = 75;
3
+ function defaultRuntimeSpawner(argv, options) {
4
+ return Bun.spawn(argv, options);
5
+ }
6
+ async function superviseWorkerRuntime(argv, env = process.env, spawnRuntime = defaultRuntimeSpawner) {
7
+ if (!process.argv[1]) {
8
+ throw new Error("Cannot locate the r5d-worker entrypoint");
9
+ }
10
+ while (true) {
11
+ const runtime = spawnRuntime([process.execPath, process.argv[1], ...argv], {
12
+ stdin: "inherit",
13
+ stdout: "inherit",
14
+ stderr: "inherit",
15
+ env: {
16
+ ...env,
17
+ [WORKER_RUNTIME_ENV]: "1"
18
+ }
19
+ });
20
+ const exitCode = await runtime.exited;
21
+ if (exitCode === WORKER_RELOAD_EXIT_CODE) {
22
+ process.stdout.write("[r5d-worker] activating updated worker runtime\n");
23
+ continue;
24
+ }
25
+ return exitCode;
26
+ }
27
+ }
28
+ export {
29
+ WORKER_RELOAD_EXIT_CODE,
30
+ WORKER_RUNTIME_ENV,
31
+ superviseWorkerRuntime
32
+ };
@@ -0,0 +1,21 @@
1
+ export type CommandResult = {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ };
6
+ export type CommandRunner = (argv: string[]) => Promise<CommandResult>;
7
+ export type CliUpdateRequest = {
8
+ workerPackageSpec: string;
9
+ r5dctlPackageSpec: string;
10
+ targetWorkerVersion: string;
11
+ targetR5dctlVersion: string;
12
+ };
13
+ export type CliUpdateResult = {
14
+ installedWorkerVersion: string;
15
+ installedR5dctlVersion: string;
16
+ };
17
+ export declare function runCommand(argv: string[]): Promise<CommandResult>;
18
+ export declare function parseCliVersion(cliName: "r5d-worker" | "r5dctl", output: string): string | null;
19
+ export declare function readInstalledCliVersion(cliName: "r5d-worker" | "r5dctl"): string | null;
20
+ export declare function getManualCliUpdateCommand(input: Pick<CliUpdateRequest, "workerPackageSpec" | "r5dctlPackageSpec">): string;
21
+ export declare function installCliUpdate(input: CliUpdateRequest, runner?: CommandRunner): Promise<CliUpdateResult>;
@@ -0,0 +1,16 @@
1
+ export type KillableSubprocess = {
2
+ readonly pid: number;
3
+ readonly exited: Promise<number>;
4
+ kill(signal?: number | NodeJS.Signals): void;
5
+ };
6
+ type TerminateProcessTreeOptions = {
7
+ graceMs?: number;
8
+ forceKillWaitMs?: number;
9
+ };
10
+ /**
11
+ * Terminates a subprocess spawned with `detached: true` and every descendant
12
+ * that remains in its process group. Concurrent termination requests share one
13
+ * cleanup attempt.
14
+ */
15
+ export declare function terminateProcessTree(subprocess: KillableSubprocess, options?: TerminateProcessTreeOptions): Promise<void>;
16
+ export {};
@@ -0,0 +1,13 @@
1
+ export declare const WORKER_RUNTIME_ENV = "R5D_WORKER_INTERNAL_RUNTIME";
2
+ export declare const WORKER_RELOAD_EXIT_CODE = 75;
3
+ type RuntimeProcess = {
4
+ exited: Promise<number>;
5
+ };
6
+ export type RuntimeSpawner = (argv: string[], options: {
7
+ stdin: "inherit";
8
+ stdout: "inherit";
9
+ stderr: "inherit";
10
+ env: Record<string, string | undefined>;
11
+ }) => RuntimeProcess;
12
+ export declare function superviseWorkerRuntime(argv: string[], env?: Record<string, string | undefined>, spawnRuntime?: RuntimeSpawner): Promise<number>;
13
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",