@shogo-ai/worker 1.0.10

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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +432 -0
  3. package/bin/shogo.mjs +38 -0
  4. package/dist/cli.mjs +64 -0
  5. package/package.json +62 -0
  6. package/src/cli.ts +199 -0
  7. package/src/commands/agent.ts +123 -0
  8. package/src/commands/config.ts +29 -0
  9. package/src/commands/doctor.ts +128 -0
  10. package/src/commands/login.ts +126 -0
  11. package/src/commands/logs.ts +24 -0
  12. package/src/commands/project-checkout.ts +179 -0
  13. package/src/commands/project-pull.ts +133 -0
  14. package/src/commands/project-push.ts +89 -0
  15. package/src/commands/runtime.ts +104 -0
  16. package/src/commands/start.ts +290 -0
  17. package/src/commands/status.ts +22 -0
  18. package/src/commands/stop.ts +13 -0
  19. package/src/lib/__tests__/agent-launch.test.ts +92 -0
  20. package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
  21. package/src/lib/__tests__/cloud-login.test.ts +218 -0
  22. package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
  23. package/src/lib/__tests__/config.test.ts +141 -0
  24. package/src/lib/__tests__/db-doctor.test.ts +204 -0
  25. package/src/lib/__tests__/git-cloner.test.ts +382 -0
  26. package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
  27. package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
  28. package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
  29. package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
  30. package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
  31. package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
  32. package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
  33. package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
  34. package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
  35. package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
  36. package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
  37. package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
  38. package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
  39. package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
  40. package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
  41. package/src/lib/api-discovery.ts +36 -0
  42. package/src/lib/cloud-login.ts +320 -0
  43. package/src/lib/cloud-sync-watcher.ts +322 -0
  44. package/src/lib/config.ts +68 -0
  45. package/src/lib/db-doctor.ts +355 -0
  46. package/src/lib/device-id.ts +27 -0
  47. package/src/lib/git-cloner.ts +354 -0
  48. package/src/lib/paths.ts +53 -0
  49. package/src/lib/preflight.ts +158 -0
  50. package/src/lib/process-manager.ts +123 -0
  51. package/src/lib/resource-limits.ts +340 -0
  52. package/src/lib/runtime-install.ts +371 -0
  53. package/src/lib/runtime-manager.ts +1873 -0
  54. package/src/lib/runtime-resolver.ts +136 -0
  55. package/src/lib/transport.ts +202 -0
  56. package/src/lib/tunnel.ts +710 -0
  57. package/src/lib/user-agent.ts +28 -0
@@ -0,0 +1,340 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Cross-platform resource limits for host-spawned agent-runtime process groups.
5
+ *
6
+ * A single project runtime is a tree: `bun run agent-runtime` -> vite build
7
+ * --watch, `bun run server.tsx`, tsserver, pyright, expo, etc. Left unbounded
8
+ * its RSS climbs until the OS OOM-kills it (macOS jetsam SIGKILL is the
9
+ * canonical case), taking the whole app's memory with it. We cap each tree,
10
+ * using the strongest primitive each platform offers:
11
+ *
12
+ * - Linux: cgroup v2. Prefer a transient `systemd-run --scope` (no root,
13
+ * hard `memory.max`/`cpu.max`/`pids.max`), fall back to writing a
14
+ * cgroup directly, fall back to the RSS watchdog.
15
+ * - Windows: a Job Object with ProcessMemoryLimit + KILL_ON_JOB_CLOSE via an
16
+ * optional native helper; fall back to the RSS watchdog.
17
+ * - macOS: no cgroups and RLIMIT_AS is unreliable for JIT/mmap-heavy Bun,
18
+ * so an RSS watchdog samples the process group and asks the
19
+ * manager to restart on breach.
20
+ *
21
+ * The JS-heap env cap (see {@link applyHeapEnvCap}) is a secondary hint layered
22
+ * on top everywhere — OS-level limits are the real enforcement.
23
+ */
24
+ import { spawnSync } from 'node:child_process';
25
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ export interface RuntimeResourceLimits {
29
+ /** Per-project RAM ceiling in MB. Always > 0 when limits are active. */
30
+ memoryMB: number;
31
+ /** CPU ceiling as percent of ONE core (100 = one full core). 0 = no cap. */
32
+ cpuPercent: number;
33
+ /** Max tasks/pids in the group (cgroup pids.max). */
34
+ tasksMax: number;
35
+ }
36
+
37
+ type Logger = Pick<Console, 'log' | 'warn' | 'error'>;
38
+
39
+ /** cgroup CPU period in microseconds (100ms), the systemd/cgroup default. */
40
+ const CPU_PERIOD_US = 100_000;
41
+
42
+ /**
43
+ * Resolve the active resource limits from the environment. Returns `null` when
44
+ * no ceiling is configured (`RUNTIME_MEMORY_MB` unset or <= 0), which disables
45
+ * all enforcement for backwards compatibility.
46
+ */
47
+ export function resolveResourceLimits(env: NodeJS.ProcessEnv): RuntimeResourceLimits | null {
48
+ const mb = parseInt(env.RUNTIME_MEMORY_MB || '', 10);
49
+ if (!Number.isFinite(mb) || mb <= 0) return null;
50
+
51
+ const cpuRaw = parseInt(env.RUNTIME_CPU_PERCENT || '0', 10);
52
+ const cpuPercent = Number.isFinite(cpuRaw) && cpuRaw > 0 ? cpuRaw : 0;
53
+
54
+ const tasksRaw = parseInt(env.RUNTIME_TASKS_MAX || '', 10);
55
+ const tasksMax = Number.isFinite(tasksRaw) && tasksRaw > 0 ? tasksRaw : 4096;
56
+
57
+ return { memoryMB: mb, cpuPercent, tasksMax };
58
+ }
59
+
60
+ /**
61
+ * Append a JS-heap ceiling to `NODE_OPTIONS` (mutates `env` in place).
62
+ *
63
+ * This bounds the V8/JSC old-space of Node children (tsserver, vite, esbuild)
64
+ * and modern Bun, which honours `--max-old-space-size`. It does NOT bound
65
+ * native RSS, so it is only a hint — the OS-level limit is the real cap. We
66
+ * size it below the RSS ceiling (~75%) so the heap limit trips (recoverable
67
+ * JS OOM) before the native RSS watchdog / cgroup kill does.
68
+ */
69
+ export function applyHeapEnvCap(env: NodeJS.ProcessEnv, memoryMB: number): void {
70
+ const heapMB = Math.max(256, Math.floor(memoryMB * 0.75));
71
+ const flag = `--max-old-space-size=${heapMB}`;
72
+ const existing = env.NODE_OPTIONS?.trim();
73
+ if (existing) {
74
+ if (/--max-old-space-size=/.test(existing)) return; // caller already set one
75
+ env.NODE_OPTIONS = `${existing} ${flag}`;
76
+ } else {
77
+ env.NODE_OPTIONS = flag;
78
+ }
79
+ }
80
+
81
+ // ─── Linux: cgroup v2 ────────────────────────────────────────────────
82
+
83
+ /** True if `systemd-run` is usable for a rootless `--user --scope` unit. */
84
+ export function hasSystemdRun(): boolean {
85
+ if (process.platform !== 'linux') return false;
86
+ try {
87
+ const r = spawnSync('systemd-run', ['--version'], { stdio: 'ignore', timeout: 3000 });
88
+ return r.status === 0;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Wrap a spawn command in a transient rootless cgroup v2 scope via
96
+ * `systemd-run --user --scope`. The child (and everything it forks) runs inside
97
+ * a scope unit with hard `MemoryMax`/`CPUQuota`/`TasksMax`. Returns the original
98
+ * command untouched (`wrapped: false`) on non-Linux or when systemd-run is
99
+ * absent — the caller then falls back to {@link attachDirectCgroup} or the
100
+ * watchdog.
101
+ */
102
+ export function wrapSpawnForCgroup(opts: {
103
+ command: string;
104
+ args: string[];
105
+ limits: RuntimeResourceLimits;
106
+ scopeName: string;
107
+ logger?: Logger;
108
+ }): { command: string; args: string[]; wrapped: boolean } {
109
+ const { command, args, limits, scopeName, logger } = opts;
110
+ if (process.platform !== 'linux' || !hasSystemdRun()) {
111
+ return { command, args, wrapped: false };
112
+ }
113
+
114
+ const props = [
115
+ '-p', `MemoryMax=${limits.memoryMB}M`,
116
+ // MemoryHigh throttles + reclaims before the hard OOM at MemoryMax, so a
117
+ // spiky build is slowed rather than instantly killed.
118
+ '-p', `MemoryHigh=${Math.max(128, Math.floor(limits.memoryMB * 0.9))}M`,
119
+ '-p', `TasksMax=${limits.tasksMax}`,
120
+ ];
121
+ if (limits.cpuPercent > 0) props.push('-p', `CPUQuota=${limits.cpuPercent}%`);
122
+
123
+ const wrappedArgs = [
124
+ '--user',
125
+ '--scope',
126
+ '--quiet',
127
+ '--collect', // GC the transient unit as soon as it exits
128
+ `--unit=${scopeName}`,
129
+ ...props,
130
+ '--',
131
+ command,
132
+ ...args,
133
+ ];
134
+ logger?.log(`[resource-limits] wrapping spawn in systemd scope ${scopeName} (MemoryMax=${limits.memoryMB}M)`);
135
+ return { command: 'systemd-run', args: wrappedArgs, wrapped: true };
136
+ }
137
+
138
+ /**
139
+ * Fallback for Linux hosts without systemd: write a cgroup v2 directly and move
140
+ * `pid` (plus its future children) into it. Best-effort — returns `false` when
141
+ * cgroup v2 is unavailable or not writable (the common non-root, non-delegated
142
+ * case), so the caller can start the watchdog instead.
143
+ */
144
+ export function attachDirectCgroup(opts: {
145
+ pid: number;
146
+ limits: RuntimeResourceLimits;
147
+ logger?: Logger;
148
+ }): boolean {
149
+ const { pid, limits, logger } = opts;
150
+ if (process.platform !== 'linux') return false;
151
+
152
+ const root = '/sys/fs/cgroup';
153
+ // cgroup v2 unified hierarchy exposes cgroup.controllers at the root.
154
+ if (!existsSync(join(root, 'cgroup.controllers'))) return false;
155
+
156
+ const dir = join(root, 'shogo', `rt-${pid}`);
157
+ try {
158
+ mkdirSync(dir, { recursive: true });
159
+ writeFileSync(join(dir, 'memory.max'), String(limits.memoryMB * 1024 * 1024));
160
+ if (limits.cpuPercent > 0) {
161
+ const quota = Math.floor((limits.cpuPercent / 100) * CPU_PERIOD_US);
162
+ writeFileSync(join(dir, 'cpu.max'), `${quota} ${CPU_PERIOD_US}`);
163
+ }
164
+ try { writeFileSync(join(dir, 'pids.max'), String(limits.tasksMax)); } catch { /* controller may be absent */ }
165
+ // Moving the pid in also captures children forked after this point.
166
+ writeFileSync(join(dir, 'cgroup.procs'), String(pid));
167
+ logger?.log(`[resource-limits] attached pid ${pid} to cgroup ${dir} (memory.max=${limits.memoryMB}M)`);
168
+ return true;
169
+ } catch (err: any) {
170
+ logger?.warn(`[resource-limits] direct cgroup attach failed for pid ${pid}: ${err?.message ?? err}`);
171
+ return false;
172
+ }
173
+ }
174
+
175
+ // ─── Windows: Job Object (optional native helper) ────────────────────
176
+
177
+ /**
178
+ * Assign `pid` to a Windows Job Object with a hard process-memory limit and
179
+ * KILL_ON_JOB_CLOSE, using an optional native helper. Node has no built-in Job
180
+ * Object API, so we load a helper lazily:
181
+ *
182
+ * - a module path in `SHOGO_WINJOB_HELPER`, or
183
+ * - a `@shogo/winjob` package if installed,
184
+ *
185
+ * exposing `limitProcess(pid, { memoryBytes, killOnClose }): boolean`.
186
+ *
187
+ * Returns `false` when no helper is available so the caller falls back to the
188
+ * RSS watchdog (which also enforces the ceiling on Windows, just via sampling).
189
+ */
190
+ export function tryAttachJobObject(opts: {
191
+ pid: number;
192
+ limits: RuntimeResourceLimits;
193
+ logger?: Logger;
194
+ }): boolean {
195
+ const { pid, limits, logger } = opts;
196
+ if (process.platform !== 'win32') return false;
197
+
198
+ const helperPath = process.env.SHOGO_WINJOB_HELPER || '@shogo/winjob';
199
+ try {
200
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
201
+ const helper = require(helperPath) as {
202
+ limitProcess?: (pid: number, o: { memoryBytes: number; killOnClose: boolean }) => boolean;
203
+ };
204
+ if (typeof helper.limitProcess !== 'function') return false;
205
+ const ok = helper.limitProcess(pid, {
206
+ memoryBytes: limits.memoryMB * 1024 * 1024,
207
+ killOnClose: true,
208
+ });
209
+ if (ok) {
210
+ logger?.log(`[resource-limits] attached pid ${pid} to Job Object (${limits.memoryMB}M)`);
211
+ }
212
+ return ok;
213
+ } catch {
214
+ // Helper not installed — expected on stock installs. Watchdog covers us.
215
+ return false;
216
+ }
217
+ }
218
+
219
+ // ─── RSS watchdog (macOS always; Windows / Linux fallback) ───────────
220
+
221
+ /**
222
+ * Sum the resident-set size (KB) of the process group led by `pid`.
223
+ *
224
+ * - POSIX: `ps -o rss= -g <pgid>` (pid is the group leader because the
225
+ * runtime is spawned `detached`), summed across the group.
226
+ * - Windows: a PowerShell pass that sums WorkingSet64 of `pid` and all of its
227
+ * descendants (walking Win32_Process.ParentProcessId).
228
+ *
229
+ * Returns `null` when sampling fails (process gone, tool missing) so the
230
+ * watchdog treats it as "no reading" rather than "0".
231
+ */
232
+ export function samplePgidRssKb(pid: number, platform: NodeJS.Platform = process.platform): number | null {
233
+ try {
234
+ if (platform === 'win32') {
235
+ const script =
236
+ `$ErrorActionPreference='SilentlyContinue';` +
237
+ `$root=${pid};` +
238
+ `$all=Get-CimInstance Win32_Process;` +
239
+ `$seen=@{};$stack=New-Object System.Collections.Stack;$stack.Push($root);$sum=0;` +
240
+ `while($stack.Count -gt 0){$id=$stack.Pop();if($seen[$id]){continue};$seen[$id]=$true;` +
241
+ `foreach($p in $all){if($p.ProcessId -eq $id){$sum+=$p.WorkingSetSize};` +
242
+ `if($p.ParentProcessId -eq $id){$stack.Push($p.ProcessId)}}};` +
243
+ `[math]::Floor($sum/1024)`;
244
+ const r = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
245
+ encoding: 'utf-8',
246
+ timeout: 10_000,
247
+ windowsHide: true,
248
+ });
249
+ if (r.status !== 0 || !r.stdout) return null;
250
+ const kb = parseInt(r.stdout.trim(), 10);
251
+ return Number.isFinite(kb) ? kb : null;
252
+ }
253
+
254
+ // POSIX: sum RSS (KB) of every process in the group.
255
+ const r = spawnSync('ps', ['-o', 'rss=', '-g', String(pid)], {
256
+ encoding: 'utf-8',
257
+ timeout: 5_000,
258
+ });
259
+ if (r.status !== 0 || !r.stdout) return null;
260
+ let total = 0;
261
+ let sawRow = false;
262
+ for (const line of r.stdout.split('\n')) {
263
+ const v = parseInt(line.trim(), 10);
264
+ if (Number.isFinite(v)) { total += v; sawRow = true; }
265
+ }
266
+ return sawRow ? total : null;
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Periodically samples a process group's RSS and invokes `onBreach` once the
274
+ * ceiling has been exceeded for {@link RssWatchdogOptions.strikes} consecutive
275
+ * samples (so a single transient spike doesn't trigger a restart). Used where
276
+ * no kernel-level cap is available.
277
+ */
278
+ export interface RssWatchdogOptions {
279
+ pid: number;
280
+ ceilingMB: number;
281
+ platform?: NodeJS.Platform;
282
+ intervalMs?: number;
283
+ /** Consecutive over-ceiling samples required before onBreach fires. */
284
+ strikes?: number;
285
+ logger?: Logger;
286
+ /** Called with the observed RSS (MB) when the ceiling is breached. */
287
+ onBreach: (rssMB: number) => void;
288
+ }
289
+
290
+ export class RssWatchdog {
291
+ private timer: ReturnType<typeof setInterval> | null = null;
292
+ private consecutive = 0;
293
+ private fired = false;
294
+ private readonly pid: number;
295
+ private readonly ceilingMB: number;
296
+ private readonly platform: NodeJS.Platform;
297
+ private readonly intervalMs: number;
298
+ private readonly strikes: number;
299
+ private readonly logger?: Logger;
300
+ private readonly onBreach: (rssMB: number) => void;
301
+
302
+ constructor(opts: RssWatchdogOptions) {
303
+ this.pid = opts.pid;
304
+ this.ceilingMB = opts.ceilingMB;
305
+ this.platform = opts.platform ?? process.platform;
306
+ this.intervalMs = opts.intervalMs ?? parseInt(process.env.RUNTIME_RSS_WATCHDOG_MS || '15000', 10);
307
+ this.strikes = opts.strikes ?? parseInt(process.env.RUNTIME_RSS_WATCHDOG_STRIKES || '2', 10);
308
+ this.logger = opts.logger;
309
+ this.onBreach = opts.onBreach;
310
+ }
311
+
312
+ start(): void {
313
+ if (this.timer) return;
314
+ this.timer = setInterval(() => this.sample(), Math.max(2000, this.intervalMs));
315
+ try { this.timer.unref?.(); } catch { /* unref is best-effort */ }
316
+ }
317
+
318
+ stop(): void {
319
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
320
+ }
321
+
322
+ private sample(): void {
323
+ if (this.fired) return;
324
+ const kb = samplePgidRssKb(this.pid, this.platform);
325
+ if (kb == null) { this.consecutive = 0; return; }
326
+ const rssMB = Math.floor(kb / 1024);
327
+ if (rssMB <= this.ceilingMB) { this.consecutive = 0; return; }
328
+
329
+ this.consecutive += 1;
330
+ this.logger?.warn(
331
+ `[resource-limits] pid ${this.pid} RSS ${rssMB}MB over ceiling ${this.ceilingMB}MB ` +
332
+ `(strike ${this.consecutive}/${this.strikes})`,
333
+ );
334
+ if (this.consecutive >= this.strikes) {
335
+ this.fired = true;
336
+ this.stop();
337
+ this.onBreach(rssMB);
338
+ }
339
+ }
340
+ }