@ricsam/r5d-worker 0.0.121 → 0.0.123

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
@@ -81,13 +81,16 @@ Operators may set `R5D_WORKER_COMMAND_LAUNCHER` to a JSON argv array, such as `[
81
81
 
82
82
  The launcher owns admission, OS isolation, budgets, and diagnostic evidence. Its JSONL protocol uses `{requestId, method, ...fields}` requests and `{requestId, result}` or `{requestId, error}` replies:
83
83
 
84
- - `initialize` returns `{}` once ready; failure prevents command admission.
85
- - `acquire` receives `{id, kind, workspaceEffect?, sessionId?}` and returns `{argvPrefix: string[]}`. The worker prepends this argv to the command or terminal shell without interpreting it.
84
+ - `initialize` returns `{}` once ready; failure prevents command admission. A launcher that bounds concurrency may return `{capacity: {generalSlots, utilitySlots, controlSlots, controlRuntimeMs, queueTimeoutMs}}` so the worker can report lane totals.
85
+ - `acquire` receives `{id, kind, workspaceEffect?, commandClass?, sessionId?}` and returns `{argvPrefix: string[]}`. The worker prepends this argv to the command or terminal shell without interpreting it. `commandClass: "control"` is sent only for an `exec_start` whose argv is one plain `r5dctl` coordination command (validated against the worker's own allowlist, spawned without a shell, resolved from the worker's own `PATH`, with code-loading environment variables removed, and stopped by the worker after `controlRuntimeMs`); a launcher may give such commands a small reserved lane.
86
+ - `status` returns `{capacity, lanes: {general|utility|control: {slots, active: [{id, sessionId?, since}], queued: [...]}}}`; optional, for operator inspection.
86
87
  - `cancel` receives `{id}` and irreversibly revokes queued or acquired work. `release` receives `{id}` and acknowledges only after cleanup. Both must be idempotent, including release before a delayed acquisition response.
87
88
  - `diagnose` receives `{id}` and returns `{message?: string}` with launcher-specific evidence. The worker does not infer a memory failure from an exit signal.
88
89
 
89
90
  Requests must be processed concurrently so cancellation and cleanup cannot wait behind queued admission. Controller RPCs are bounded (10 seconds, or 90 seconds for acquisition); external policy must reject excessive queue waits within that bound. Controller loss or protocol failure stops command admission, preserves uncertain outcomes for recovery, reaps ordinary children, and restarts the runtime through its supervisor. It never falls back to direct spawning. Shutdown closes controller stdin; the controller must reap its jobs before exiting.
90
91
 
91
- Cancellation is checked again immediately before spawn. Transient disconnects preserve commands and terminals; expiry of the five-minute execution lease cancels agent work and closes terminals. File operations, communication recovery, and cancellation do not enter launcher admission. Internal worker maintenance subprocesses are also outside this hook. Deployment-specific limits and service protection are documented in [Worker deployment resources](../../docs/worker-deployment-resources.md).
92
+ Cancellation is checked again immediately before spawn. Transient disconnects preserve commands and terminals; expiry of the five-minute execution lease cancels agent work and closes terminals. File operations, communication recovery, and cancellation do not enter launcher admission. Internal worker maintenance subprocesses are also outside this hook. The worker reports its own view of every lane (holders and queue, by run id) to the server with `capacity_report` whenever an admission changes; the platform exposes it through `r5dctl ps list` and `r5dctl workspace status`. Deployment-specific limits and service protection are documented in [Worker deployment resources](../../docs/worker-deployment-resources.md).
92
93
 
93
94
  When the connected `r5d-browser` requests a port forward, the worker opens each relayed connection only to `127.0.0.1` on the requested worker port. Browser-side and worker-side ports may differ. The worker never opens a public listener, and a disconnected worker leaves the browser's long-lived mapping unavailable until the same worker label reconnects.
95
+
96
+ Control execution requires an initialized launcher that explicitly declares its control capacity (or direct execution without an external launcher), an updated worker, and r5dctl installed on the worker's own PATH. Missing control support is an update error; a control request never falls back to a general shell. Deploy the app and worker support assets, update the CLI/worker, and reconnect before resuming paused work.
@@ -21,12 +21,20 @@ __export(command_launcher_exports, {
21
21
  CommandLaunchCancelledError: () => CommandLaunchCancelledError,
22
22
  CommandLauncherUnavailableError: () => CommandLauncherUnavailableError,
23
23
  WorkerCommandLauncher: () => WorkerCommandLauncher,
24
+ commandLaunchLane: () => commandLaunchLane,
24
25
  commandLauncherEnvironment: () => commandLauncherEnvironment,
25
26
  createWorkerCommandLauncher: () => createWorkerCommandLauncher,
26
27
  parseCommandLauncher: () => parseCommandLauncher
27
28
  });
28
29
  module.exports = __toCommonJS(command_launcher_exports);
29
30
  var import_node_child_process = require("node:child_process");
31
+ function commandLaunchLane(request) {
32
+ if (request.commandClass === "control") {
33
+ if (request.kind !== "exec_start") throw new Error("Control commands must be exec_start launches");
34
+ return "control";
35
+ }
36
+ return request.kind === "exec" && request.workspaceEffect === "none" ? "utility" : "general";
37
+ }
30
38
  class CommandLauncherUnavailableError extends Error {
31
39
  constructor(message, cause) {
32
40
  super(message, { cause });
@@ -114,12 +122,14 @@ class WorkerCommandLauncher {
114
122
  initialized = false;
115
123
  closePromise;
116
124
  sequence = 0;
125
+ capacity;
117
126
  async initialize() {
118
127
  this.assertHealthy();
119
128
  if (this.initialized) return;
120
129
  if (this.child) {
121
130
  try {
122
- await this.rpc("initialize");
131
+ const result = await this.rpc("initialize");
132
+ this.capacity = parseCapacity(result.capacity);
123
133
  } catch (error) {
124
134
  this.fail(error instanceof Error ? error : new Error(String(error)));
125
135
  await this.close();
@@ -131,19 +141,63 @@ class WorkerCommandLauncher {
131
141
  get activeIds() {
132
142
  return [...this.launches.keys()];
133
143
  }
144
+ /** The lane totals the launcher declared at initialization; undefined for a direct worker or an older launcher. */
145
+ get declaredCapacity() {
146
+ return this.capacity;
147
+ }
148
+ get supportsControlLane() {
149
+ return this.initialized && (this.mode === "direct" || (this.capacity?.controlSlots ?? 0) > 0);
150
+ }
151
+ /** Current admissions per lane, for the worker's capacity report. */
152
+ capacityReport() {
153
+ const bounded = this.child !== void 0;
154
+ const lanes = {
155
+ general: { slots: bounded ? this.capacity?.generalSlots ?? null : null, active: [], queued: [] },
156
+ utility: { slots: bounded ? this.capacity?.utilitySlots ?? null : null, active: [], queued: [] },
157
+ control: { slots: bounded ? this.capacity?.controlSlots ?? null : null, active: [], queued: [] }
158
+ };
159
+ for (const record of this.launches.values()) {
160
+ if (record.released) continue;
161
+ const entry = {
162
+ id: record.request.id,
163
+ ...record.request.sessionId ? { sessionId: record.request.sessionId } : {},
164
+ since: record.since
165
+ };
166
+ (record.phase === "active" ? lanes[record.lane].active : lanes[record.lane].queued).push(entry);
167
+ }
168
+ return { bounded, controlRuntimeMs: bounded ? this.capacity?.controlRuntimeMs ?? null : null, lanes };
169
+ }
170
+ notifyCapacityChange() {
171
+ try {
172
+ this.options.onCapacityChange?.();
173
+ } catch {
174
+ }
175
+ }
134
176
  async acquire(request) {
135
177
  this.assertHealthy();
136
178
  if (!this.initialized) throw new Error("Command launcher is not initialized");
137
179
  request.assertAdmission();
138
180
  if (this.launches.has(request.id)) throw new Error(`Duplicate command launch identity: ${request.id}`);
181
+ const lane = commandLaunchLane(request);
182
+ if (lane === "control" && !this.supportsControlLane)
183
+ throw new Error("Update the worker command launcher before running control commands");
139
184
  let rejectAdmission;
140
185
  const cancellation = new Promise((_resolve, reject) => {
141
186
  rejectAdmission = reject;
142
187
  });
143
188
  void cancellation.catch(() => {
144
189
  });
145
- const record = { request, revoked: false, released: false, rejectAdmission };
190
+ const record = {
191
+ request,
192
+ lane,
193
+ phase: this.child ? "queued" : "active",
194
+ since: (/* @__PURE__ */ new Date()).toISOString(),
195
+ revoked: false,
196
+ released: false,
197
+ rejectAdmission
198
+ };
146
199
  this.launches.set(request.id, record);
200
+ this.notifyCapacityChange();
147
201
  let prefix = [];
148
202
  let admissionTimer;
149
203
  try {
@@ -169,6 +223,9 @@ class WorkerCommandLauncher {
169
223
  }
170
224
  }, 100);
171
225
  prefix = await Promise.race([acquisition, cancellation]);
226
+ record.phase = "active";
227
+ record.since = (/* @__PURE__ */ new Date()).toISOString();
228
+ this.notifyCapacityChange();
172
229
  }
173
230
  request.assertAdmission();
174
231
  this.assertHealthy();
@@ -268,6 +325,7 @@ class WorkerCommandLauncher {
268
325
  }
269
326
  record.released = true;
270
327
  this.launches.delete(record.request.id);
328
+ this.notifyCapacityChange();
271
329
  })();
272
330
  }
273
331
  assertHealthy() {
@@ -329,6 +387,21 @@ class WorkerCommandLauncher {
329
387
  return this.failure;
330
388
  }
331
389
  }
390
+ function parseCapacity(value) {
391
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
392
+ const candidate = value;
393
+ const numbers = ["generalSlots", "utilitySlots", "controlSlots", "controlRuntimeMs", "queueTimeoutMs"];
394
+ if (!numbers.every((key) => typeof candidate[key] === "number" && Number.isSafeInteger(candidate[key]) && candidate[key] >= 0)) {
395
+ throw new Error("Command launcher initialize returned an invalid capacity");
396
+ }
397
+ return {
398
+ generalSlots: candidate.generalSlots,
399
+ utilitySlots: candidate.utilitySlots,
400
+ controlSlots: candidate.controlSlots,
401
+ controlRuntimeMs: candidate.controlRuntimeMs,
402
+ queueTimeoutMs: candidate.queueTimeoutMs
403
+ };
404
+ }
332
405
  async function createWorkerCommandLauncher(options = {}) {
333
406
  const launcher = new WorkerCommandLauncher({ ...options, argv: parseCommandLauncher(options.configuration) });
334
407
  await launcher.initialize();
@@ -339,6 +412,7 @@ async function createWorkerCommandLauncher(options = {}) {
339
412
  CommandLaunchCancelledError,
340
413
  CommandLauncherUnavailableError,
341
414
  WorkerCommandLauncher,
415
+ commandLaunchLane,
342
416
  commandLauncherEnvironment,
343
417
  createWorkerCommandLauncher,
344
418
  parseCommandLauncher
@@ -0,0 +1,177 @@
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 control_command_policy_exports = {};
20
+ __export(control_command_policy_exports, {
21
+ CONTROL_COMMAND_MAX_ARGV: () => CONTROL_COMMAND_MAX_ARGV,
22
+ CONTROL_COMMAND_MAX_LENGTH: () => CONTROL_COMMAND_MAX_LENGTH,
23
+ CONTROL_COMMAND_PROGRAM: () => CONTROL_COMMAND_PROGRAM,
24
+ CONTROL_COMMAND_RUNTIME_MS: () => CONTROL_COMMAND_RUNTIME_MS,
25
+ assertControlCommandArgv: () => assertControlCommandArgv,
26
+ classifyControlCommand: () => classifyControlCommand,
27
+ controlCommandEnvironment: () => controlCommandEnvironment,
28
+ resolveControlCommandExecutable: () => resolveControlCommandExecutable
29
+ });
30
+ module.exports = __toCommonJS(control_command_policy_exports);
31
+ const CONTROL_COMMAND_PROGRAM = "r5dctl";
32
+ const CONTROL_COMMAND_RUNTIME_MS = 12e4;
33
+ const CONTROL_COMMAND_MAX_ARGV = 64;
34
+ const CONTROL_COMMAND_MAX_LENGTH = 4096;
35
+ const CONTROL_GLOBAL_FLAGS_WITH_VALUE = /* @__PURE__ */ new Set(["-p", "--project", "-s", "--session", "-b", "--branch"]);
36
+ const CONTROL_GLOBAL_FLAGS = /* @__PURE__ */ new Set(["--json", "-h", "--help", "-v", "--version"]);
37
+ const REFUSED_ANYWHERE = /* @__PURE__ */ new Set(["-f", "--follow", "--watch", "--base-url", "--config", "--token", "--api-key"]);
38
+ const CONTROL_VERBS = {
39
+ session: /* @__PURE__ */ new Set(["status", "stop", "prompt", "claim", "claims", "process-log"]),
40
+ sessions: /* @__PURE__ */ new Set(["recent"]),
41
+ describe: /* @__PURE__ */ new Set(["project", "branch", "session", "branch-operation", "branch-deletion"]),
42
+ get: /* @__PURE__ */ new Set(["projects", "branches", "sessions", "envs", "env"]),
43
+ conversation: /* @__PURE__ */ new Set(["overview", "inspect-node", "inspect-work"]),
44
+ ps: /* @__PURE__ */ new Set(["list", "history", "inspect", "stop"]),
45
+ workspace: /* @__PURE__ */ new Set(["status"]),
46
+ auth: /* @__PURE__ */ new Set(["status"]),
47
+ create: /* @__PURE__ */ new Set(["session"]),
48
+ update: /* @__PURE__ */ new Set(["session"]),
49
+ delete: /* @__PURE__ */ new Set(["session"]),
50
+ "answer-questions": null,
51
+ "answer-env-request": null
52
+ };
53
+ const CODE_LOADING_ENV_PATTERN = /^(NODE_OPTIONS|NODE_PATH|NODE_REPL_EXTERNAL_MODULE|BUN_OPTIONS|BUN_CONFIG_[A-Z_]+|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|DYLD_[A-Z_]+|PATH|PATHEXT|PYTHONPATH|PERL5OPT|RUBYOPT|SHELL|ENV|BASH_ENV)$/i;
54
+ const BARE_WORD = /^[A-Za-z0-9_@%+=:,./-]+$/;
55
+ const DOUBLE_QUOTED_FORBIDDEN = /[$`\\!]/;
56
+ function tokenizePlainInvocation(command) {
57
+ if (command.length > CONTROL_COMMAND_MAX_LENGTH) return { reason: "command is longer than the control lane allows" };
58
+ if (/[\r\n]/.test(command)) return { reason: "multi-line commands are shell scripts" };
59
+ const argv = [];
60
+ let index = 0;
61
+ while (index < command.length) {
62
+ const char = command[index];
63
+ if (char === " " || char === " ") {
64
+ index += 1;
65
+ continue;
66
+ }
67
+ let word = "";
68
+ let quotedParts = 0;
69
+ let bareParts = 0;
70
+ while (index < command.length && command[index] !== " " && command[index] !== " ") {
71
+ const current = command[index];
72
+ if (current === "'") {
73
+ const end = command.indexOf("'", index + 1);
74
+ if (end < 0) return { reason: "unterminated single quote" };
75
+ word += command.slice(index + 1, end);
76
+ quotedParts += 1;
77
+ index = end + 1;
78
+ continue;
79
+ }
80
+ if (current === '"') {
81
+ const end = command.indexOf('"', index + 1);
82
+ if (end < 0) return { reason: "unterminated double quote" };
83
+ const inner = command.slice(index + 1, end);
84
+ if (DOUBLE_QUOTED_FORBIDDEN.test(inner)) return { reason: "double-quoted text would be expanded by the shell" };
85
+ word += inner;
86
+ quotedParts += 1;
87
+ index = end + 1;
88
+ continue;
89
+ }
90
+ let bare = "";
91
+ while (index < command.length && !/[\s'"]/.test(command[index])) {
92
+ bare += command[index];
93
+ index += 1;
94
+ }
95
+ if (!BARE_WORD.test(bare)) return { reason: `shell syntax in "${bare}" is not part of a plain invocation` };
96
+ word += bare;
97
+ bareParts += 1;
98
+ }
99
+ if (quotedParts + bareParts > 1) return { reason: "adjacent quoted and bare text is shell concatenation" };
100
+ argv.push(word);
101
+ if (argv.length > CONTROL_COMMAND_MAX_ARGV) return { reason: "too many arguments for a control command" };
102
+ }
103
+ if (argv.length === 0) return { reason: "empty command" };
104
+ return { argv };
105
+ }
106
+ function assertControlCommandArgv(argv) {
107
+ if (argv.length === 0 || argv.length > CONTROL_COMMAND_MAX_ARGV) throw new Error("Control commands are a single r5dctl invocation");
108
+ if (argv[0] !== CONTROL_COMMAND_PROGRAM) throw new Error(`Control commands must start with ${CONTROL_COMMAND_PROGRAM}`);
109
+ if (argv.reduce((length, argument) => length + (typeof argument === "string" ? argument.length : 0) + 1, 0) > CONTROL_COMMAND_MAX_LENGTH)
110
+ throw new Error("Control command arguments exceed the length limit");
111
+ for (const argument of argv) {
112
+ if (typeof argument !== "string" || argument.includes("\0") || /[\r\n]/.test(argument)) throw new Error("Control command arguments must be plain text");
113
+ if (REFUSED_ANYWHERE.has(argument.split("=", 1)[0])) throw new Error(`Control command option is not allowed (${argument.split("=", 1)[0]})`);
114
+ if (argument === "--") throw new Error("Control commands cannot use `--`");
115
+ }
116
+ let position = 1;
117
+ while (position < argv.length) {
118
+ const argument = argv[position];
119
+ if (CONTROL_GLOBAL_FLAGS.has(argument)) {
120
+ position += 1;
121
+ continue;
122
+ }
123
+ if (CONTROL_GLOBAL_FLAGS_WITH_VALUE.has(argument)) {
124
+ if (position + 1 >= argv.length) throw new Error(`Missing value for ${argument}`);
125
+ position += 2;
126
+ continue;
127
+ }
128
+ if (argument.startsWith("-")) throw new Error(`Global flag ${argument} is not allowed in a control command`);
129
+ break;
130
+ }
131
+ const verb = argv[position];
132
+ if (verb === void 0) throw new Error("Control commands need a coordination verb (for example `r5dctl ps list`)");
133
+ const allowed = Object.hasOwn(CONTROL_VERBS, verb) ? CONTROL_VERBS[verb] : void 0;
134
+ if (allowed === void 0) throw new Error(`r5dctl ${verb} is not a coordination command`);
135
+ if (allowed === null) return;
136
+ const subject = argv[position + 1];
137
+ if (subject === void 0 || !allowed.has(subject)) {
138
+ throw new Error(`r5dctl ${verb}${subject ? ` ${subject}` : ""} is not a coordination command`);
139
+ }
140
+ }
141
+ function resolveControlCommandExecutable(trustedPath, which, program = CONTROL_COMMAND_PROGRAM) {
142
+ const resolved = trustedPath ? which(program, { PATH: trustedPath }) : null;
143
+ if (!resolved) throw new Error(`${program} is not installed on the worker's trusted PATH; control commands are unavailable until it is`);
144
+ return resolved;
145
+ }
146
+ function controlCommandEnvironment(environment, trustedPath) {
147
+ const result = {};
148
+ for (const [key, value] of Object.entries(environment)) {
149
+ if (value === void 0 || CODE_LOADING_ENV_PATTERN.test(key)) continue;
150
+ result[key] = value;
151
+ }
152
+ if (trustedPath !== void 0) result.PATH = trustedPath;
153
+ return result;
154
+ }
155
+ function classifyControlCommand(command) {
156
+ const tokenized = tokenizePlainInvocation(command.trim());
157
+ if ("reason" in tokenized) return { control: false, reason: tokenized.reason };
158
+ const { argv } = tokenized;
159
+ if (argv[0] !== CONTROL_COMMAND_PROGRAM) return { control: false, reason: "not an r5dctl invocation" };
160
+ try {
161
+ assertControlCommandArgv(argv);
162
+ } catch (error) {
163
+ return { control: false, reason: error instanceof Error ? error.message : String(error) };
164
+ }
165
+ return { control: true, argv };
166
+ }
167
+ // Annotate the CommonJS export names for ESM import in node:
168
+ 0 && (module.exports = {
169
+ CONTROL_COMMAND_MAX_ARGV,
170
+ CONTROL_COMMAND_MAX_LENGTH,
171
+ CONTROL_COMMAND_PROGRAM,
172
+ CONTROL_COMMAND_RUNTIME_MS,
173
+ assertControlCommandArgv,
174
+ classifyControlCommand,
175
+ controlCommandEnvironment,
176
+ resolveControlCommandExecutable
177
+ });
package/dist/cjs/main.cjs CHANGED
@@ -74,6 +74,8 @@ var import_bun_sqlite = require("bun:sqlite");
74
74
  var import_cli_update = require("./cli-update.cjs");
75
75
  var import_git_process_environment = require("./git-process-environment.cjs");
76
76
  var import_process_tree = require("./process-tree.cjs");
77
+ var import_project_checkout_garbage = require("./project-checkout-garbage.cjs");
78
+ var import_control_command_policy = require("./control-command-policy.cjs");
77
79
  var import_pty_output_coalescer = require("./pty-output-coalescer.cjs");
78
80
  var import_port_forward_client = require("./port-forward-client.cjs");
79
81
  var import_registry_auth = require("./registry-auth.cjs");
@@ -157,6 +159,26 @@ const PTY_FOREGROUND_POLL_MS = 1e3;
157
159
  const PTY_FOREGROUND_IDLE_ENABLED = process.env.R5D_PTY_FOREGROUND_IDLE !== "0";
158
160
  const PTY_TMP_PATH_PREFIX = "r5d-worker-tmp://";
159
161
  let workerCommandLauncher;
162
+ const trustedControlPath = process.env.PATH;
163
+ let capacityReportTimer;
164
+ let capacityReportSocket = null;
165
+ let lastCapacityReport;
166
+ function scheduleCapacityReport(force = false) {
167
+ if (force) lastCapacityReport = void 0;
168
+ if (capacityReportTimer) return;
169
+ capacityReportTimer = setTimeout(() => {
170
+ capacityReportTimer = void 0;
171
+ const ws = capacityReportSocket;
172
+ if (!ws || ws.readyState !== WebSocket.OPEN || !workerCommandLauncher) return;
173
+ const capacity = { ...workerCommandLauncher.capacityReport(), reportedAt: (/* @__PURE__ */ new Date()).toISOString() };
174
+ const { reportedAt: _reportedAt, ...comparable } = capacity;
175
+ const serialized = JSON.stringify(comparable);
176
+ if (serialized === lastCapacityReport) return;
177
+ lastCapacityReport = serialized;
178
+ sendWorkerMessage(ws, { type: "capacity_report", capacity });
179
+ }, 100);
180
+ capacityReportTimer.unref?.();
181
+ }
160
182
  const activeProcesses = /* @__PURE__ */ new Map();
161
183
  const credentialBearingProcessGroups = /* @__PURE__ */ new Map();
162
184
  const credentialBearingProcessGroupTargets = /* @__PURE__ */ new Map();
@@ -274,6 +296,15 @@ let workerAdmissionGeneration = 0;
274
296
  const workspaceMutationGate = new import_workspace_mutation_gate.WorkspaceMutationGate();
275
297
  let workspaceSyncQueue = Promise.resolve();
276
298
  let startupProjectSnapshotRecoveryCompleted = false;
299
+ const checkoutGarbageCollectors = /* @__PURE__ */ new Map();
300
+ function checkoutGarbageCollectorFor(projectsRoot) {
301
+ let collector = checkoutGarbageCollectors.get(projectsRoot);
302
+ if (!collector) {
303
+ collector = new import_project_checkout_garbage.ProjectCheckoutGarbageCollector({ projectsRoot });
304
+ checkoutGarbageCollectors.set(projectsRoot, collector);
305
+ }
306
+ return collector;
307
+ }
277
308
  const workspaceSyncSingleFlight = {
278
309
  runExclusive(operation) {
279
310
  const queued = workspaceMutationGate.runSync(operation);
@@ -2765,6 +2796,10 @@ function cancelWorkerCommandLaunch(resources) {
2765
2796
  });
2766
2797
  }
2767
2798
  async function acquireWorkerCommandLaunch(ws, message, assertAdmission) {
2799
+ if (message.type === "exec_start" && message.commandClass === "control") {
2800
+ (0, import_control_command_policy.assertControlCommandArgv)(message.argv);
2801
+ if (message.interactive) throw new Error("Control commands cannot be interactive");
2802
+ }
2768
2803
  const id = message.type === "pty_open" ? `pty:${message.ptyId}` : message.runId;
2769
2804
  const operationId = message.type === "pty_open" ? id : `exec:${id}`;
2770
2805
  sendWorkerMessage(ws, { type: "heartbeat_lease", operationId, active: true });
@@ -2773,6 +2808,7 @@ async function acquireWorkerCommandLaunch(ws, message, assertAdmission) {
2773
2808
  id,
2774
2809
  kind: message.type,
2775
2810
  ..."workspaceEffect" in message && message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
2811
+ ...message.type === "exec_start" && message.commandClass === "control" ? { commandClass: "control" } : {},
2776
2812
  ..."sessionId" in message && message.sessionId ? { sessionId: message.sessionId } : {},
2777
2813
  assertAdmission: () => {
2778
2814
  assertAdmission();
@@ -2920,8 +2956,17 @@ async function executeStreamingCommand(input) {
2920
2956
  });
2921
2957
  const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
2922
2958
  const interactive = input.message.interactive === true;
2959
+ const control = input.message.commandClass === "control";
2960
+ let argv = input.message.argv;
2961
+ let environment = workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv2]);
2962
+ if (control) {
2963
+ (0, import_control_command_policy.assertControlCommandArgv)(argv);
2964
+ if (interactive) throw new Error("Control commands cannot be interactive");
2965
+ argv = [(0, import_control_command_policy.resolveControlCommandExecutable)(trustedControlPath, (program, options) => Bun.which(program, options)), ...argv.slice(1)];
2966
+ environment = (0, import_control_command_policy.controlCommandEnvironment)(environment, trustedControlPath);
2967
+ }
2923
2968
  input.assertAdmission();
2924
- const subprocess = Bun.spawn(input.resources.wrap(input.message.argv), {
2969
+ const subprocess = Bun.spawn(input.resources.wrap(argv), {
2925
2970
  cwd,
2926
2971
  // Without an explicit stdin the process reads /dev/null and interactive
2927
2972
  // prompts see immediate EOF; "pipe" keeps stdin open for exec_stdin.
@@ -2929,7 +2974,7 @@ async function executeStreamingCommand(input) {
2929
2974
  stdout: "pipe",
2930
2975
  stderr: "pipe",
2931
2976
  detached: true,
2932
- env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv2])
2977
+ env: environment
2933
2978
  });
2934
2979
  spawnedProcess = subprocess;
2935
2980
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
@@ -2944,12 +2989,13 @@ async function executeStreamingCommand(input) {
2944
2989
  pid: subprocess.pid,
2945
2990
  processGroupId: process.platform === "win32" ? void 0 : subprocess.pid,
2946
2991
  credentialId: input.message.credentialId,
2947
- argv: input.message.argv,
2992
+ argv,
2948
2993
  command: input.message.command,
2949
2994
  cwd,
2950
2995
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2951
2996
  ...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
2952
- ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2997
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
2998
+ ...control ? { commandClass: "control" } : {}
2953
2999
  });
2954
3000
  settlePreparation();
2955
3001
  started = true;
@@ -2961,7 +3007,8 @@ async function executeStreamingCommand(input) {
2961
3007
  pid: subprocess.pid,
2962
3008
  ...process.platform === "win32" ? {} : { processGroupId: subprocess.pid }
2963
3009
  });
2964
- if (input.message.timeoutMs) {
3010
+ const timeoutMs = control ? Math.min(input.message.timeoutMs ?? import_control_command_policy.CONTROL_COMMAND_RUNTIME_MS, import_control_command_policy.CONTROL_COMMAND_RUNTIME_MS) : input.message.timeoutMs;
3011
+ if (timeoutMs) {
2965
3012
  timeout = setTimeout(() => {
2966
3013
  timedOut = true;
2967
3014
  cancelWorkerCommandLaunch(input.resources);
@@ -2972,7 +3019,7 @@ async function executeStreamingCommand(input) {
2972
3019
  `
2973
3020
  );
2974
3021
  });
2975
- }, input.message.timeoutMs);
3022
+ }, timeoutMs);
2976
3023
  }
2977
3024
  const [exitCode] = await Promise.all([
2978
3025
  subprocess.exited,
@@ -3135,7 +3182,8 @@ function buildActiveProcessReports() {
3135
3182
  ...active.cwd ? { cwd: active.cwd } : {},
3136
3183
  startedAt: active.startedAt,
3137
3184
  ...active.interactive ? { interactive: true } : {},
3138
- ...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
3185
+ ...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
3186
+ ...active.commandClass ? { commandClass: active.commandClass } : {}
3139
3187
  }));
3140
3188
  }
3141
3189
  function sendActiveProcessReport(ws) {
@@ -3657,6 +3705,7 @@ async function startWorker(options, projectRuntime = {
3657
3705
  validateLabel(label);
3658
3706
  if (!workerCommandLauncher) {
3659
3707
  const launcher = await (0, import_command_launcher.createWorkerCommandLauncher)({
3708
+ onCapacityChange: () => scheduleCapacityReport(),
3660
3709
  configuration: process.env.R5D_WORKER_COMMAND_LAUNCHER,
3661
3710
  env: (0, import_command_launcher.commandLauncherEnvironment)(),
3662
3711
  onFailure: handleWorkerCommandLauncherFailure
@@ -3704,6 +3753,12 @@ async function startWorker(options, projectRuntime = {
3704
3753
  projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
3705
3754
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3706
3755
  let projectWorkspaceState = initializedWorkspaceState.store.read();
3756
+ const checkoutGarbageCollector = checkoutGarbageCollectorFor(projectsRoot);
3757
+ const staleStagedCheckouts = checkoutGarbageCollector.sweep();
3758
+ if (staleStagedCheckouts > 0) {
3759
+ process.stderr.write(`[r5d-worker] collecting ${staleStagedCheckouts} deleted checkout(s) left from an earlier run
3760
+ `);
3761
+ }
3707
3762
  const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3708
3763
  const pendingCheckouts = /* @__PURE__ */ new Map();
3709
3764
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
@@ -3818,6 +3873,7 @@ async function startWorker(options, projectRuntime = {
3818
3873
  `
3819
3874
  );
3820
3875
  }
3876
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3821
3877
  }
3822
3878
  const planSourcePath = import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/"));
3823
3879
  import_node_fs.default.rmSync(planSourcePath, { recursive: true, force: true });
@@ -3901,6 +3957,7 @@ async function startWorker(options, projectRuntime = {
3901
3957
  `
3902
3958
  );
3903
3959
  }
3960
+ if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
3904
3961
  import_node_fs.default.rmSync(import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
3905
3962
  readyProjectIds.delete(project.projectId);
3906
3963
  reconciledProjectConfigFingerprints.delete(project.projectId);
@@ -5835,6 +5892,8 @@ async function startWorker(options, projectRuntime = {
5835
5892
  workspaceIncidentConfigDeferralV1: true,
5836
5893
  workspaceConfigResetToCanonicalV1: true,
5837
5894
  projectBranchDeletionFastAckV1: true,
5895
+ projectBranchDeletionStagedRemovalV1: true,
5896
+ commandControlLaneV1: workerCommandLauncher?.supportsControlLane === true,
5838
5897
  projectMirrorLeaseV1: true,
5839
5898
  projectMirrorRefsTokensV1: true,
5840
5899
  projectBranchWorkingTreeModeV1: true
@@ -5936,7 +5995,9 @@ async function startWorker(options, projectRuntime = {
5936
5995
  if (admission !== "new") {
5937
5996
  const response = recoveryStore.response(operationRequestId);
5938
5997
  if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
5939
- return;
5998
+ if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
5999
+ return;
6000
+ }
5940
6001
  }
5941
6002
  }
5942
6003
  const messageAdmissionGeneration = workerAdmissionGeneration;
@@ -5959,6 +6020,8 @@ async function startWorker(options, projectRuntime = {
5959
6020
  });
5960
6021
  };
5961
6022
  if (message.type === "connected") {
6023
+ capacityReportSocket = ws;
6024
+ scheduleCapacityReport(true);
5962
6025
  return;
5963
6026
  }
5964
6027
  if (message.type === "project_mirror_refs_tokens") {
@@ -6714,8 +6777,10 @@ async function startWorker(options, projectRuntime = {
6714
6777
  const runCommand = async () => {
6715
6778
  try {
6716
6779
  const resolvedTarget = resolveMessageTarget(message.target);
6717
- process.stdout.write(`[r5d-worker] exec_start ${message.runId}: ${message.argv.join(" ")}
6718
- `);
6780
+ process.stdout.write(
6781
+ `[r5d-worker] exec_start ${message.runId}${message.commandClass === "control" ? " (control)" : ""}: ${message.argv.join(" ")}
6782
+ `
6783
+ );
6719
6784
  await executeStreamingCommand({
6720
6785
  resources,
6721
6786
  ws,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.121",
3
+ "version": "0.0.123",
4
4
  "type": "commonjs"
5
5
  }