@ricsam/r5d-worker 0.0.109 → 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/README.md CHANGED
@@ -71,4 +71,16 @@ worker exec macos bun test
71
71
 
72
72
  Agent shell commands receive no stdin by default: prompts read immediate EOF instead of hanging. A command started with `interactive: true` is spawned with a writable stdin pipe, and the agent's `shell_write` tool delivers input through the `exec_stdin` protocol message. The worker acknowledges each write with an `operation_result` carrying the delivered byte count, closes stdin on an explicit `eof`, on cancellation, on timeout, and at process exit, and advertises this support through the `execStdinV1` capability in its hello message. Servers refuse interactive runs and stdin writes for workers without the capability, so this is a protocol-compatible release that uses the ordinary worker update flow. Delivered stdin is audited server-side in the run's `process-runs/<runId>/stdin.log`.
73
73
 
74
+ ### Command resource limits
75
+
76
+ Managed Linux workers require cgroup v2 delegation with `Delegate=memory pids`, `DelegateSubgroup=control`, and `OOMPolicy=continue`. The supervisor and control runtime stay in the service's `control` subgroup. Every command and PTY enters its own memory-limited cgroup before executing user code, and all descendants remain under `r5d-worker.service` for credential-generation cleanup. Each job has a process limit of 512 and group OOM termination; a job that exhausts memory reports a command/terminal resource failure instead of stopping the worker. Control processes receive OOM protection, while the command wrapper removes that protection before executing user code.
77
+
78
+ Admission is shared across all projects in the runtime: two general commands or terminals can run concurrently. A separate two-slot utility lane, capped at 256 MiB per job, serves explicitly effect-free inline RPC commands. File operations, cancellation, and connection recovery do not wait in either queue. Inline RPC output is limited to 8 MiB per stream so a small producer cannot fill the control runtime; larger output requires streaming execution. General jobs reserve the rest of the configured aggregate budget. The managed VM sets direct jobs to 50% of effective RAM and uses a separately bounded default BuildKit builder (up to 25%, capped at 2 GiB), leaving at least 25% for the OS and control services. Effective RAM honors the tightest ancestor cgroup limit.
79
+
80
+ Queued commands retain their durable operation identities across reconnects. They wait at most 60 seconds before returning a capacity error without starting a command; cancellation also prevents a command whose asynchronous preparation is still in progress from starting. This bound prevents indefinite capacity deadlocks when all running shells wait on nested `r5dctl` work. Retry later or stop an occupied command/terminal when capacity is exhausted. Transient disconnects preserve running commands and PTYs; expiry of the five-minute execution lease closes them and cancels queued work.
81
+
82
+ The standalone default aggregate limit is 70% of effective RAM. Operators can set `R5D_WORKER_JOBS_MEMORY_PERCENT` (1–70), `R5D_WORKER_JOBS_MEMORY_BYTES`, `R5D_WORKER_JOB_MEMORY_BYTES`, `R5D_WORKER_UTILITY_MEMORY_BYTES`, `R5D_WORKER_JOB_CONCURRENCY`, and `R5D_WORKER_UTILITY_CONCURRENCY`. The sum of all slot reservations must fit the aggregate budget. `R5D_WORKER_JOB_QUEUE_TIMEOUT_MS` is capped at 60000; `R5D_WORKER_JOB_QUEUE_LIMIT` defaults to 128. Managed services set `R5D_WORKER_RESOURCE_ISOLATION=required` and refuse command service if delegation is unavailable. Direct workers on other platforms use bounded concurrency and explicitly report `concurrency-only`; they do not claim kernel memory isolation.
83
+
84
+ Cgroups constrain direct descendants, not workloads launched by separate daemons. The managed default `BUILDX_BUILDER` selects the bounded build daemon, but explicit alternate builders and arbitrary `docker run`/Compose containers need their own memory limits. These controls prevent accidental resource exhaustion; they are not a hostile same-UID sandbox.
85
+
74
86
  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.
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var job_resources_exports = {};
30
+ __export(job_resources_exports, {
31
+ WORKER_INLINE_OUTPUT_MAX_BYTES: () => WORKER_INLINE_OUTPUT_MAX_BYTES,
32
+ WorkerJobCancelledError: () => WorkerJobCancelledError,
33
+ WorkerJobCapacityError: () => WorkerJobCapacityError,
34
+ WorkerJobResourceController: () => WorkerJobResourceController,
35
+ collectWorkerInlineOutput: () => collectWorkerInlineOutput,
36
+ createWorkerJobResources: () => createWorkerJobResources,
37
+ effectiveWorkerMemory: () => effectiveWorkerMemory,
38
+ workerDelegatedCgroupRoot: () => workerDelegatedCgroupRoot,
39
+ workerJobBudget: () => workerJobBudget
40
+ });
41
+ module.exports = __toCommonJS(job_resources_exports);
42
+ var import_node_fs = __toESM(require("node:fs"), 1);
43
+ var import_node_os = __toESM(require("node:os"), 1);
44
+ var import_node_path = __toESM(require("node:path"), 1);
45
+ const MIB = 1024 * 1024;
46
+ const WORKER_INLINE_OUTPUT_MAX_BYTES = 8 * MIB;
47
+ async function collectWorkerInlineOutput(stream, maxBytes = WORKER_INLINE_OUTPUT_MAX_BYTES, signal) {
48
+ if (!stream) return "";
49
+ signal?.throwIfAborted();
50
+ const reader = stream.getReader();
51
+ const abort = () => {
52
+ void reader.cancel().catch(() => {
53
+ });
54
+ };
55
+ signal?.addEventListener("abort", abort, { once: true });
56
+ const decoder = new TextDecoder();
57
+ const parts = [];
58
+ let bytes = 0;
59
+ try {
60
+ while (true) {
61
+ const next = await reader.read();
62
+ signal?.throwIfAborted();
63
+ if (next.done) break;
64
+ bytes += next.value.byteLength;
65
+ if (bytes > maxBytes) {
66
+ void reader.cancel().catch(() => {
67
+ });
68
+ throw new Error(`Inline command output exceeded ${maxBytes} bytes. Use streaming execution for large output.`);
69
+ }
70
+ parts.push(decoder.decode(next.value, { stream: true }));
71
+ }
72
+ parts.push(decoder.decode());
73
+ return parts.join("");
74
+ } finally {
75
+ signal?.removeEventListener("abort", abort);
76
+ reader.releaseLock();
77
+ }
78
+ }
79
+ function positiveInteger(value, fallback, name) {
80
+ const result = value === void 0 ? fallback : Number(value);
81
+ if (!Number.isSafeInteger(result) || result <= 0) throw new Error(`${name} must be a positive integer`);
82
+ return result;
83
+ }
84
+ function workerJobBudget(effectiveMemoryBytes, env = process.env) {
85
+ const generalSlots = positiveInteger(env.R5D_WORKER_JOB_CONCURRENCY, 2, "R5D_WORKER_JOB_CONCURRENCY");
86
+ const utilitySlots = positiveInteger(env.R5D_WORKER_UTILITY_CONCURRENCY, 2, "R5D_WORKER_UTILITY_CONCURRENCY");
87
+ const memoryPercent = positiveInteger(env.R5D_WORKER_JOBS_MEMORY_PERCENT, 70, "R5D_WORKER_JOBS_MEMORY_PERCENT");
88
+ if (memoryPercent > 70) throw new Error("Worker jobs may reserve at most 70% of effective memory");
89
+ const aggregateBytes = positiveInteger(
90
+ env.R5D_WORKER_JOBS_MEMORY_BYTES,
91
+ Math.floor(effectiveMemoryBytes * memoryPercent / 100),
92
+ "R5D_WORKER_JOBS_MEMORY_BYTES"
93
+ );
94
+ const utilityBytes = positiveInteger(
95
+ env.R5D_WORKER_UTILITY_MEMORY_BYTES,
96
+ Math.min(256 * MIB, Math.floor(aggregateBytes / 10)),
97
+ "R5D_WORKER_UTILITY_MEMORY_BYTES"
98
+ );
99
+ const generalBytes = positiveInteger(
100
+ env.R5D_WORKER_JOB_MEMORY_BYTES,
101
+ Math.floor((aggregateBytes - utilityBytes * utilitySlots) / generalSlots),
102
+ "R5D_WORKER_JOB_MEMORY_BYTES"
103
+ );
104
+ if (aggregateBytes > Math.floor(effectiveMemoryBytes * 0.7)) throw new Error("Worker jobs may reserve at most 70% of effective memory");
105
+ if (generalBytes * generalSlots + utilityBytes * utilitySlots > aggregateBytes)
106
+ throw new Error("Worker job limits exceed the aggregate memory budget");
107
+ const queueTimeoutMs = positiveInteger(env.R5D_WORKER_JOB_QUEUE_TIMEOUT_MS, 6e4, "R5D_WORKER_JOB_QUEUE_TIMEOUT_MS");
108
+ if (queueTimeoutMs > 6e4) throw new Error("Worker job queue timeout cannot exceed 60000ms");
109
+ return {
110
+ aggregateBytes,
111
+ generalBytes,
112
+ utilityBytes,
113
+ generalSlots,
114
+ utilitySlots,
115
+ queueTimeoutMs,
116
+ maxQueued: positiveInteger(env.R5D_WORKER_JOB_QUEUE_LIMIT, 128, "R5D_WORKER_JOB_QUEUE_LIMIT"),
117
+ pidsPerJob: positiveInteger(env.R5D_WORKER_JOB_PIDS, 512, "R5D_WORKER_JOB_PIDS")
118
+ };
119
+ }
120
+ function workerDelegatedCgroupRoot(cgroupText, mount = "/sys/fs/cgroup") {
121
+ const relative = cgroupText.split("\n").find((line) => line.startsWith("0::/"))?.slice(3);
122
+ if (!relative) return void 0;
123
+ const segments = relative.split("/").filter(Boolean);
124
+ const index = segments.indexOf("r5d-worker.service");
125
+ if (index < 0 || segments[index + 1] !== "control") return void 0;
126
+ return import_node_path.default.join(mount, ...segments.slice(0, index + 1));
127
+ }
128
+ function effectiveWorkerMemory(root, hostBytes = import_node_os.default.totalmem(), mount = "/sys/fs/cgroup") {
129
+ let result = hostBytes;
130
+ if (!root) return result;
131
+ let current = root;
132
+ while (current === mount || current.startsWith(`${mount}/`)) {
133
+ try {
134
+ const limit = Number(import_node_fs.default.readFileSync(import_node_path.default.join(current, "memory.max"), "utf8").trim());
135
+ if (Number.isSafeInteger(limit) && limit > 0) result = Math.min(result, limit);
136
+ } catch {
137
+ }
138
+ if (current === mount) break;
139
+ current = import_node_path.default.dirname(current);
140
+ }
141
+ return result;
142
+ }
143
+ class WorkerJobCapacityError extends Error {
144
+ constructor() {
145
+ super("Worker command capacity is busy. No command was started; retry later or stop another command or terminal.");
146
+ this.name = "WorkerJobCapacityError";
147
+ }
148
+ }
149
+ class WorkerJobCancelledError extends Error {
150
+ constructor() {
151
+ super("Command cancelled before it started");
152
+ this.name = "WorkerJobCancelledError";
153
+ }
154
+ }
155
+ class WorkerJobResourceController {
156
+ constructor(budget, delegatedRoot) {
157
+ this.budget = budget;
158
+ this.isolation = delegatedRoot ? "cgroup-v2" : "concurrency-only";
159
+ if (delegatedRoot) {
160
+ if (import_node_fs.default.readFileSync(import_node_path.default.join(delegatedRoot, "cgroup.procs"), "utf8").trim())
161
+ throw new Error("Worker cgroup delegation root must be empty; configure DelegateSubgroup=control");
162
+ import_node_fs.default.writeFileSync(import_node_path.default.join(delegatedRoot, "cgroup.subtree_control"), "+memory +pids");
163
+ this.jobsRoot = import_node_path.default.join(delegatedRoot, "jobs");
164
+ import_node_fs.default.mkdirSync(this.jobsRoot, { recursive: true });
165
+ import_node_fs.default.writeFileSync(import_node_path.default.join(this.jobsRoot, "memory.max"), String(budget.aggregateBytes));
166
+ import_node_fs.default.writeFileSync(import_node_path.default.join(this.jobsRoot, "memory.swap.max"), "0");
167
+ import_node_fs.default.writeFileSync(import_node_path.default.join(this.jobsRoot, "memory.oom.group"), "0");
168
+ import_node_fs.default.writeFileSync(import_node_path.default.join(this.jobsRoot, "cgroup.subtree_control"), "+memory +pids");
169
+ for (const name of import_node_fs.default.readdirSync(this.jobsRoot)) {
170
+ if (!/^job-[0-9a-f-]+$/.test(name)) continue;
171
+ const old = import_node_path.default.join(this.jobsRoot, name);
172
+ if (!import_node_fs.default.statSync(old).isDirectory()) continue;
173
+ import_node_fs.default.writeFileSync(import_node_path.default.join(old, "cgroup.kill"), "1");
174
+ }
175
+ }
176
+ }
177
+ budget;
178
+ isolation;
179
+ jobsRoot;
180
+ active = /* @__PURE__ */ new Map();
181
+ waiters = [];
182
+ timer;
183
+ get status() {
184
+ return { isolation: this.isolation, active: this.active.size, queued: this.waiters.length };
185
+ }
186
+ acquire(input) {
187
+ try {
188
+ input.assertAdmission();
189
+ if (this.active.has(input.id) || this.waiters.some((entry) => entry.id === input.id))
190
+ throw new Error(`Duplicate resource admission: ${input.id}`);
191
+ if (this.available(input.lane)) return Promise.resolve(this.start(input));
192
+ if (this.waiters.length >= this.budget.maxQueued) throw new WorkerJobCapacityError();
193
+ return new Promise((resolve, reject) => {
194
+ this.waiters.push({ ...input, resolve, reject, deadline: Date.now() + this.budget.queueTimeoutMs });
195
+ this.timer ??= setInterval(() => this.drain(), Math.min(100, this.budget.queueTimeoutMs));
196
+ });
197
+ } catch (error) {
198
+ return Promise.reject(error);
199
+ }
200
+ }
201
+ cancel(id) {
202
+ for (let index = this.waiters.length - 1; index >= 0; index--) {
203
+ if (this.waiters[index].id !== id) continue;
204
+ this.waiters.splice(index, 1)[0].reject(new WorkerJobCancelledError());
205
+ }
206
+ this.active.get(id)?.lease.kill();
207
+ this.drain();
208
+ }
209
+ cancelSession(sessionId) {
210
+ this.cancelMatching((_id, ownerSessionId) => ownerSessionId === sessionId);
211
+ }
212
+ cancelMatching(matches) {
213
+ const ids = /* @__PURE__ */ new Set([
214
+ ...this.waiters.filter((entry) => matches(entry.id, entry.sessionId)).map((entry) => entry.id),
215
+ ...[...this.active].filter(([id, entry]) => matches(id, entry.sessionId)).map(([id]) => id)
216
+ ]);
217
+ const errors = [];
218
+ for (const id of ids) {
219
+ try {
220
+ this.cancel(id);
221
+ } catch (error) {
222
+ errors.push(error);
223
+ }
224
+ }
225
+ if (errors.length) throw new AggregateError(errors, "Worker job cancellation was incomplete");
226
+ }
227
+ available(lane) {
228
+ return [...this.active.values()].filter((entry) => entry.lane === lane).length < (lane === "general" ? this.budget.generalSlots : this.budget.utilitySlots);
229
+ }
230
+ drain() {
231
+ for (let index = 0; index < this.waiters.length; ) {
232
+ const waiter = this.waiters[index];
233
+ try {
234
+ waiter.assertAdmission();
235
+ if (Date.now() >= waiter.deadline) throw new WorkerJobCapacityError();
236
+ if (!this.available(waiter.lane)) {
237
+ index++;
238
+ continue;
239
+ }
240
+ this.waiters.splice(index, 1);
241
+ waiter.resolve(this.start(waiter));
242
+ } catch (error) {
243
+ if (this.waiters[index] === waiter) this.waiters.splice(index, 1);
244
+ waiter.reject(error);
245
+ }
246
+ }
247
+ if (this.waiters.length === 0 && this.timer) {
248
+ clearInterval(this.timer);
249
+ this.timer = void 0;
250
+ }
251
+ }
252
+ start(input) {
253
+ input.assertAdmission();
254
+ const memoryBytes = input.lane === "general" ? this.budget.generalBytes : this.budget.utilityBytes;
255
+ const cgroupPath = this.jobsRoot ? import_node_path.default.join(this.jobsRoot, `job-${crypto.randomUUID()}`) : void 0;
256
+ if (cgroupPath) {
257
+ import_node_fs.default.mkdirSync(cgroupPath);
258
+ try {
259
+ import_node_fs.default.writeFileSync(import_node_path.default.join(cgroupPath, "memory.max"), String(memoryBytes));
260
+ import_node_fs.default.writeFileSync(import_node_path.default.join(cgroupPath, "memory.swap.max"), "0");
261
+ import_node_fs.default.writeFileSync(import_node_path.default.join(cgroupPath, "memory.oom.group"), "1");
262
+ import_node_fs.default.writeFileSync(import_node_path.default.join(cgroupPath, "pids.max"), String(this.budget.pidsPerJob));
263
+ } catch (error) {
264
+ import_node_fs.default.rmdirSync(cgroupPath);
265
+ throw error;
266
+ }
267
+ }
268
+ let released = false;
269
+ let revoked = false;
270
+ let releasePromise;
271
+ const kill = () => {
272
+ revoked = true;
273
+ if (!released && cgroupPath) import_node_fs.default.writeFileSync(import_node_path.default.join(cgroupPath, "cgroup.kill"), "1");
274
+ };
275
+ const lease = {
276
+ cgroupPath,
277
+ memoryBytes,
278
+ wrap: (argv) => {
279
+ if (released) throw new Error("Worker job resource lease was already released");
280
+ if (revoked) throw new WorkerJobCancelledError();
281
+ if (!argv.length) throw new Error("Command argv must not be empty");
282
+ return cgroupPath ? [
283
+ "/bin/sh",
284
+ "-c",
285
+ 'set -e; printf "%s" "$$" > "$1/cgroup.procs"; read -r oom < /proc/self/oom_score_adj; if [ "$oom" -lt 0 ]; then printf 0 > /proc/self/oom_score_adj; fi; shift; exec "$@"',
286
+ "r5d-job",
287
+ cgroupPath,
288
+ ...argv
289
+ ] : argv;
290
+ },
291
+ kill,
292
+ memoryLimitExceeded: () => {
293
+ if (!cgroupPath || released) return false;
294
+ return /^oom_kill [1-9][0-9]*$/m.test(import_node_fs.default.readFileSync(import_node_path.default.join(cgroupPath, "memory.events"), "utf8"));
295
+ },
296
+ release: () => releasePromise ??= (async () => {
297
+ kill();
298
+ if (cgroupPath) {
299
+ const deadline = Date.now() + 2e3;
300
+ while (/^populated 1$/m.test(import_node_fs.default.readFileSync(import_node_path.default.join(cgroupPath, "cgroup.events"), "utf8"))) {
301
+ if (Date.now() >= deadline) throw new Error(`Worker job cgroup remained populated: ${cgroupPath}`);
302
+ await new Promise((resolve) => setTimeout(resolve, 10));
303
+ }
304
+ import_node_fs.default.rmdirSync(cgroupPath);
305
+ }
306
+ released = true;
307
+ this.active.delete(input.id);
308
+ this.drain();
309
+ })()
310
+ };
311
+ this.active.set(input.id, { lane: input.lane, sessionId: input.sessionId, lease });
312
+ return lease;
313
+ }
314
+ }
315
+ function createWorkerJobResources(env = process.env) {
316
+ const mode = env.R5D_WORKER_RESOURCE_ISOLATION ?? "auto";
317
+ if (mode !== "auto" && mode !== "required") throw new Error("R5D_WORKER_RESOURCE_ISOLATION must be auto or required");
318
+ const root = process.platform === "linux" ? workerDelegatedCgroupRoot(import_node_fs.default.readFileSync("/proc/self/cgroup", "utf8")) : void 0;
319
+ if (mode === "required" && !root)
320
+ throw new Error("Worker requires delegated cgroup v2 resource isolation; configure Delegate=memory pids and DelegateSubgroup=control");
321
+ const budget = workerJobBudget(effectiveWorkerMemory(root), env);
322
+ return new WorkerJobResourceController(budget, root);
323
+ }
324
+ // Annotate the CommonJS export names for ESM import in node:
325
+ 0 && (module.exports = {
326
+ WORKER_INLINE_OUTPUT_MAX_BYTES,
327
+ WorkerJobCancelledError,
328
+ WorkerJobCapacityError,
329
+ WorkerJobResourceController,
330
+ collectWorkerInlineOutput,
331
+ createWorkerJobResources,
332
+ effectiveWorkerMemory,
333
+ workerDelegatedCgroupRoot,
334
+ workerJobBudget
335
+ });