@ricsam/r5d-worker 0.0.110 → 0.0.111

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/dist/cjs/main.cjs CHANGED
@@ -62,6 +62,7 @@ var import_plan_lint = require("./plan-lint.cjs");
62
62
  var import_node_fs = __toESM(require("node:fs"), 1);
63
63
  var import_recovery_store = require("./recovery-store.cjs");
64
64
  var import_recovery_output_window = require("./recovery-output-window.cjs");
65
+ var import_job_resources = require("./job-resources.cjs");
65
66
  var import_recovery_protocol = require("./recovery-protocol.cjs");
66
67
  var import_node_path = __toESM(require("node:path"), 1);
67
68
  var import_node_crypto = require("node:crypto");
@@ -154,6 +155,7 @@ const PTY_INPUT_BUSY_GRACE_MS = 3e3;
154
155
  const PTY_FOREGROUND_POLL_MS = 1e3;
155
156
  const PTY_FOREGROUND_IDLE_ENABLED = process.env.R5D_PTY_FOREGROUND_IDLE !== "0";
156
157
  const PTY_TMP_PATH_PREFIX = "r5d-worker-tmp://";
158
+ let workerJobResources;
157
159
  const activeProcesses = /* @__PURE__ */ new Map();
158
160
  const credentialBearingProcessGroups = /* @__PURE__ */ new Map();
159
161
  const credentialBearingProcessGroupTargets = /* @__PURE__ */ new Map();
@@ -202,8 +204,23 @@ function renewWorkerExecutionLease(leaseMs) {
202
204
  () => {
203
205
  if (Date.now() < workerExecutionLeaseDeadline) return;
204
206
  workerLeaseExpired = true;
207
+ try {
208
+ workerJobResources?.cancelMatching((id) => id.startsWith("pty:"));
209
+ } catch (error) {
210
+ process.stderr.write(`[r5d-worker] expired PTY resource termination failed: ${error}
211
+ `);
212
+ }
213
+ closeAllPtys();
205
214
  for (const operation of recoveryStore?.operations() ?? []) {
206
- if (operation.sessionId && operation.state !== "completed") recoveryStore?.cancelSession(operation.sessionId);
215
+ if (operation.sessionId && operation.state !== "completed") {
216
+ recoveryStore?.cancelSession(operation.sessionId);
217
+ try {
218
+ workerJobResources?.cancelSession(operation.sessionId);
219
+ } catch (error) {
220
+ process.stderr.write(`[r5d-worker] expired command resource termination failed: ${error}
221
+ `);
222
+ }
223
+ }
207
224
  }
208
225
  for (const [runId, active] of activeProcesses) {
209
226
  if (!active.sessionId) continue;
@@ -1597,7 +1614,7 @@ function credentialReapContractStatus(probe) {
1597
1614
  if (process.platform !== "linux") return "direct";
1598
1615
  try {
1599
1616
  const cgroupText = import_node_fs.default.readFileSync("/proc/self/cgroup", "utf8");
1600
- const unit = cgroupText.match(/(?:^|\/)r5d-worker\.service(?:$|\n)/m)?.[0]?.replace(/^\//, "").trim();
1617
+ const unit = /(?:^|\/)r5d-worker\.service(?:\/|$|\n)/m.test(cgroupText) ? "r5d-worker.service" : void 0;
1601
1618
  if (unit !== "r5d-worker.service") {
1602
1619
  return SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(process.env.INVOCATION_ID ?? "") ? "systemd_unverified" : "direct";
1603
1620
  }
@@ -1631,7 +1648,7 @@ function credentialReapContractStatus(probe) {
1631
1648
  }
1632
1649
  }
1633
1650
  if (resolved.platform !== "linux") return "direct";
1634
- if (!/(?:^|\/)r5d-worker\.service(?:$|\n)/m.test(resolved.cgroupText)) {
1651
+ if (!/(?:^|\/)r5d-worker\.service(?:\/|$|\n)/m.test(resolved.cgroupText)) {
1635
1652
  return SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(resolved.invocationId ?? "") ? "systemd_unverified" : "direct";
1636
1653
  }
1637
1654
  if (!SYSTEMD_INVOCATION_IDENTIFIER_PATTERN.test(resolved.invocationId ?? "")) return "systemd_unverified";
@@ -2013,12 +2030,6 @@ function resolveCommandCwd(branchPath, cwd) {
2013
2030
  }
2014
2031
  return resolved;
2015
2032
  }
2016
- async function collectStream(stream) {
2017
- if (!stream) {
2018
- return "";
2019
- }
2020
- return await new Response(stream).text();
2021
- }
2022
2033
  async function streamCommandOutput(stream, onData) {
2023
2034
  if (!stream) {
2024
2035
  return;
@@ -2739,8 +2750,37 @@ async function prepareShellEnvForTarget(input) {
2739
2750
  ...targetProcessEnv(input.rootDir, input.target)
2740
2751
  };
2741
2752
  }
2753
+ async function releaseWorkerJobResources(resources) {
2754
+ if (!resources) return;
2755
+ try {
2756
+ await resources.release();
2757
+ } catch (error) {
2758
+ process.stderr.write(`[r5d-worker] job resource cleanup failed: ${error instanceof Error ? error.message : String(error)}
2759
+ `);
2760
+ }
2761
+ }
2762
+ async function acquireWorkerCommandResources(ws, message, assertAdmission) {
2763
+ const id = message.type === "pty_open" ? `pty:${message.ptyId}` : message.runId;
2764
+ const operationId = message.type === "pty_open" ? id : `exec:${id}`;
2765
+ sendWorkerMessage(ws, { type: "heartbeat_lease", operationId, active: true });
2766
+ try {
2767
+ return await workerJobResources.acquire({
2768
+ id,
2769
+ lane: message.type === "exec" && message.workspaceEffect === "none" ? "utility" : "general",
2770
+ ..."sessionId" in message && message.sessionId ? { sessionId: message.sessionId } : {},
2771
+ assertAdmission: () => {
2772
+ assertAdmission();
2773
+ if (cancelledProcessRuns.has(id)) throw new import_job_resources.WorkerJobCancelledError();
2774
+ }
2775
+ });
2776
+ } finally {
2777
+ sendWorkerMessage(ws, { type: "heartbeat_lease", operationId, active: false });
2778
+ }
2779
+ }
2742
2780
  async function executeCommand(input) {
2743
2781
  const startedAt = Date.now();
2782
+ const outputAbort = new AbortController();
2783
+ let outputReaders = [];
2744
2784
  let timeout;
2745
2785
  let spawnedProcess;
2746
2786
  let preparationSettled = false;
@@ -2761,7 +2801,7 @@ async function executeCommand(input) {
2761
2801
  });
2762
2802
  const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
2763
2803
  input.assertAdmission();
2764
- const subprocess = Bun.spawn(input.message.argv, {
2804
+ const subprocess = Bun.spawn(input.resources.wrap(input.message.argv), {
2765
2805
  cwd,
2766
2806
  stdout: "pipe",
2767
2807
  stderr: "pipe",
@@ -2789,6 +2829,7 @@ async function executeCommand(input) {
2789
2829
  settlePreparation();
2790
2830
  if (input.message.timeoutMs) {
2791
2831
  timeout = setTimeout(() => {
2832
+ input.resources.kill();
2792
2833
  void (0, import_process_tree.terminateProcessTree)(subprocess).catch((error) => {
2793
2834
  process.stderr.write(
2794
2835
  `[r5d-worker] failed to terminate timed-out command ${input.message.runId}: ${error instanceof Error ? error.message : String(error)}
@@ -2797,21 +2838,26 @@ async function executeCommand(input) {
2797
2838
  });
2798
2839
  }, input.message.timeoutMs);
2799
2840
  }
2800
- const [stdout, stderr, exitCode] = await Promise.all([
2801
- collectStream(subprocess.stdout),
2802
- collectStream(subprocess.stderr),
2803
- subprocess.exited
2804
- ]);
2841
+ outputReaders = [
2842
+ (0, import_job_resources.collectWorkerInlineOutput)(subprocess.stdout, void 0, outputAbort.signal),
2843
+ (0, import_job_resources.collectWorkerInlineOutput)(subprocess.stderr, void 0, outputAbort.signal)
2844
+ ];
2845
+ const [stdout, stderr, exitCode] = await Promise.all([outputReaders[0], outputReaders[1], subprocess.exited]);
2805
2846
  return {
2806
2847
  type: "exec_result",
2807
2848
  requestId: input.message.requestId,
2808
2849
  stdout,
2809
2850
  stderr,
2810
2851
  exitCode,
2811
- durationMs: Date.now() - startedAt
2852
+ durationMs: Date.now() - startedAt,
2853
+ ...input.resources.memoryLimitExceeded() ? {
2854
+ error: `Command exceeded its worker memory limit (${input.resources.memoryBytes} bytes). Reduce parallelism or memory use and retry.`
2855
+ } : {}
2812
2856
  };
2813
2857
  } catch (error) {
2858
+ outputAbort.abort(error);
2814
2859
  if (error instanceof StaleWorkerAdmissionError) throw error;
2860
+ input.resources.kill();
2815
2861
  return {
2816
2862
  type: "exec_result",
2817
2863
  requestId: input.message.requestId,
@@ -2829,6 +2875,8 @@ async function executeCommand(input) {
2829
2875
  if (spawnedProcess) {
2830
2876
  await reapCompletedCredentialBearingProcessGroup(input.message.runId, spawnedProcess);
2831
2877
  }
2878
+ outputAbort.abort();
2879
+ await Promise.allSettled(outputReaders);
2832
2880
  activeProcesses.delete(input.message.runId);
2833
2881
  }
2834
2882
  }
@@ -2866,7 +2914,7 @@ async function executeStreamingCommand(input) {
2866
2914
  const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
2867
2915
  const interactive = input.message.interactive === true;
2868
2916
  input.assertAdmission();
2869
- const subprocess = Bun.spawn(input.message.argv, {
2917
+ const subprocess = Bun.spawn(input.resources.wrap(input.message.argv), {
2870
2918
  cwd,
2871
2919
  // Without an explicit stdin the process reads /dev/null and interactive
2872
2920
  // prompts see immediate EOF; "pipe" keeps stdin open for exec_stdin.
@@ -2909,6 +2957,7 @@ async function executeStreamingCommand(input) {
2909
2957
  if (input.message.timeoutMs) {
2910
2958
  timeout = setTimeout(() => {
2911
2959
  timedOut = true;
2960
+ input.resources.kill();
2912
2961
  closeProcessStdin(activeProcesses.get(input.message.runId));
2913
2962
  void (0, import_process_tree.terminateProcessTree)(subprocess).catch((error) => {
2914
2963
  process.stderr.write(
@@ -2937,6 +2986,16 @@ async function executeStreamingCommand(input) {
2937
2986
  });
2938
2987
  })
2939
2988
  ]);
2989
+ if (input.resources.memoryLimitExceeded()) {
2990
+ sendWorkerMessage(input.ws, {
2991
+ type: "exec_output",
2992
+ runId: input.message.runId,
2993
+ stream: "stderr",
2994
+ data: `
2995
+ [r5d-worker] Command exceeded its memory limit (${input.resources.memoryBytes} bytes). Reduce parallelism or memory use and retry.
2996
+ `
2997
+ });
2998
+ }
2940
2999
  const canonicalWorkspaceHead = canonicalSyncTerminalHead(input.resolvedTarget);
2941
3000
  const terminal = {
2942
3001
  type: "exec_exit",
@@ -3366,6 +3425,7 @@ async function openPty(input) {
3366
3425
  }) : {};
3367
3426
  const targetEnv = targetProcessEnv(input.rootDir, input.resolvedTarget.target);
3368
3427
  const shell = resolveHostShell(input.message.command);
3428
+ const shellArgv = input.resources.wrap([shell.file, ...shell.args]);
3369
3429
  const outputCoalescer = (0, import_pty_output_coalescer.createPtyOutputCoalescer)({
3370
3430
  send: (data) => {
3371
3431
  sendWorkerMessage(input.ws, {
@@ -3385,8 +3445,8 @@ async function openPty(input) {
3385
3445
  };
3386
3446
  try {
3387
3447
  const ptyProcess = createNodePtyBridge({
3388
- file: shell.file,
3389
- args: shell.args,
3448
+ file: shellArgv[0],
3449
+ args: shellArgv.slice(1),
3390
3450
  ptyOptions: {
3391
3451
  name: "xterm-256color",
3392
3452
  cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
@@ -3415,11 +3475,18 @@ async function openPty(input) {
3415
3475
  outputCoalescer.push(data);
3416
3476
  },
3417
3477
  onExit: (event) => {
3478
+ if (input.resources.memoryLimitExceeded())
3479
+ outputCoalescer.push(
3480
+ `\r
3481
+ [r5d-worker] Terminal exceeded its memory limit (${input.resources.memoryBytes} bytes). Reduce parallelism or memory use and retry.\r
3482
+ `
3483
+ );
3418
3484
  outputCoalescer.flush();
3419
3485
  input.releaseWorkspaceMutation?.();
3420
3486
  activePtys.delete(input.message.ptyId);
3421
3487
  cleanupEnvFiles();
3422
3488
  input.onTerminal?.();
3489
+ void releaseWorkerJobResources(input.resources);
3423
3490
  sendWorkerMessage(input.ws, {
3424
3491
  type: "pty_exit",
3425
3492
  ptyId: input.message.ptyId,
@@ -3433,6 +3500,7 @@ async function openPty(input) {
3433
3500
  activePtys.delete(input.message.ptyId);
3434
3501
  cleanupEnvFiles();
3435
3502
  input.onTerminal?.();
3503
+ void releaseWorkerJobResources(input.resources);
3436
3504
  sendWorkerMessage(input.ws, {
3437
3505
  type: "pty_error",
3438
3506
  requestId: input.message.requestId,
@@ -3482,6 +3550,7 @@ function resizePty(message) {
3482
3550
  ptyProcess.resize(Math.max(1, Math.min(Math.floor(message.cols || 80), 500)), Math.max(1, Math.min(Math.floor(message.rows || 24), 500)));
3483
3551
  }
3484
3552
  function closePty(message) {
3553
+ workerJobResources?.cancel(`pty:${message.ptyId}`);
3485
3554
  const ptyProcess = activePtys.get(message.ptyId);
3486
3555
  if (!ptyProcess) {
3487
3556
  return;
@@ -3500,7 +3569,7 @@ function closePtysBestEffort(ptys, reportFailure) {
3500
3569
  function closeAllPtys() {
3501
3570
  closePtysBestEffort(activePtys, (ptyId, error) => {
3502
3571
  process.stderr.write(
3503
- `[r5d-worker] failed to close PTY ${ptyId} after control disconnect: ${error instanceof Error ? error.message : String(error)}
3572
+ `[r5d-worker] failed to close PTY ${ptyId} after execution lease expiry: ${error instanceof Error ? error.message : String(error)}
3504
3573
  `
3505
3574
  );
3506
3575
  });
@@ -3573,6 +3642,11 @@ async function startWorker(options, projectRuntime = {
3573
3642
  initializeCredentialStoreDirectoryOnce();
3574
3643
  const label = requireValue(options.label, "Missing required --label <label>");
3575
3644
  validateLabel(label);
3645
+ if (!workerJobResources) {
3646
+ workerJobResources = (0, import_job_resources.createWorkerJobResources)();
3647
+ process.stdout.write(`[r5d-worker] job resources: ${workerJobResources.isolation}; ${JSON.stringify(workerJobResources.budget)}
3648
+ `);
3649
+ }
3576
3650
  const rootDir = import_node_path.default.resolve(options.rootDir ?? process.env.R5D_ROOT ?? defaultRootDir());
3577
3651
  recoveryStore ??= new import_recovery_store.WorkerRecoveryStore(
3578
3652
  import_node_path.default.join(rootDir, ".worker-recovery", (0, import_node_crypto.createHash)("sha256").update(`${baseUrl}\0${label}`).digest("hex"), "protocol.sqlite")
@@ -5777,6 +5851,7 @@ async function startWorker(options, projectRuntime = {
5777
5851
  }
5778
5852
  if (message.type === "cancel_session") {
5779
5853
  recoveryStore.cancelSession(message.sessionId);
5854
+ workerJobResources?.cancelSession(message.sessionId);
5780
5855
  await Promise.all(
5781
5856
  [...activeProcesses].filter(([, active]) => active.sessionId === message.sessionId).map(async ([runId, active]) => {
5782
5857
  cancelledProcessRuns.add(runId);
@@ -6263,8 +6338,14 @@ async function startWorker(options, projectRuntime = {
6263
6338
  return;
6264
6339
  }
6265
6340
  if (message.type === "cancel") {
6341
+ let resourceCancellationError;
6342
+ try {
6343
+ workerJobResources?.cancel(message.runId);
6344
+ } catch (error) {
6345
+ resourceCancellationError = error instanceof Error ? error.message : String(error);
6346
+ }
6266
6347
  const active = activeProcesses.get(message.runId);
6267
- let cancelled = true;
6348
+ let cancelled = resourceCancellationError === void 0;
6268
6349
  let cancelMessage;
6269
6350
  if (active) {
6270
6351
  try {
@@ -6279,6 +6360,7 @@ async function startWorker(options, projectRuntime = {
6279
6360
  cancelledProcessRuns.add(message.runId);
6280
6361
  cancelMessage = "Cancellation queued before command start";
6281
6362
  }
6363
+ if (resourceCancellationError) cancelMessage += `; job resource termination failed: ${resourceCancellationError}`;
6282
6364
  sendSerializedWorkerMessage(
6283
6365
  ws,
6284
6366
  JSON.stringify({
@@ -6365,13 +6447,16 @@ async function startWorker(options, projectRuntime = {
6365
6447
  });
6366
6448
  return;
6367
6449
  }
6368
- await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
6369
- workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
6370
- if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
6371
- });
6450
+ let resources;
6451
+ let resourcesTransferred = false;
6372
6452
  let releaseWorkspaceMutation;
6373
6453
  let mutationLeaseTransferred = false;
6374
6454
  try {
6455
+ resources = await acquireWorkerCommandResources(ws, message, assertMessageAdmission);
6456
+ await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
6457
+ workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
6458
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
6459
+ });
6375
6460
  releaseWorkspaceMutation = await (0, import_workspace_command_sync_policy.acquireWorkspaceCommandMutation)(message.target, workspaceSyncSingleFlight);
6376
6461
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
6377
6462
  throw new Error(
@@ -6382,6 +6467,7 @@ async function startWorker(options, projectRuntime = {
6382
6467
  process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${describeWorkerSessionTarget(message.target)}
6383
6468
  `);
6384
6469
  await openPty({
6470
+ resources,
6385
6471
  ws,
6386
6472
  message,
6387
6473
  resolvedTarget,
@@ -6394,6 +6480,7 @@ async function startWorker(options, projectRuntime = {
6394
6480
  }
6395
6481
  } : {}
6396
6482
  });
6483
+ resourcesTransferred = true;
6397
6484
  mutationLeaseTransferred = releaseWorkspaceMutation !== void 0;
6398
6485
  } catch (error) {
6399
6486
  sendWorkerMessageFromCurrentSource(ws, {
@@ -6405,6 +6492,7 @@ async function startWorker(options, projectRuntime = {
6405
6492
  } finally {
6406
6493
  workspaceSyncPriorityPtyTargets.delete(message.ptyId);
6407
6494
  if (!mutationLeaseTransferred) releaseWorkspaceMutation?.();
6495
+ if (!resourcesTransferred) await releaseWorkerJobResources(resources);
6408
6496
  }
6409
6497
  return;
6410
6498
  }
@@ -6463,9 +6551,11 @@ async function startWorker(options, projectRuntime = {
6463
6551
  return;
6464
6552
  }
6465
6553
  let result;
6554
+ let resources;
6466
6555
  let targetReserved = false;
6467
6556
  const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
6468
6557
  try {
6558
+ resources = await acquireWorkerCommandResources(ws, message, assertMessageAdmission);
6469
6559
  if (hasWorkspaceEffect) {
6470
6560
  await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
6471
6561
  workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
@@ -6483,6 +6573,7 @@ async function startWorker(options, projectRuntime = {
6483
6573
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
6484
6574
  `);
6485
6575
  return await executeCommand({
6576
+ resources,
6486
6577
  message,
6487
6578
  resolvedTarget,
6488
6579
  baseUrl,
@@ -6518,6 +6609,8 @@ async function startWorker(options, projectRuntime = {
6518
6609
  };
6519
6610
  } finally {
6520
6611
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
6612
+ await releaseWorkerJobResources(resources);
6613
+ cancelledProcessRuns.delete(message.runId);
6521
6614
  }
6522
6615
  if (infrastructureStoppedRunIds.has(message.runId)) {
6523
6616
  recoveryStore.unknown(message.requestId);
@@ -6549,12 +6642,14 @@ async function startWorker(options, projectRuntime = {
6549
6642
  runId: message.runId
6550
6643
  });
6551
6644
  const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
6645
+ let resources;
6552
6646
  const runCommand = async () => {
6553
6647
  try {
6554
6648
  const resolvedTarget = resolveMessageTarget(message.target);
6555
6649
  process.stdout.write(`[r5d-worker] exec_start ${message.runId}: ${message.argv.join(" ")}
6556
6650
  `);
6557
6651
  await executeStreamingCommand({
6652
+ resources,
6558
6653
  ws,
6559
6654
  message,
6560
6655
  resolvedTarget,
@@ -6588,6 +6683,7 @@ async function startWorker(options, projectRuntime = {
6588
6683
  const execution = (async () => {
6589
6684
  let targetReserved = false;
6590
6685
  try {
6686
+ resources = await acquireWorkerCommandResources(ws, message, assertMessageAdmission);
6591
6687
  if (hasWorkspaceEffect) {
6592
6688
  await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
6593
6689
  workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
@@ -6603,6 +6699,8 @@ async function startWorker(options, projectRuntime = {
6603
6699
  });
6604
6700
  } finally {
6605
6701
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
6702
+ await releaseWorkerJobResources(resources);
6703
+ cancelledProcessRuns.delete(message.runId);
6606
6704
  }
6607
6705
  })();
6608
6706
  void execution.catch((error) => {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.110",
3
+ "version": "0.0.111",
4
4
  "type": "commonjs"
5
5
  }