@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,1873 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * WorkerRuntimeManager — multi-project agent-runtime spawner.
5
+ *
6
+ * License boundary: this module locates the agent-runtime via
7
+ * `runtime-resolver` and invokes it with `Bun.spawn` (or `child_process.spawn`
8
+ * as a fallback). It does NOT import `@shogo/agent-runtime` — the AGPL
9
+ * runtime runs as a separate OS process, communicating with the worker
10
+ * over HTTP-on-localhost only. See packages/shogo-worker/README.md
11
+ * "Process boundary" section for the full rationale.
12
+ *
13
+ * Responsibilities:
14
+ * - Allocate a free localhost port per projectId (range 37100-37900,
15
+ * mirroring the desktop runtime so port conflicts surface the same
16
+ * way for users running both locally).
17
+ * - Spawn the runtime binary with the env it expects (PROJECT_ID,
18
+ * PORT, API_SERVER_PORT, SKILL_SERVER_PORT, RUNTIME_AUTH_SECRET,
19
+ * WEBHOOK_TOKEN, SHOGO_API_URL, SHOGO_API_KEY, AI_PROXY_URL,
20
+ * AI_PROXY_TOKEN, NODE_ENV).
21
+ * - Wait for /health to respond before declaring `running`.
22
+ * - Restart with exponential backoff on unexpected exits.
23
+ * - Idle-evict per-project runtimes after RUNTIME_IDLE_MS of inactivity.
24
+ * - Stop everything on SIGINT/SIGTERM via `stopAll()`.
25
+ *
26
+ * What this manager does NOT do (vs the desktop one in apps/api):
27
+ * - No Vite spawning. The cloud-attached worker only serves agent
28
+ * traffic; previews are owned by the cloud preview path.
29
+ * - No workspace template seeding. The runtime assumes the workspace
30
+ * is already at PROJECT_DIR (cloud sets this before invoking).
31
+ * - No Prisma reads. All policy comes from the spawn config.
32
+ * - No security policy build. Cloud signs the policy and sends it.
33
+ */
34
+ import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
35
+ import { createHmac, randomBytes } from 'node:crypto';
36
+ import { existsSync, mkdirSync, readdirSync } from 'node:fs';
37
+ import { createConnection } from 'node:net';
38
+ import { tmpdir } from 'node:os';
39
+ import { dirname, join } from 'node:path';
40
+ import { resolveRuntime, type ResolvedRuntime } from './runtime-resolver.ts';
41
+ import {
42
+ RssWatchdog,
43
+ applyHeapEnvCap,
44
+ attachDirectCgroup,
45
+ resolveResourceLimits,
46
+ tryAttachJobObject,
47
+ wrapSpawnForCgroup,
48
+ type RuntimeResourceLimits,
49
+ } from './resource-limits.ts';
50
+ import type { ResolveRejection, RuntimeResolver } from './tunnel.ts';
51
+ import { CloudFileTransport } from '@shogo-ai/sdk/cloud-file-transport';
52
+ import { CloudSyncWatcher } from './cloud-sync-watcher.ts';
53
+ import { cloneProject, gitIsAvailable, isGitRepo } from './git-cloner.ts';
54
+
55
+ /** Port range for random allocation (mirrors apps/api desktop manager). */
56
+ const PORT_RANGE_START = 37100;
57
+ const PORT_RANGE_END = 37900;
58
+ const API_PORT_OFFSET = 1; // API server port = agentPort + 1.
59
+
60
+ /**
61
+ * Offset (from the agent port) of the workspace preview sidecar base.
62
+ *
63
+ * A workspace runtime serves N attached projects, each with its own preview
64
+ * sidecar (`server.tsx`) on `WORKSPACE_API_PORT_BASE + projectIndex`. We anchor
65
+ * that base at `agentPort + 2` (agentPort=+0, its API/skill server=+1) so every
66
+ * runtime gets a DISTINCT sidecar range. Before warm-multiple, only one runtime
67
+ * ran at a time and all of them could share the fixed default base (3101); now
68
+ * that several runtimes stay warm concurrently they were all binding 3101 and
69
+ * crash-looping (force-killing each other's leaked sidecars), which SIGKILLed
70
+ * the agent-runtime and restart-looped it.
71
+ */
72
+ const PREVIEW_API_BASE_OFFSET = 2;
73
+
74
+ /**
75
+ * Contiguous ports reserved per runtime: agent(+0), API/skill server(+1) and
76
+ * the preview sidecars (+2 … +RUNTIME_PORT_BLOCK-1). Reserving the whole block
77
+ * (rather than just the two eagerly-bound ports) guarantees no OTHER runtime's
78
+ * block overlaps this one's sidecar range. 16 supports up to 14 attached
79
+ * projects per workspace; the 800-port range still fits 50 such blocks (>> the
80
+ * default maxRuntimes of 10).
81
+ */
82
+ const RUNTIME_PORT_BLOCK = 16;
83
+
84
+ /** Default idle eviction window — unused runtimes get killed after this. */
85
+ const RUNTIME_IDLE_MS = 15 * 60 * 1000;
86
+
87
+ /**
88
+ * A runtime touched within this window is treated as "actively in use"
89
+ * (likely mid-stream — the agent-proxy/ai-proxy refresh `lastUsedAt` on
90
+ * every forwarded chunk) and is never picked as an LRU eviction victim
91
+ * by {@link WorkerRuntimeManager.enforceMaxRuntimes}, even when the cap
92
+ * is exceeded. Better to briefly run one over the cap than to SIGKILL a
93
+ * live chat stream out from under the user.
94
+ */
95
+ const STREAM_ACTIVE_WINDOW_MS = 30 * 1000;
96
+
97
+ /** Restart backoff bounds. */
98
+ const RESTART_BACKOFF_BASE_MS = 1_000;
99
+ const RESTART_BACKOFF_MAX_MS = 60_000;
100
+
101
+ /**
102
+ * Circuit breaker. After this many *consecutive* non-clean exits within
103
+ * {@link RESTART_FAILURE_WINDOW_MS}, the manager stops respawning and
104
+ * parks the slot in `'failed'`. Without this cap a project that the OS
105
+ * keeps OOM-killing (macOS jetsam SIGKILL is the canonical case) burns
106
+ * forever at ~1/minute, with every cycle re-spawning bun + vite +
107
+ * tsserver + pyright and (on posix) leaking the children of any
108
+ * incarnation the manager couldn't kill in its own process group.
109
+ *
110
+ * 8 across a 5-minute window means "we tried for at least
111
+ * (8 * BASE_BACKOFF capped at MAX) = ~7m of escalating backoff before
112
+ * we gave up", which is long enough to ride out a transient port-bind
113
+ * race or a one-shot dependency upgrade and short enough that an
114
+ * operator chasing runaway RSS on their laptop notices the loop
115
+ * stopping before the next memory-pressure cycle.
116
+ */
117
+ const MAX_CONSECUTIVE_RESTARTS = 8;
118
+ const RESTART_FAILURE_WINDOW_MS = 5 * 60 * 1000;
119
+ /**
120
+ * If a runtime stays up at least this long after the /health-gated
121
+ * `'running'` transition, we treat it as "recovered" and reset the
122
+ * consecutive-failure counter. This is the contract that lets a
123
+ * project that crashed twice on cold start (e.g. waiting for the API
124
+ * port to release) but then ran healthily for ten minutes start over
125
+ * with a fresh budget the next time it hiccups.
126
+ */
127
+ const STARTUP_GRACE_MS = 60_000;
128
+
129
+ /** Health check poll interval while waiting for /health. */
130
+ const HEALTH_POLL_MS = 500;
131
+ /**
132
+ * Absolute ceiling on how long we'll wait for a freshly-spawned
133
+ * agent-runtime to respond 2xx on /health. After the 2026-05 fix
134
+ * (`runtime-log-writer` async batched writes, deferred LSP +
135
+ * IndexEngine start, /health fast-path in the outer fetch handler),
136
+ * a Windows cold boot is ~8-12 s; macOS/Linux are ~3-5 s. 30 s gives
137
+ * a comfortable margin without hiding real hangs.
138
+ *
139
+ * Why we don't short-circuit on TCP-listening + stdout activity any
140
+ * more (the historical fast path that bypassed /health entirely):
141
+ * declaring "ready" while the event loop is saturated tricks the UI
142
+ * into starting to load the preview iframe and the API server into
143
+ * starting the agent-proxy, both of which then hit their own (much
144
+ * shorter) timeouts and surface as the "Connection timed out — The
145
+ * agent runtime could not be reached" toast and "[AgentProxy] PATCH
146
+ * /agent/config timeout" retries. Waiting for a real 2xx is slower
147
+ * but the resulting `'running'` status actually means "responsive to
148
+ * HTTP", which is what every caller of `status === 'running'` already
149
+ * assumes. The progress logger keeps devs informed during the slow
150
+ * path so the wait isn't a silent black-box.
151
+ */
152
+ const HEALTH_BOOT_TIMEOUT_MS = 30_000;
153
+ /**
154
+ * If `STDOUT_PROGRESS_WINDOW_MS` elapses without any new stdout AND
155
+ * TCP-listening is true AND /health still hasn't responded, the
156
+ * child is considered wedged and the wait short-circuits with the
157
+ * "stdout silent" error so the restart loop has a chance to recover.
158
+ * Note: this is the ONLY purpose of the window now — it does not
159
+ * declare the runtime ready on its own. See HEALTH_BOOT_TIMEOUT_MS.
160
+ *
161
+ * Sized at 25 s — short of the 30 s HEALTH_BOOT_TIMEOUT_MS so a
162
+ * wedged child surfaces with the more informative "stdout silent for
163
+ * Xms" message instead of the generic timeout. After the 2026-05
164
+ * cold-boot investigation we deferred LSP startup + IndexEngine
165
+ * pre-warm out of the critical path and got Windows cold boot down
166
+ * to ~8-12 s (with macOS at ~3-5 s), so a 25 s silent window is now
167
+ * a real anomaly worth respawning for.
168
+ */
169
+ const STDOUT_PROGRESS_WINDOW_MS = 25_000;
170
+ /**
171
+ * Log a "still waiting for /health" progress line at most this
172
+ * often, so operators / devs watching `[dev:all]` see something
173
+ * happening during the slow Windows cold-boot path instead of a
174
+ * silent 30-90s gap.
175
+ */
176
+ const HEALTH_PROGRESS_LOG_MS = 5_000;
177
+ /**
178
+ * Per-attempt TCP connect budget for the kernel-level readiness
179
+ * probe. Connect attempts only need a TCP SYN/SYN-ACK roundtrip on
180
+ * loopback — anything past ~50ms means the kernel doesn't have the
181
+ * listener bound yet (the agent-runtime hasn't reached the
182
+ * Bun.serve() default-export evaluation), so 500ms is comfortably
183
+ * above the noise floor without prolonging probe iterations.
184
+ */
185
+ const TCP_CONNECT_TIMEOUT_MS = 500;
186
+
187
+ export type RuntimeStatus =
188
+ | 'starting'
189
+ | 'running'
190
+ | 'restarting'
191
+ | 'stopping'
192
+ | 'stopped'
193
+ | 'error'
194
+ /**
195
+ * Terminal state: the circuit breaker tripped. The slot stays in the
196
+ * `runtimes` map (so `status(projectId)` keeps reporting it) but no
197
+ * more spawns will happen until {@link WorkerRuntimeManager.resetFailure}
198
+ * is called or the slot is explicitly `stop()`'d.
199
+ */
200
+ | 'failed';
201
+
202
+ export interface RuntimeStatusInfo {
203
+ projectId: string;
204
+ status: RuntimeStatus;
205
+ agentPort?: number;
206
+ apiServerPort?: number;
207
+ pid?: number;
208
+ startedAt?: number;
209
+ lastUsedAt?: number;
210
+ restarts: number;
211
+ lastError?: string;
212
+ }
213
+
214
+ export interface ProjectSpawnConfig {
215
+ /** Cloud URL the runtime should hit for backend services. */
216
+ cloudUrl: string;
217
+ /** Worker's API key, forwarded so the runtime can authenticate to the cloud. */
218
+ apiKey: string;
219
+ /** Workspace dir on disk for this project. Cloud sets this before invoking. */
220
+ projectDir?: string;
221
+ /** Optional AI proxy URL the runtime should use (cloud-managed). */
222
+ aiProxyUrl?: string;
223
+ /** Optional AI proxy token (per-project, short-lived). */
224
+ aiProxyToken?: string;
225
+ /** Tech-stack id (for runtime to seed correct template if PROJECT_DIR is empty). */
226
+ techStackId?: string;
227
+ /** Friendly project name. */
228
+ name?: string;
229
+ /** Workspace id for this project. */
230
+ workspaceId?: string;
231
+ /** Extra env to merge in last (advanced; usually unused). */
232
+ extraEnv?: Record<string, string>;
233
+ }
234
+
235
+ /**
236
+ * Translate a resolved runtime binary path into the actual spawn
237
+ * command. Default returns `{ command: bin, args: [] }` (compiled
238
+ * binary). The desktop AGPL adapter overrides this to spawn
239
+ * `bun run packages/agent-runtime/src/server.ts` from source so it
240
+ * doesn't have to download a prebuilt binary in dev.
241
+ */
242
+ export type SpawnCommandFactory = (binPath: string) => { command: string; args: string[] };
243
+
244
+ export const defaultSpawnCommand: SpawnCommandFactory = (bin) => ({ command: bin, args: [] });
245
+
246
+ /**
247
+ * Override how the runtime binary is located. Default uses the
248
+ * `runtime-resolver` priority chain (--runtime-bin > env > ~/.shogo >
249
+ * PATH). The desktop AGPL adapter overrides this to point at the
250
+ * monorepo source (`packages/agent-runtime/src/server.ts`).
251
+ */
252
+ export type RuntimeBinResolver = () => ResolvedRuntime | null;
253
+
254
+ export interface WorkerRuntimeManagerOptions {
255
+ /** `--runtime-bin <path>` flag value if any (forwarded to resolveRuntime). */
256
+ runtimeBin?: string;
257
+ /**
258
+ * Idle window in ms before evicting an unused runtime (default 15min).
259
+ *
260
+ * Pass `0`, a negative number, or `Infinity` to disable idle eviction
261
+ * entirely. The desktop / `SHOGO_LOCAL_MODE=true` path uses this to
262
+ * keep long-running chat streams alive past 15min of agent-proxy
263
+ * silence — eviction in that environment cuts the user's stream
264
+ * mid-flight (only one user, no resource pressure to recycle for).
265
+ * Cloud workers leave this unset so the default still fires.
266
+ */
267
+ idleMs?: number;
268
+ /**
269
+ * Hard ceiling on the number of concurrently-running runtimes. Once
270
+ * exceeded, `ensureRunning` LRU-evicts the least-recently-used slot
271
+ * that has not been touched within {@link STREAM_ACTIVE_WINDOW_MS}
272
+ * (i.e. is not mid-stream). Pass `0`, a negative number, or a
273
+ * non-finite value to disable the cap (the historical behaviour —
274
+ * runtimes were then bounded only by idle eviction). Defaults to
275
+ * disabled when unset.
276
+ */
277
+ maxRuntimes?: number;
278
+ /** Optional logger. Defaults to console. */
279
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
280
+ /** Working directory for spawned runtimes. Defaults to OS tmpdir/shogo-runtime. */
281
+ runtimeWorkDir?: string;
282
+ /** Override env (for tests). Defaults to process.env. */
283
+ env?: NodeJS.ProcessEnv;
284
+ /**
285
+ * How to translate the resolved binary path into spawn argv. Default
286
+ * spawns the binary directly. The desktop AGPL adapter overrides this
287
+ * to wrap it in `bun run`.
288
+ */
289
+ spawnCommand?: SpawnCommandFactory;
290
+ /**
291
+ * Override binary resolution. Default uses the `runtime-resolver`
292
+ * priority chain. The desktop AGPL adapter passes a resolver that
293
+ * points at the monorepo source so dev builds don't need a prebuilt
294
+ * AGPL binary on disk.
295
+ */
296
+ resolveBin?: RuntimeBinResolver;
297
+ /**
298
+ * Default spawn config used when the manager is acting as a
299
+ * `RuntimeResolver` (i.e. asked to ensureRunning by the tunnel,
300
+ * which doesn't carry per-project config). The runtime can fetch
301
+ * everything else it needs from the cloud using its api key.
302
+ */
303
+ defaultSpawnConfig?: ProjectSpawnConfig;
304
+ /**
305
+ * Optional callback to enrich the spawn config for a given projectId
306
+ * just before spawning. Lets the desktop adapter inject per-project
307
+ * Prisma-derived secrets (AI proxy token, security policy, etc.).
308
+ */
309
+ enrichSpawnConfig?: (projectId: string, base: ProjectSpawnConfig) => Promise<ProjectSpawnConfig>;
310
+ /**
311
+ * Auto-pull configuration. When enabled, the manager will clone a
312
+ * project's workspace from Shogo Cloud into `<projectsDir>/<projectId>/`
313
+ * on first request, then keep edits in sync via a {@link CloudSyncWatcher}.
314
+ *
315
+ * This is what makes "pin a staging project to a paired VPS, send a
316
+ * webhook" work end-to-end without the user manually running
317
+ * `shogo project pull` first.
318
+ */
319
+ autoPull?: AutoPullOptions;
320
+ }
321
+
322
+ export interface AutoPullOptions {
323
+ /** Master switch. Defaults to false; the `worker start` command flips
324
+ * it on for `cli_worker` instances unless `--no-auto-pull` is passed. */
325
+ enabled: boolean;
326
+ /** Directory under which each project's workspace lives. Required when enabled. */
327
+ projectsDir: string;
328
+ /** Watch the pulled workspace and push edits back to cloud. Default: true. */
329
+ watch?: boolean;
330
+ /**
331
+ * Prefer the git smart-HTTP backend over the file transport for the
332
+ * initial clone and the watcher's flush path. Defaults to `true`:
333
+ * the worker will probe `git --version` and fall back to the file
334
+ * transport if git isn't installed. Set to `false` (via `--no-git` on
335
+ * `worker start`) to force the file-transport path even when git is
336
+ * available — useful for environments where outbound HTTPS to git's
337
+ * pack RPC endpoints is blocked at the firewall.
338
+ */
339
+ useGit?: boolean;
340
+ /** Optional logger. Defaults to the manager's logger. */
341
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
342
+ /** Test seam: swap in fakes for the git-cloner ops without resorting
343
+ * to module-level mocking (which leaks across bun:test files). Each
344
+ * field falls back to the real implementation when not provided. */
345
+ gitOps?: Partial<GitOpsAdapter>;
346
+ }
347
+
348
+ /** Subset of git-cloner that the runtime-manager actually calls. */
349
+ export interface GitOpsAdapter {
350
+ cloneProject: typeof cloneProject;
351
+ gitIsAvailable: typeof gitIsAvailable;
352
+ isGitRepo: typeof isGitRepo;
353
+ }
354
+
355
+ /** Per-project sync strategy. Recorded so the watcher (or stopAll) can
356
+ * branch on it without re-probing `git --version`. */
357
+ export type SyncMode = 'git' | 'files';
358
+
359
+ /** Internal per-project runtime record. */
360
+ interface InternalRuntime {
361
+ projectId: string;
362
+ agentPort: number;
363
+ apiServerPort: number;
364
+ status: RuntimeStatus;
365
+ proc: ChildProcess | null;
366
+ /**
367
+ * PID of the most recent spawn, retained after `proc` is nulled in
368
+ * {@link WorkerRuntimeManager.handleExit}. On posix, the runtime is
369
+ * spawned as a process group leader (`detached: true`), so this is
370
+ * also the PGID — `process.kill(-pid, ...)` cascades to vite, the
371
+ * preview-manager's inner API server, tsserver and pyright that the
372
+ * runtime spawned, which otherwise survive a SIGKILL of the parent
373
+ * (jetsam OOM) and accumulate as orphans until app restart.
374
+ */
375
+ pid: number | null;
376
+ startedAt: number;
377
+ /**
378
+ * Wall-clock timestamp of the most recent stdout/stderr line emitted
379
+ * by `proc`. Read by {@link WorkerRuntimeManager.waitForHealth} as a
380
+ * "process is making forward progress" signal that lets a slow boot
381
+ * clear the readiness gate even when /health hasn't responded yet.
382
+ * See the {@link STDOUT_PROGRESS_WINDOW_MS} doc and the comment block
383
+ * inside `waitForHealth` for the full rationale (Windows
384
+ * `--conditions=development` cold-boot pattern where Bun
385
+ * JIT-compiles the entire TS dep graph on first request and the
386
+ * 30s HTTP /health budget vanishes before the first response).
387
+ */
388
+ lastStdoutAt: number;
389
+ lastUsedAt: number;
390
+ restarts: number;
391
+ /** Consecutive non-clean exits since the last healthy run. */
392
+ consecutiveFailures: number;
393
+ /** Timestamp (Date.now) of the most recent non-clean exit. Used to
394
+ * detect "loop within the failure window" for the circuit breaker. */
395
+ lastFailureAt: number;
396
+ /** Timer that resets `consecutiveFailures` to 0 once a fresh run has
397
+ * survived for {@link STARTUP_GRACE_MS}. */
398
+ graceTimer: ReturnType<typeof setTimeout> | null;
399
+ restartTimer: ReturnType<typeof setTimeout> | null;
400
+ idleTimer: ReturnType<typeof setTimeout> | null;
401
+ /**
402
+ * RSS watchdog enforcing the per-project memory ceiling where no kernel-level
403
+ * cap is available (macOS always; Windows without the Job Object helper;
404
+ * Linux without cgroup access). Null when a cgroup/Job Object handles the cap
405
+ * or when no limit is configured. Stopped on exit/stop.
406
+ */
407
+ limitWatchdog: RssWatchdog | null;
408
+ lastError?: string;
409
+ spawnConfig: ProjectSpawnConfig;
410
+ /**
411
+ * Promise of an in-flight start so concurrent ensureRunning() calls
412
+ * dedupe instead of double-spawning.
413
+ */
414
+ startPromise: Promise<InternalRuntime> | null;
415
+ }
416
+
417
+ /**
418
+ * Per-worker signing secret used to derive `RUNTIME_AUTH_SECRET` and
419
+ * `WEBHOOK_TOKEN` for each project. Generated lazily on first need and
420
+ * persisted nowhere — secrets exist for the worker's process lifetime.
421
+ *
422
+ * The cloud already authenticated the tunneled request; the runtime
423
+ * token only protects the localhost surface from co-tenants on shared
424
+ * dev machines, so a per-process random is sufficient.
425
+ */
426
+ let workerSigningSecret: string | null = null;
427
+ function getWorkerSigningSecret(): string {
428
+ if (!workerSigningSecret) {
429
+ workerSigningSecret = randomBytes(32).toString('hex');
430
+ }
431
+ return workerSigningSecret;
432
+ }
433
+
434
+ function deriveRuntimeToken(projectId: string): string {
435
+ return createHmac('sha256', getWorkerSigningSecret()).update(`runtime:${projectId}`).digest('hex');
436
+ }
437
+
438
+ function deriveWebhookToken(projectId: string): string {
439
+ return createHmac('sha256', getWorkerSigningSecret()).update(`webhook:${projectId}`).digest('hex');
440
+ }
441
+
442
+ function splitPathAndQuery(pathWithQuery: string): { pathname: string; search: string } {
443
+ const q = pathWithQuery.indexOf('?');
444
+ if (q === -1) return { pathname: pathWithQuery, search: '' };
445
+ return { pathname: pathWithQuery.slice(0, q), search: pathWithQuery.slice(q) };
446
+ }
447
+
448
+ /** True if the directory exists and contains no entries (or doesn't exist). */
449
+ function isDirEmpty(dir: string): boolean {
450
+ try {
451
+ const entries = readdirSync(dir);
452
+ return entries.length === 0;
453
+ } catch {
454
+ return true;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Render the operator-facing multi-line error the worker raises when it
460
+ * cannot determine a real on-disk workspace for a project.
461
+ *
462
+ * Why it's verbose: every branch here represents a real misconfig the
463
+ * operator has to fix before the worker can serve traffic, and the
464
+ * runtime's own `WORKSPACE_DIR fell back to '/app/workspace'` warning
465
+ * is only visible in the spawned child's stderr (often hidden behind
466
+ * the worker's logging seam). Surfacing the full menu of fixes — flag,
467
+ * env var, manual `shogo project pull` — at the throw site means an
468
+ * operator's first sight of the failure is also their fix.
469
+ */
470
+ export function formatWorkspaceMisconfigError(
471
+ projectId: string,
472
+ reason: 'no-auto-pull-config' | 'no-projects-dir' | 'auto-pull-disabled',
473
+ expectedDir: string | null,
474
+ ): string {
475
+ const lines: string[] = [];
476
+ lines.push(`Cannot spawn agent-runtime for project ${projectId}: no workspace directory available.`);
477
+ lines.push('');
478
+ switch (reason) {
479
+ case 'no-auto-pull-config':
480
+ lines.push(
481
+ ' Reason: WorkerRuntimeManager was constructed without an `autoPull` config and ' +
482
+ 'no caller-provided `projectDir` was found on disk.',
483
+ );
484
+ lines.push(
485
+ ' This usually means a programmatic embedder forgot to wire up enrichSpawnConfig ' +
486
+ 'or autoPull. CLI users should not see this — please file a bug.',
487
+ );
488
+ break;
489
+ case 'no-projects-dir':
490
+ lines.push(
491
+ ' Reason: auto-pull is configured but `projectsDir` is empty. The worker needs a ' +
492
+ 'persistent root directory under which it can store cloned project workspaces.',
493
+ );
494
+ break;
495
+ case 'auto-pull-disabled':
496
+ lines.push(
497
+ ' Reason: auto-pull was disabled (--no-auto-pull) and the expected pre-pulled ' +
498
+ `workspace at ${expectedDir} is missing or empty.`,
499
+ );
500
+ break;
501
+ }
502
+ lines.push('');
503
+ lines.push(' How to fix (pick one):');
504
+ lines.push(' 1. Re-enable auto-pull (default). Drop the --no-auto-pull flag and restart');
505
+ lines.push(' the worker. The first inbound request for this project will clone its');
506
+ lines.push(' workspace from Shogo Cloud into <projectsDir>/<projectId>/.');
507
+ lines.push('');
508
+ lines.push(' 2. Pre-pull manually with `shogo project pull <projectId>` before starting');
509
+ lines.push(' the worker. Use this when you want full control over when the clone runs');
510
+ lines.push(' (slow links, scheduled maintenance windows, etc.).');
511
+ lines.push('');
512
+ lines.push(' 3. Point the worker at an existing workspace by setting either:');
513
+ lines.push(' --projects-dir <path> (per-invocation flag)');
514
+ lines.push(' SHOGO_PROJECTS_DIR=<path> (env var, persists across restarts)');
515
+ lines.push(' shogo config set projectsDir <path>');
516
+ lines.push(' Whichever path you pick must contain a subdirectory named after the ');
517
+ lines.push(` project id (e.g. <path>/${projectId}/) populated with the project's source.`);
518
+ lines.push('');
519
+ lines.push(' Docs: https://shogo.ai/docs/self-hosted-worker#workspace-seeding');
520
+ return lines.join('\n');
521
+ }
522
+
523
+ export class WorkerRuntimeManager implements RuntimeResolver {
524
+ private readonly opts: WorkerRuntimeManagerOptions;
525
+ private readonly log: Pick<Console, 'log' | 'warn' | 'error'>;
526
+ private readonly runtimes = new Map<string, InternalRuntime>();
527
+ private readonly usedPorts = new Set<number>();
528
+ private readonly spawnCommand: SpawnCommandFactory;
529
+ private resolved: ResolvedRuntime | null = null;
530
+ private stopped = false;
531
+ /**
532
+ * Per-project resource limits (memory/CPU ceiling) read once from the
533
+ * environment. `undefined` = not yet resolved; `null` = no limit configured.
534
+ */
535
+ private resolvedLimits: RuntimeResourceLimits | null | undefined = undefined;
536
+
537
+ /** Active watchers per projectId, keyed by projectId. Stopped in stopAll. */
538
+ private readonly watchers = new Map<string, CloudSyncWatcher>();
539
+ /** Projects we've already pulled (or attempted to pull) this lifetime. */
540
+ private readonly pulledProjects = new Set<string>();
541
+ /** Which sync strategy each project ended up using. Used by the watcher
542
+ * to pick between git commit-push and file-transport flush modes. */
543
+ private readonly syncModes = new Map<string, SyncMode>();
544
+
545
+ constructor(opts: WorkerRuntimeManagerOptions = {}) {
546
+ this.opts = opts;
547
+ this.log = opts.logger ?? console;
548
+ this.spawnCommand = opts.spawnCommand ?? defaultSpawnCommand;
549
+ }
550
+
551
+ /**
552
+ * Resolve and cache the runtime binary path. Called eagerly by
553
+ * `shogo worker start` so the user sees the missing-binary error
554
+ * immediately rather than on the first inbound request.
555
+ */
556
+ resolveBinary(): ResolvedRuntime | null {
557
+ if (!this.resolved) {
558
+ this.resolved = this.opts.resolveBin
559
+ ? this.opts.resolveBin()
560
+ : resolveRuntime({ flag: this.opts.runtimeBin, env: this.opts.env });
561
+ }
562
+ return this.resolved;
563
+ }
564
+
565
+ // ─── RuntimeResolver implementation (used by WorkerTunnel) ──────
566
+
567
+ /**
568
+ * Resolve a tunneled path to a local URL. /agent/* paths trigger an
569
+ * on-demand spawn for the projectId; non-agent paths return null
570
+ * (the worker doesn't host an apps/api locally — the desktop adapter
571
+ * subclasses this to add that fallback).
572
+ */
573
+ async resolveLocalUrl(pathWithQuery: string, projectId?: string): Promise<string | null> {
574
+ const { pathname, search } = splitPathAndQuery(pathWithQuery);
575
+ if (!(pathname.startsWith('/agent/') || pathname === '/agent')) return null;
576
+ if (!projectId) {
577
+ // Without a projectId we can't pick a runtime; pick the first active
578
+ // one if there's exactly one — matches the desktop's permissive fallback.
579
+ const active = this.getActiveProjects();
580
+ if (active.length !== 1) return null;
581
+ projectId = active[0]!;
582
+ }
583
+ const config = await this.spawnConfigFor(projectId);
584
+ if (!config) {
585
+ this.log.warn(`[WorkerRuntimeManager] No spawn config for ${projectId} — set defaultSpawnConfig or enrichSpawnConfig`);
586
+ return null;
587
+ }
588
+ const status = await this.ensureRunning(projectId, config);
589
+ if (!status.agentPort) return null;
590
+ this.touch(projectId);
591
+ return `http://127.0.0.1:${status.agentPort}${pathname}${search}`;
592
+ }
593
+
594
+ deriveRuntimeToken(projectId: string): string | null {
595
+ return deriveRuntimeToken(projectId);
596
+ }
597
+
598
+ /**
599
+ * Tell the {@link WorkerTunnel} why we returned `null` from
600
+ * `resolveLocalUrl`. The tunnel echoes this into the structured 502
601
+ * body so a Studio client reading the response can tell whether the
602
+ * request hit a path the worker has no opinion about
603
+ * (`/api/projects`) versus an /agent path that lacked a project
604
+ * context.
605
+ *
606
+ * Stable codes:
607
+ * CLI_WORKER_HAS_NO_DATA_API — non-/agent path; cli-worker
608
+ * instances are execution targets,
609
+ * not data sources. Studio is
610
+ * expected to gate stateful API
611
+ * routing on `instance.kind` and
612
+ * fall back to cloud for these.
613
+ * CLI_WORKER_NO_PROJECT_FOR_PATH — /agent path arrived without a
614
+ * `projectId` and we don't have a
615
+ * single active project to fall
616
+ * back to.
617
+ */
618
+ describeRejection(pathWithQuery: string, projectId?: string): ResolveRejection {
619
+ const { pathname } = splitPathAndQuery(pathWithQuery);
620
+ if (!(pathname.startsWith('/agent/') || pathname === '/agent')) {
621
+ return {
622
+ code: 'CLI_WORKER_HAS_NO_DATA_API',
623
+ message: `cli-worker only serves /agent/* paths; tried: ${pathname}`,
624
+ };
625
+ }
626
+ return {
627
+ code: 'CLI_WORKER_NO_PROJECT_FOR_PATH',
628
+ message:
629
+ `cli-worker received an /agent path without a single active project; ` +
630
+ `projectId=${projectId ?? 'none'}, path=${pathname}`,
631
+ };
632
+ }
633
+
634
+ private async spawnConfigFor(projectId: string): Promise<ProjectSpawnConfig | null> {
635
+ const base = this.opts.defaultSpawnConfig;
636
+ if (!base) return null;
637
+ if (this.opts.enrichSpawnConfig) {
638
+ try {
639
+ return await this.opts.enrichSpawnConfig(projectId, base);
640
+ } catch (err: any) {
641
+ this.log.warn(
642
+ `[WorkerRuntimeManager] enrichSpawnConfig failed for ${projectId}: ${err?.message ?? err}`,
643
+ );
644
+ }
645
+ }
646
+ return base;
647
+ }
648
+
649
+ /**
650
+ * Idempotently ensure a runtime exists for this projectId. Concurrent
651
+ * callers share the in-flight spawn promise.
652
+ *
653
+ * Side effect: if `opts.autoPull.enabled` is true and this is the first
654
+ * time we've seen this projectId, we'll clone the workspace from cloud
655
+ * BEFORE spawning the runtime. Failures are non-fatal — the runtime
656
+ * still spawns and falls back to template-seeded defaults so the worker
657
+ * never bricks because the cloud Files API was momentarily down.
658
+ */
659
+ async ensureRunning(projectId: string, config: ProjectSpawnConfig): Promise<RuntimeStatusInfo> {
660
+ if (this.stopped) throw new Error('WorkerRuntimeManager is stopped');
661
+
662
+ // Refuse circuit-broken slots BEFORE auto-pull so we don't churn
663
+ // the network/disk on a project we already know we won't spawn.
664
+ // Surfacing the parked-state message lets the caller (tunnel
665
+ // proxy, desktop UI) render an actionable error instead of the
666
+ // generic auto-pull / spawn failure.
667
+ const failedExisting = this.runtimes.get(projectId);
668
+ if (failedExisting?.status === 'failed') {
669
+ throw new Error(
670
+ `[WorkerRuntimeManager] cannot ensureRunning(${projectId}): ` +
671
+ `${failedExisting.lastError ?? 'runtime is in failed state'}. ` +
672
+ `Call resetFailure(${projectId}) or stop(${projectId}) before retrying.`,
673
+ );
674
+ }
675
+
676
+ // Apply auto-pull before any runtime spawn so the runtime's PROJECT_DIR
677
+ // points at a fully-cloned workspace. Idempotent: subsequent calls hit
678
+ // the `pulledProjects` short-circuit.
679
+ config = await this.maybeAutoPull(projectId, config);
680
+
681
+ const existing = this.runtimes.get(projectId);
682
+ if (existing?.status === 'running') {
683
+ this.touch(projectId);
684
+ return this.snapshot(existing);
685
+ }
686
+ if (existing?.startPromise) {
687
+ const r = await existing.startPromise;
688
+ return this.snapshot(r);
689
+ }
690
+
691
+ const slot: InternalRuntime = existing ?? this.makeSlot(projectId, config);
692
+ if (!existing) this.runtimes.set(projectId, slot);
693
+ slot.spawnConfig = config;
694
+ slot.startPromise = this.doStart(slot);
695
+ try {
696
+ const r = await slot.startPromise;
697
+ // We just brought a (possibly new) runtime up — enforce the hard
698
+ // ceiling now so a busy multi-project session can't accumulate
699
+ // runtimes without bound. Never evicts the one we just started.
700
+ this.enforceMaxRuntimes(projectId);
701
+ return this.snapshot(r);
702
+ } finally {
703
+ slot.startPromise = null;
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Enforce {@link WorkerRuntimeManagerOptions.maxRuntimes} by LRU-evicting
709
+ * the least-recently-used running slot until the count is at/under the
710
+ * cap. Skips:
711
+ * - the slot we just started (`keepProjectId`),
712
+ * - any slot touched within {@link STREAM_ACTIVE_WINDOW_MS} (treated as
713
+ * mid-stream — we never cut a live chat),
714
+ * - non-running slots (starting/restarting/stopping/failed don't count
715
+ * against the cap and aren't safe to tear down here).
716
+ *
717
+ * If every over-cap slot is actively streaming we stop early and let the
718
+ * count ride briefly over the cap rather than killing a live stream — the
719
+ * next ensureRunning (or idle eviction) reclaims it once it goes quiet.
720
+ *
721
+ * Fire-and-forget: eviction `stop()` is async (process-group kill +
722
+ * grace window) but we don't await it — the caller shouldn't block its
723
+ * own spawn on tearing down someone else's idle runtime.
724
+ */
725
+ private enforceMaxRuntimes(keepProjectId: string): void {
726
+ const cap = this.opts.maxRuntimes;
727
+ if (cap == null || !Number.isFinite(cap) || cap <= 0) return;
728
+
729
+ const now = Date.now();
730
+ const running = Array.from(this.runtimes.values()).filter((r) => r.status === 'running');
731
+ if (running.length <= cap) return;
732
+
733
+ // LRU order: oldest lastUsedAt first.
734
+ const candidates = running
735
+ .filter((r) => r.projectId !== keepProjectId && now - r.lastUsedAt >= STREAM_ACTIVE_WINDOW_MS)
736
+ .sort((a, b) => a.lastUsedAt - b.lastUsedAt);
737
+
738
+ let overBy = running.length - cap;
739
+ for (const victim of candidates) {
740
+ if (overBy <= 0) break;
741
+ const idleMs = now - victim.lastUsedAt;
742
+ this.log.log(
743
+ `[WorkerRuntimeManager] maxRuntimes=${cap} exceeded (${running.length} running) — ` +
744
+ `LRU-evicting ${victim.projectId} (idle ${Math.round(idleMs / 1000)}s)`,
745
+ );
746
+ void this.stop(victim.projectId).catch((err: any) => {
747
+ this.log.warn(
748
+ `[WorkerRuntimeManager] maxRuntimes eviction of ${victim.projectId} failed: ${err?.message ?? err}`,
749
+ );
750
+ });
751
+ overBy--;
752
+ }
753
+
754
+ if (overBy > 0) {
755
+ this.log.log(
756
+ `[WorkerRuntimeManager] maxRuntimes=${cap} still exceeded by ${overBy} after eviction pass — ` +
757
+ `remaining over-cap slots are mid-stream; will retry on next spawn / idle reap`,
758
+ );
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Public entry point for tests + the `worker start` command to pre-warm
764
+ * a project's workspace without spawning anything. Internally idempotent.
765
+ */
766
+ async ensurePulled(projectId: string, config: ProjectSpawnConfig): Promise<ProjectSpawnConfig> {
767
+ return this.maybeAutoPull(projectId, config);
768
+ }
769
+
770
+ private async maybeAutoPull(projectId: string, config: ProjectSpawnConfig): Promise<ProjectSpawnConfig> {
771
+ const auto = this.opts.autoPull;
772
+
773
+ // ── Workspace-locatability invariants for the cli-worker ──
774
+ //
775
+ // The agent-runtime hard-falls-back to `/app/workspace` (a Docker
776
+ // convention) when none of WORKSPACE_DIR / AGENT_DIR / PROJECT_DIR
777
+ // are set. On a self-hosted VPS that path doesn't exist, so the
778
+ // runtime boots but every project-aware route silently serves
779
+ // empty state. To keep that bug from ever shipping again, the
780
+ // worker spawn path is required to either:
781
+ //
782
+ // (a) populate `cfg.projectDir` itself before reaching here
783
+ // (desktop AGPL adapter does this through `enrichSpawnConfig`,
784
+ // cloud sets WORKSPACE_DIR via Knative env vars), OR
785
+ // (b) carry an `autoPull` config with a `projectsDir` so this
786
+ // method can synthesise a per-project workspace path on
787
+ // disk and clone the cloud snapshot into it.
788
+ //
789
+ // Anything else is a misconfiguration — the runtime would either
790
+ // not find the workspace or scribble into a co-tenant's tree —
791
+ // so we throw with a multi-line operator-facing message instead
792
+ // of letting the agent-runtime print a deceptively-mild warning.
793
+ //
794
+ // The bypass for `enrichSpawnConfig` callers is deliberate: the
795
+ // desktop AGPL adapter wires up its own per-project Prisma-derived
796
+ // workspace path and does NOT need autoPull. We honour `projectDir`
797
+ // when the directory actually exists.
798
+ if (config.projectDir && existsSync(config.projectDir)) {
799
+ return config;
800
+ }
801
+
802
+ if (!auto) {
803
+ throw new Error(formatWorkspaceMisconfigError(projectId, 'no-auto-pull-config', null));
804
+ }
805
+
806
+ if (!auto.projectsDir) {
807
+ throw new Error(formatWorkspaceMisconfigError(projectId, 'no-projects-dir', null));
808
+ }
809
+
810
+ if (!auto.enabled) {
811
+ // Operator opted out of auto-pull. Honour a pre-pulled workspace
812
+ // (`shogo project pull <id>` lays one down at the canonical path);
813
+ // anything else is a misconfig because the runtime has nothing to
814
+ // operate on.
815
+ const candidate = join(auto.projectsDir, projectId);
816
+ if (existsSync(candidate) && !isDirEmpty(candidate)) {
817
+ return { ...config, projectDir: candidate };
818
+ }
819
+ throw new Error(formatWorkspaceMisconfigError(projectId, 'auto-pull-disabled', candidate));
820
+ }
821
+
822
+ if (this.pulledProjects.has(projectId)) {
823
+ // Already attempted in this process — return the canonical path
824
+ // so a previous failure doesn't strand the runtime on the
825
+ // /app/workspace fallback.
826
+ return { ...config, projectDir: join(auto.projectsDir, projectId) };
827
+ }
828
+
829
+ const projectDir = join(auto.projectsDir, projectId);
830
+ const log = auto.logger ?? this.log;
831
+ const git = {
832
+ cloneProject: auto.gitOps?.cloneProject ?? cloneProject,
833
+ gitIsAvailable: auto.gitOps?.gitIsAvailable ?? gitIsAvailable,
834
+ isGitRepo: auto.gitOps?.isGitRepo ?? isGitRepo,
835
+ };
836
+
837
+ // Mark before attempting so failures don't cause repeated re-pulls
838
+ // on every single request — the runtime will still start with an
839
+ // empty WORKSPACE_DIR and seed templates as a fallback.
840
+ this.pulledProjects.add(projectId);
841
+
842
+ try {
843
+ mkdirSync(projectDir, { recursive: true });
844
+ const isEmpty = isDirEmpty(projectDir);
845
+ const alreadyGitRepo = git.isGitRepo(projectDir);
846
+
847
+ // Strategy:
848
+ // 1. If git is available AND the dir is empty (no .git, no files):
849
+ // clone via smart-HTTP, then top-up `.shogo/` (SQLite, gitignored)
850
+ // via the file transport.
851
+ // 2. If git is available AND the dir already has a .git/: trust
852
+ // the existing clone. The watcher / a later `shogo project
853
+ // checkout` brings refs forward.
854
+ // 3. Otherwise (git unavailable, useGit=false, OR the dir has
855
+ // non-empty content with no .git/): fall back to file transport.
856
+ const wantGit = auto.useGit !== false;
857
+ const gitAvailable = wantGit ? await git.gitIsAvailable() : false;
858
+ const mode: SyncMode = (gitAvailable && (isEmpty || alreadyGitRepo)) ? 'git' : 'files';
859
+ this.syncModes.set(projectId, mode);
860
+
861
+ if (mode === 'git') {
862
+ if (isEmpty) {
863
+ log.log(`[WorkerRuntimeManager] auto-pull: git clone project ${projectId} into ${projectDir}`);
864
+ try {
865
+ const res = await git.cloneProject({
866
+ apiUrl: config.cloudUrl,
867
+ apiKey: config.apiKey,
868
+ projectId,
869
+ localDir: projectDir,
870
+ shallow: true,
871
+ logger: log,
872
+ });
873
+ log.log(`[WorkerRuntimeManager] auto-pull: ${projectId} cloned at ${res.commitSha.slice(0, 8)}`);
874
+ } catch (err: any) {
875
+ // Git clone failed — try the file transport as a fallback.
876
+ // We DON'T retry git on subsequent runs: the mode flip is
877
+ // sticky for this projectId's lifetime to avoid bouncing.
878
+ log.warn(
879
+ `[WorkerRuntimeManager] auto-pull: git clone failed for ${projectId} (${err?.message ?? err}); ` +
880
+ `falling back to CloudFileTransport.downloadAll`,
881
+ );
882
+ this.syncModes.set(projectId, 'files');
883
+ await this.fileTransportClone(projectId, projectDir, config, log);
884
+ }
885
+ } else if (alreadyGitRepo) {
886
+ log.log(`[WorkerRuntimeManager] auto-pull: ${projectId} already has .git/; skipping clone`);
887
+ }
888
+
889
+ // After a git clone, top-up gitignored `.shogo/` SQLite state via
890
+ // the file transport. `.shogo/` is excluded from git but the
891
+ // agent-runtime requires it for state continuity across pins.
892
+ if (this.syncModes.get(projectId) === 'git') {
893
+ await this.topUpShogoState(projectId, projectDir, config, log);
894
+ }
895
+ } else if (isEmpty) {
896
+ // Pure file-transport path (git unavailable or disabled).
897
+ await this.fileTransportClone(projectId, projectDir, config, log);
898
+ } else {
899
+ log.log(`[WorkerRuntimeManager] auto-pull: ${projectId} workspace already populated; skipping clone`);
900
+ }
901
+
902
+ // Spin up a watcher so locally written files sync back to cloud.
903
+ // We only need ONE watcher per project regardless of how many
904
+ // runtimes spawn for it. The watcher's mode mirrors the chosen
905
+ // sync strategy: git → commit+push on flush, files → PUT per file.
906
+ if (auto.watch !== false && !this.watchers.has(projectId)) {
907
+ try {
908
+ const transport = new CloudFileTransport({
909
+ apiUrl: config.cloudUrl,
910
+ apiKey: config.apiKey,
911
+ projectId,
912
+ localDir: projectDir,
913
+ });
914
+ const finalMode = this.syncModes.get(projectId) ?? 'files';
915
+ const watcher = new CloudSyncWatcher({
916
+ rootDir: projectDir,
917
+ transport,
918
+ logger: log,
919
+ mode: finalMode,
920
+ git: finalMode === 'git'
921
+ ? {
922
+ apiUrl: config.cloudUrl,
923
+ apiKey: config.apiKey,
924
+ projectId,
925
+ }
926
+ : undefined,
927
+ });
928
+ watcher.start();
929
+ this.watchers.set(projectId, watcher);
930
+ } catch (err: any) {
931
+ log.warn(`[WorkerRuntimeManager] auto-pull: watcher start failed for ${projectId}: ${err?.message ?? err}`);
932
+ }
933
+ }
934
+ } catch (err: any) {
935
+ log.warn(
936
+ `[WorkerRuntimeManager] auto-pull: failed for ${projectId} — runtime will fall back to template defaults. ` +
937
+ `(${err?.message ?? err})`,
938
+ );
939
+ }
940
+
941
+ // Always set projectDir so the runtime points at the (possibly empty)
942
+ // persistent location instead of a tmpdir. This keeps the runtime
943
+ // crash-resilient — restarts find the same workspace.
944
+ return { ...config, projectDir };
945
+ }
946
+
947
+ /** File-transport clone of an entire project workspace into `projectDir`. */
948
+ private async fileTransportClone(
949
+ projectId: string,
950
+ projectDir: string,
951
+ config: ProjectSpawnConfig,
952
+ log: Pick<Console, 'log' | 'warn' | 'error'>,
953
+ ): Promise<void> {
954
+ log.log(`[WorkerRuntimeManager] auto-pull: file-transport clone of ${projectId} into ${projectDir}`);
955
+ const transport = new CloudFileTransport({
956
+ apiUrl: config.cloudUrl,
957
+ apiKey: config.apiKey,
958
+ projectId,
959
+ localDir: projectDir,
960
+ });
961
+ const stats = await transport.downloadAll();
962
+ log.log(
963
+ `[WorkerRuntimeManager] auto-pull: ${projectId} downloaded ${stats.downloaded} files ` +
964
+ `(${stats.errors.length} errors)`,
965
+ );
966
+ }
967
+
968
+ /**
969
+ * After a git clone, the worker's workspace is missing `.shogo/`
970
+ * (the per-project SQLite state directory) because `.shogo/` is
971
+ * gitignored. Pull just those entries via the file transport so the
972
+ * agent-runtime sees consistent DB state on first spawn.
973
+ */
974
+ private async topUpShogoState(
975
+ projectId: string,
976
+ projectDir: string,
977
+ config: ProjectSpawnConfig,
978
+ log: Pick<Console, 'log' | 'warn' | 'error'>,
979
+ ): Promise<void> {
980
+ try {
981
+ const transport = new CloudFileTransport({
982
+ apiUrl: config.cloudUrl,
983
+ apiKey: config.apiKey,
984
+ projectId,
985
+ localDir: projectDir,
986
+ });
987
+ const manifest = await transport.listManifest();
988
+ const shogoEntries = manifest.filter((e: { path: string }) => e.path === '.shogo' || e.path.startsWith('.shogo/'));
989
+ if (shogoEntries.length === 0) return;
990
+ const stats = await transport.downloadFiles(shogoEntries);
991
+ log.log(
992
+ `[WorkerRuntimeManager] auto-pull: ${projectId} .shogo/ top-up downloaded ${stats.downloaded} ` +
993
+ `files (${stats.errors.length} errors)`,
994
+ );
995
+ } catch (err: any) {
996
+ // Non-fatal: the runtime will create a fresh SQLite db if needed.
997
+ log.warn(`[WorkerRuntimeManager] auto-pull: .shogo top-up failed for ${projectId}: ${err?.message ?? err}`);
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * Whether the spawned runtime for `projectId` should defer its own
1003
+ * S3Sync + checkpoint inserts to the worker's cloud sync (sets
1004
+ * `SHOGO_CLOUD_SYNC=1`). True only when auto-pull is enabled AND this
1005
+ * project was actually pulled (so a watcher is running for it) — a
1006
+ * pre-seeded / operator-pinned / template-seeded project keeps the
1007
+ * runtime's own sync. Public so the wiring is unit-testable without
1008
+ * spawning a runtime.
1009
+ */
1010
+ shouldDeferToCloudSync(projectId: string): boolean {
1011
+ return !!this.opts.autoPull?.enabled && this.pulledProjects.has(projectId);
1012
+ }
1013
+
1014
+ status(projectId: string): RuntimeStatusInfo | null {
1015
+ const r = this.runtimes.get(projectId);
1016
+ return r ? this.snapshot(r) : null;
1017
+ }
1018
+
1019
+ getActiveProjects(): string[] {
1020
+ return Array.from(this.runtimes.keys()).filter((id) => {
1021
+ const r = this.runtimes.get(id);
1022
+ return r && (r.status === 'running' || r.status === 'starting' || r.status === 'restarting');
1023
+ });
1024
+ }
1025
+
1026
+ /** Mark the runtime as recently used. Resets the idle eviction timer. */
1027
+ touch(projectId: string): void {
1028
+ const r = this.runtimes.get(projectId);
1029
+ if (!r) return;
1030
+ r.lastUsedAt = Date.now();
1031
+ this.armIdleTimer(r);
1032
+ }
1033
+
1034
+ async stop(projectId: string, signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
1035
+ const r = this.runtimes.get(projectId);
1036
+ if (!r) return;
1037
+ r.status = 'stopping';
1038
+ if (r.restartTimer) { clearTimeout(r.restartTimer); r.restartTimer = null; }
1039
+ if (r.idleTimer) { clearTimeout(r.idleTimer); r.idleTimer = null; }
1040
+ if (r.graceTimer) { clearTimeout(r.graceTimer); r.graceTimer = null; }
1041
+ if (r.limitWatchdog) { r.limitWatchdog.stop(); r.limitWatchdog = null; }
1042
+ if (r.proc) {
1043
+ // Send the requested signal to the whole process group first so
1044
+ // children (vite, preview-manager's API server, LSPs) start their
1045
+ // own graceful shutdown in parallel with the parent.
1046
+ this.killProcessGroup(r, signal);
1047
+ try { r.proc.kill(signal); } catch { /* already gone */ }
1048
+ await this.waitForExit(r.proc, 5000);
1049
+ // Belt-and-suspenders: if the grace window elapsed without a
1050
+ // clean exit, `waitForExit` already SIGKILL'd the parent — chase
1051
+ // the rest of the group too in case any child ignored SIGTERM.
1052
+ this.killProcessGroup(r, 'SIGKILL');
1053
+ }
1054
+ r.pid = null;
1055
+ this.releasePort(r.agentPort);
1056
+ this.runtimes.delete(projectId);
1057
+ }
1058
+
1059
+ /**
1060
+ * Re-arm a runtime that the circuit breaker parked in `'failed'`.
1061
+ * Drops the slot from the map so the next `ensureRunning(projectId, …)`
1062
+ * call performs a fresh `doStart()` with a zeroed failure budget.
1063
+ *
1064
+ * Intended for the desktop's "reopen project" flow and for operators
1065
+ * who fixed whatever was crashing the runtime (e.g. freed memory,
1066
+ * deleted a corrupted workspace file) and want to retry without
1067
+ * tearing down the whole worker. No-op if the project isn't in
1068
+ * `'failed'`.
1069
+ */
1070
+ resetFailure(projectId: string): boolean {
1071
+ const r = this.runtimes.get(projectId);
1072
+ if (!r || r.status !== 'failed') return false;
1073
+ if (r.restartTimer) { clearTimeout(r.restartTimer); r.restartTimer = null; }
1074
+ if (r.idleTimer) { clearTimeout(r.idleTimer); r.idleTimer = null; }
1075
+ if (r.graceTimer) { clearTimeout(r.graceTimer); r.graceTimer = null; }
1076
+ if (r.limitWatchdog) { r.limitWatchdog.stop(); r.limitWatchdog = null; }
1077
+ this.runtimes.delete(projectId);
1078
+ this.log.log(`[WorkerRuntimeManager] resetFailure: ${projectId} cleared, next ensureRunning will respawn`);
1079
+ return true;
1080
+ }
1081
+
1082
+ async stopAll(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
1083
+ this.stopped = true;
1084
+ // Stop watchers FIRST so their final flush has a chance to PUT before
1085
+ // we tear down processes. We don't await individual stops in parallel
1086
+ // with runtime stops because watcher.stop() does network IO and we
1087
+ // want it to complete before the runtime kill.
1088
+ const watcherIds = Array.from(this.watchers.keys());
1089
+ await Promise.all(watcherIds.map(async (id) => {
1090
+ const w = this.watchers.get(id);
1091
+ this.watchers.delete(id);
1092
+ if (w) {
1093
+ try { await w.stop(); } catch (err: any) {
1094
+ this.log.warn(`[WorkerRuntimeManager] watcher stop ${id}: ${err?.message ?? err}`);
1095
+ }
1096
+ }
1097
+ }));
1098
+
1099
+ const ids = Array.from(this.runtimes.keys());
1100
+ await Promise.all(ids.map((id) => this.stop(id, signal).catch((err) => {
1101
+ this.log.error(`[WorkerRuntimeManager] Failed to stop ${id}: ${err?.message ?? err}`);
1102
+ })));
1103
+ }
1104
+
1105
+ // ─── Internals ──────────────────────────────────────────────────
1106
+
1107
+ private makeSlot(projectId: string, config: ProjectSpawnConfig): InternalRuntime {
1108
+ return {
1109
+ projectId,
1110
+ agentPort: 0,
1111
+ apiServerPort: 0,
1112
+ status: 'starting',
1113
+ proc: null,
1114
+ pid: null,
1115
+ startedAt: 0,
1116
+ lastStdoutAt: 0,
1117
+ lastUsedAt: Date.now(),
1118
+ restarts: 0,
1119
+ consecutiveFailures: 0,
1120
+ lastFailureAt: 0,
1121
+ graceTimer: null,
1122
+ restartTimer: null,
1123
+ idleTimer: null,
1124
+ limitWatchdog: null,
1125
+ spawnConfig: config,
1126
+ startPromise: null,
1127
+ };
1128
+ }
1129
+
1130
+ private async doStart(slot: InternalRuntime): Promise<InternalRuntime> {
1131
+ const resolved = this.resolveBinary();
1132
+ if (!resolved) {
1133
+ slot.status = 'error';
1134
+ slot.lastError = 'agent-runtime binary not found (run `shogo runtime install`)';
1135
+ throw new Error(slot.lastError);
1136
+ }
1137
+
1138
+ if (!slot.agentPort) {
1139
+ slot.agentPort = await this.allocatePort();
1140
+ slot.apiServerPort = slot.agentPort + API_PORT_OFFSET;
1141
+ }
1142
+
1143
+ const env = this.buildEnv(slot, resolved.path);
1144
+ const cwd = this.resolveCwd(slot);
1145
+ const baseSpawn = this.spawnCommand(resolved.path);
1146
+
1147
+ // Resource limits: on Linux, prefer wrapping the spawn in a rootless
1148
+ // systemd cgroup scope (hard memory.max/cpu.max). The other platforms and
1149
+ // the systemd-absent fallback are handled post-spawn in applyPostSpawnLimits.
1150
+ const limits = this.resolveLimits();
1151
+ let command = baseSpawn.command;
1152
+ let args = baseSpawn.args;
1153
+ let cgroupWrapped = false;
1154
+ if (limits) {
1155
+ const wrapped = wrapSpawnForCgroup({
1156
+ command: baseSpawn.command,
1157
+ args: baseSpawn.args,
1158
+ limits,
1159
+ scopeName: `shogo-rt-${slot.projectId.slice(0, 8)}-${Date.now().toString(36)}`,
1160
+ logger: this.log,
1161
+ });
1162
+ command = wrapped.command;
1163
+ args = wrapped.args;
1164
+ cgroupWrapped = wrapped.wrapped;
1165
+ }
1166
+
1167
+ this.log.log(
1168
+ `[WorkerRuntimeManager] Spawning agent-runtime for ${slot.projectId} ` +
1169
+ `via ${command} ${args.join(' ')} (port=${slot.agentPort}, source=${resolved.source}` +
1170
+ `${limits ? `, memCeil=${limits.memoryMB}M` : ''})`,
1171
+ );
1172
+
1173
+ // Spawn the runtime as its own process group leader (posix only —
1174
+ // Windows has no equivalent and Node's child_process docs warn that
1175
+ // `detached: true` there gives you a separate console window, not a
1176
+ // PGID). Mirrors what apps/desktop/src/local-server.ts already does
1177
+ // for the outer API server so a single kill at teardown reaches the
1178
+ // bun child + every subprocess it spawned (vite, the inner
1179
+ // preview-manager API server, tsserver, pyright). Without this,
1180
+ // jetsam SIGKILL of the bun parent leaves all of those orphans
1181
+ // alive — they keep their listening sockets, the next respawn
1182
+ // races into EADDRINUSE, and RSS climbs forever (see the storm
1183
+ // pattern in main.log lines 5258–7093 where preview-manager has to
1184
+ // walk the API port and `Force-killed leaked process` 10 times in
1185
+ // a row).
1186
+ const useProcessGroup = process.platform !== 'win32';
1187
+ const proc = spawn(command, args, {
1188
+ cwd,
1189
+ env,
1190
+ detached: useProcessGroup,
1191
+ stdio: ['ignore', 'pipe', 'pipe'],
1192
+ });
1193
+ if (useProcessGroup) {
1194
+ // The detached child would otherwise keep the parent's event
1195
+ // loop alive even after we've removed it from `runtimes` —
1196
+ // matches the apps/desktop precedent at local-server.ts:412.
1197
+ try { proc.unref(); } catch { /* unref is best-effort */ }
1198
+ }
1199
+
1200
+ slot.proc = proc;
1201
+ slot.pid = proc.pid ?? null;
1202
+ slot.status = 'starting';
1203
+ slot.startedAt = Date.now();
1204
+
1205
+ // Enforce the per-project memory/CPU ceiling on the freshly-spawned group
1206
+ // (cgroup direct-write / Windows Job Object / RSS watchdog fallback). No-op
1207
+ // when the systemd scope above already caps it or no limit is configured.
1208
+ if (limits) {
1209
+ try {
1210
+ this.applyPostSpawnLimits(slot, limits, cgroupWrapped);
1211
+ } catch (err: any) {
1212
+ this.log.warn(
1213
+ `[WorkerRuntimeManager] failed to apply resource limits for ${slot.projectId}: ${err?.message ?? err}`,
1214
+ );
1215
+ }
1216
+ }
1217
+ // Seed the progress timestamp at spawn so a child that emits its
1218
+ // first line within `STDOUT_PROGRESS_WINDOW_MS` is treated as
1219
+ // "making progress since spawn" without the readiness check having
1220
+ // to special-case the cold-start gap.
1221
+ slot.lastStdoutAt = slot.startedAt;
1222
+
1223
+ proc.on('error', (err) => {
1224
+ slot.lastError = err?.message ?? String(err);
1225
+ this.log.error(`[WorkerRuntimeManager] spawn error for ${slot.projectId}: ${slot.lastError}`);
1226
+ });
1227
+
1228
+ proc.on('exit', (code, signal) => {
1229
+ this.handleExit(slot, code, signal);
1230
+ });
1231
+
1232
+ const prefix = `[runtime:${slot.projectId.slice(0, 8)}]`;
1233
+ // Each output line bumps `lastStdoutAt` — used by waitForHealth as
1234
+ // a forward-progress signal so a long-but-still-booting child
1235
+ // (LSP spawn, optimizeDeps, hook registration) doesn't get
1236
+ // SIGTERM'd mid-boot just because /health hasn't responded yet.
1237
+ proc.stdout?.on('data', (data) => {
1238
+ slot.lastStdoutAt = Date.now();
1239
+ for (const line of data.toString().trimEnd().split('\n')) {
1240
+ if (line) this.log.log(`${prefix} ${line}`);
1241
+ }
1242
+ });
1243
+ proc.stderr?.on('data', (data) => {
1244
+ slot.lastStdoutAt = Date.now();
1245
+ for (const line of data.toString().trimEnd().split('\n')) {
1246
+ if (line) this.log.error(`${prefix} ${line}`);
1247
+ }
1248
+ });
1249
+
1250
+ try {
1251
+ await this.waitForHealth(slot, HEALTH_BOOT_TIMEOUT_MS);
1252
+ slot.status = 'running';
1253
+ slot.lastUsedAt = Date.now();
1254
+ this.armIdleTimer(slot);
1255
+ this.armGraceTimer(slot);
1256
+ return slot;
1257
+ } catch (err: any) {
1258
+ slot.status = 'error';
1259
+ slot.lastError = err?.message ?? String(err);
1260
+ // The /health wait timed out (or the spawn itself failed). Tear
1261
+ // down the whole process group rather than just the parent so we
1262
+ // don't leave a half-booted preview-manager + vite running on
1263
+ // the allocated ports.
1264
+ this.killProcessGroup(slot, 'SIGTERM');
1265
+ try { proc.kill('SIGTERM'); } catch { /* already gone */ }
1266
+ this.releasePort(slot.agentPort);
1267
+ slot.agentPort = 0;
1268
+ slot.apiServerPort = 0;
1269
+ throw err;
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Kill every process in `slot.pid`'s process group / job tree.
1275
+ * Best-effort: if the group is already gone (everyone exited cleanly)
1276
+ * this is a no-op.
1277
+ *
1278
+ * Why we use the recorded PID and not `slot.proc.pid`: by the time
1279
+ * {@link handleExit} runs, `proc` has already fired its `'exit'`
1280
+ * event and we've nulled it. The kernel keeps the process group
1281
+ * intact until the *last* member of the group exits, so the PGID
1282
+ * we captured at spawn is still valid for reaping the orphans even
1283
+ * after the group leader is gone.
1284
+ *
1285
+ * Windows: `process.kill(-pid, ...)` is unsupported and Node's
1286
+ * `child.kill('SIGTERM')` is just `TerminateProcess` on the parent,
1287
+ * which does NOT cascade to grandchildren — vite, the inner
1288
+ * preview-manager API server, tsserver, pyright, and `server.tsx`
1289
+ * all survive as orphans (each holding chokidar watcher handles
1290
+ * that wedge the next spawn's event loop). We use `taskkill /F /T`
1291
+ * to walk the process tree by parent PID instead.
1292
+ */
1293
+ /** Memoized per-project resource limits from the environment. */
1294
+ private resolveLimits(): RuntimeResourceLimits | null {
1295
+ if (this.resolvedLimits === undefined) {
1296
+ this.resolvedLimits = resolveResourceLimits(this.opts.env ?? process.env);
1297
+ }
1298
+ return this.resolvedLimits;
1299
+ }
1300
+
1301
+ /**
1302
+ * Apply the strongest available OS-level cap to a freshly-spawned runtime,
1303
+ * or start the RSS watchdog as a fallback. Called right after spawn so most
1304
+ * of the runtime's children inherit the cgroup/Job Object.
1305
+ *
1306
+ * @param cgroupWrapped true when the spawn was already wrapped in a
1307
+ * `systemd-run` scope (Linux), in which case the cap is already enforced.
1308
+ */
1309
+ private applyPostSpawnLimits(
1310
+ slot: InternalRuntime,
1311
+ limits: RuntimeResourceLimits,
1312
+ cgroupWrapped: boolean,
1313
+ ): void {
1314
+ const pid = slot.pid;
1315
+ if (!pid) return;
1316
+
1317
+ if (process.platform === 'linux') {
1318
+ if (cgroupWrapped) return; // systemd scope already enforces the cap
1319
+ if (attachDirectCgroup({ pid, limits, logger: this.log })) return;
1320
+ // fall through to watchdog
1321
+ } else if (process.platform === 'win32') {
1322
+ if (tryAttachJobObject({ pid, limits, logger: this.log })) return;
1323
+ // fall through to watchdog
1324
+ }
1325
+
1326
+ // macOS always, or a fallback from the branches above: sample RSS and
1327
+ // restart the group on sustained breach.
1328
+ slot.limitWatchdog = new RssWatchdog({
1329
+ pid,
1330
+ ceilingMB: limits.memoryMB,
1331
+ logger: this.log,
1332
+ onBreach: (rssMB) => this.handleLimitBreach(slot, rssMB, limits.memoryMB),
1333
+ });
1334
+ slot.limitWatchdog.start();
1335
+ }
1336
+
1337
+ /**
1338
+ * React to a memory-ceiling breach reported by the RSS watchdog: tear the
1339
+ * process group down gracefully. The existing `handleExit` path then treats
1340
+ * this as a non-clean exit and restarts with backoff (a project that keeps
1341
+ * ballooning will trip the circuit breaker, exactly as with an OOM loop).
1342
+ */
1343
+ private handleLimitBreach(slot: InternalRuntime, rssMB: number, ceilingMB: number): void {
1344
+ this.log.warn(
1345
+ `[WorkerRuntimeManager] runtime ${slot.projectId} exceeded memory ceiling ` +
1346
+ `(${rssMB}MB > ${ceilingMB}MB) — restarting`,
1347
+ );
1348
+ slot.lastError = `memory ceiling exceeded (${rssMB}MB > ${ceilingMB}MB)`;
1349
+ if (slot.limitWatchdog) { slot.limitWatchdog.stop(); slot.limitWatchdog = null; }
1350
+ this.killProcessGroup(slot, 'SIGTERM');
1351
+ try { slot.proc?.kill('SIGTERM'); } catch { /* already gone */ }
1352
+ // Escalate to SIGKILL if the group ignores SIGTERM within the grace window.
1353
+ const killTimer = setTimeout(() => this.killProcessGroup(slot, 'SIGKILL'), 5000);
1354
+ try { killTimer.unref?.(); } catch { /* unref is best-effort */ }
1355
+ }
1356
+
1357
+ private killProcessGroup(slot: InternalRuntime, signal: NodeJS.Signals): void {
1358
+ if (!slot.pid) return;
1359
+ if (process.platform === 'win32') {
1360
+ try {
1361
+ spawnSync('taskkill', ['/F', '/T', '/PID', String(slot.pid)], {
1362
+ stdio: 'ignore',
1363
+ windowsHide: true,
1364
+ });
1365
+ } catch {
1366
+ // taskkill missing or the tree already collapsed — either way
1367
+ // we have no recourse and the parent .kill() above (or the
1368
+ // belt-and-suspenders SIGKILL in waitForExit) is the best we
1369
+ // can still do.
1370
+ }
1371
+ return;
1372
+ }
1373
+ try {
1374
+ process.kill(-slot.pid, signal);
1375
+ } catch {
1376
+ // ESRCH (no such process group) is the happy path here — it
1377
+ // means every child exited with their parent and the kernel
1378
+ // already reaped the group. EPERM is the only other plausible
1379
+ // case; swallow it because the caller has no recourse anyway.
1380
+ }
1381
+ }
1382
+
1383
+ /**
1384
+ * Arm a timer that resets `consecutiveFailures` once a fresh run has
1385
+ * survived for {@link STARTUP_GRACE_MS}. Re-armed on every successful
1386
+ * /health transition; cleared on any non-clean exit so a crash
1387
+ * inside the grace window counts toward the circuit breaker.
1388
+ */
1389
+ private armGraceTimer(slot: InternalRuntime): void {
1390
+ if (slot.graceTimer) {
1391
+ clearTimeout(slot.graceTimer);
1392
+ slot.graceTimer = null;
1393
+ }
1394
+ slot.graceTimer = setTimeout(() => {
1395
+ slot.graceTimer = null;
1396
+ if (slot.consecutiveFailures > 0) {
1397
+ slot.consecutiveFailures = 0;
1398
+ }
1399
+ }, STARTUP_GRACE_MS);
1400
+ try { slot.graceTimer.unref?.(); } catch { /* unref is best-effort */ }
1401
+ }
1402
+
1403
+ private buildEnv(slot: InternalRuntime, runtimeBinPath: string): NodeJS.ProcessEnv {
1404
+ const cfg = slot.spawnConfig;
1405
+ const env: NodeJS.ProcessEnv = {
1406
+ ...(this.opts.env ?? process.env),
1407
+ PROJECT_ID: slot.projectId,
1408
+ PORT: String(slot.agentPort),
1409
+ API_SERVER_PORT: String(slot.apiServerPort),
1410
+ SKILL_SERVER_PORT: String(slot.apiServerPort),
1411
+ // Per-runtime base for workspace preview sidecars (server.tsx). Anchored
1412
+ // at agentPort+2 so each warm runtime owns a distinct sidecar range and
1413
+ // they can't all collide on the fixed default (3101) — the cause of the
1414
+ // preview-manager crash-loop / agent-runtime SIGKILL restart storm when
1415
+ // multiple projects are kept warm at once.
1416
+ WORKSPACE_API_PORT_BASE: String(slot.agentPort + PREVIEW_API_BASE_OFFSET),
1417
+ NODE_ENV: 'production',
1418
+ SHOGO_CLOUD_URL: cfg.cloudUrl,
1419
+ SHOGO_API_URL: cfg.cloudUrl,
1420
+ SHOGO_API_KEY: cfg.apiKey,
1421
+ RUNTIME_AUTH_SECRET: deriveRuntimeToken(slot.projectId),
1422
+ WEBHOOK_TOKEN: deriveWebhookToken(slot.projectId),
1423
+ };
1424
+
1425
+ if (cfg.projectDir) {
1426
+ env.PROJECT_DIR = cfg.projectDir;
1427
+ env.WORKSPACE_DIR = cfg.projectDir;
1428
+ }
1429
+ // Tell the agent-runtime to skip its built-in S3Sync — the worker is
1430
+ // already running a CloudFileTransport watcher against this WORKSPACE_DIR.
1431
+ // Without this both sides upload the same files and the watcher loops on
1432
+ // its own writes.
1433
+ //
1434
+ // Gate on whether THIS project was actually auto-pulled, not just on the
1435
+ // global `autoPull.enabled` flag: a worker can serve a pre-pulled or
1436
+ // operator-pinned `projectDir` that auto-pull skipped (line 777), and a
1437
+ // desktop-style embed enables auto-pull globally but still seeds some
1438
+ // projects from a local template. Those projects have no watcher, so they
1439
+ // must keep the runtime's own S3Sync/checkpoint behavior.
1440
+ if (this.shouldDeferToCloudSync(slot.projectId)) {
1441
+ env.SHOGO_CLOUD_SYNC = '1';
1442
+ }
1443
+ if (cfg.aiProxyUrl) env.AI_PROXY_URL = cfg.aiProxyUrl;
1444
+ if (cfg.aiProxyToken) env.AI_PROXY_TOKEN = cfg.aiProxyToken;
1445
+ if (cfg.techStackId) env.TECH_STACK_ID = cfg.techStackId;
1446
+ if (cfg.name) env.AGENT_NAME = cfg.name;
1447
+ if (cfg.workspaceId) env.WORKSPACE_ID = cfg.workspaceId;
1448
+
1449
+ // Belt-and-suspenders: explicitly point the spawned agent-runtime at
1450
+ // the WASM sidecar that ships next to its binary. The runtime's own
1451
+ // `code-extractor.ts:getWasmDir()` would derive the same path from
1452
+ // `dirname(process.execPath)` as a fallback, but exporting it here:
1453
+ //
1454
+ // (a) makes the resolved location observable via `env | grep
1455
+ // TREE_SITTER` for an operator debugging a self-hosted box,
1456
+ // (b) survives a future build-script regression that breaks the
1457
+ // sidecar copy on a per-platform basis (the env var still
1458
+ // points to the expected directory, so the loud failure in
1459
+ // `code-extractor.ts:getLanguage()` reports the right path),
1460
+ // (c) keeps explicit operator overrides working — the runtime
1461
+ // reads `process.env.TREE_SITTER_WASM_DIR` first, so an
1462
+ // operator who sets it externally still wins.
1463
+ //
1464
+ // We do NOT verify the directory exists here. The runtime's
1465
+ // resolver does that check; if it's missing we want the loud
1466
+ // runtime error (which lists every override knob), not a silent
1467
+ // worker-side `process.env` deletion that hides the bundling bug.
1468
+ if (!env.TREE_SITTER_WASM_DIR) {
1469
+ env.TREE_SITTER_WASM_DIR = join(dirname(runtimeBinPath), 'tree-sitter-wasm');
1470
+ }
1471
+
1472
+ if (cfg.extraEnv) Object.assign(env, cfg.extraEnv);
1473
+
1474
+ // Secondary hint: cap the JS heap of Node children (tsserver, vite/esbuild)
1475
+ // and Bun below the RSS ceiling so a JS OOM (recoverable) trips before the
1476
+ // OS-level cap/watchdog kills the whole group. OS limits are the real cap.
1477
+ const limits = this.resolveLimits();
1478
+ if (limits) applyHeapEnvCap(env, limits.memoryMB);
1479
+
1480
+ return env;
1481
+ }
1482
+
1483
+ private resolveCwd(slot: InternalRuntime): string {
1484
+ const cfg = slot.spawnConfig;
1485
+ if (cfg.projectDir && existsSync(cfg.projectDir)) return cfg.projectDir;
1486
+ const fallback = this.opts.runtimeWorkDir ?? join(tmpdir(), 'shogo-runtime', slot.projectId);
1487
+ mkdirSync(fallback, { recursive: true });
1488
+ return fallback;
1489
+ }
1490
+
1491
+ private handleExit(slot: InternalRuntime, code: number | null, signal: NodeJS.Signals | null): void {
1492
+ const exitedClean = signal === null && code === 0;
1493
+ this.log.log(
1494
+ `[WorkerRuntimeManager] runtime ${slot.projectId} exited (code=${code}, signal=${signal})`,
1495
+ );
1496
+ slot.proc = null;
1497
+ if (slot.graceTimer) {
1498
+ clearTimeout(slot.graceTimer);
1499
+ slot.graceTimer = null;
1500
+ }
1501
+ // The process (and its group) is gone — stop sampling its RSS. A fresh
1502
+ // watchdog is armed by the next doStart() if the runtime restarts.
1503
+ if (slot.limitWatchdog) { slot.limitWatchdog.stop(); slot.limitWatchdog = null; }
1504
+
1505
+ if (slot.status === 'stopping' || this.stopped) {
1506
+ // We initiated the stop; the orphan reap was already done by
1507
+ // stop()/stopAll(). Just clear bookkeeping.
1508
+ slot.status = 'stopped';
1509
+ slot.pid = null;
1510
+ this.releasePort(slot.agentPort);
1511
+ slot.agentPort = 0;
1512
+ slot.apiServerPort = 0;
1513
+ return;
1514
+ }
1515
+
1516
+ if (exitedClean) {
1517
+ slot.status = 'stopped';
1518
+ slot.pid = null;
1519
+ this.releasePort(slot.agentPort);
1520
+ slot.agentPort = 0;
1521
+ slot.apiServerPort = 0;
1522
+ this.runtimes.delete(slot.projectId);
1523
+ return;
1524
+ }
1525
+
1526
+ // Non-clean exit. Two failure shapes we care about:
1527
+ // 1. Parent died but children (vite, preview-manager API server,
1528
+ // LSPs) are still alive in the same process group. Without
1529
+ // reaping, the next doStart() races into EADDRINUSE on the
1530
+ // same agent port and starts the storm.
1531
+ // 2. Repeated jetsam SIGKILL under memory pressure. Each cycle
1532
+ // respawns the full child tree and leaks more RSS. The
1533
+ // circuit breaker below stops that loop.
1534
+ this.killProcessGroup(slot, 'SIGKILL');
1535
+ slot.pid = null;
1536
+
1537
+ const now = Date.now();
1538
+ const withinWindow = now - slot.lastFailureAt <= RESTART_FAILURE_WINDOW_MS;
1539
+ slot.consecutiveFailures = withinWindow ? slot.consecutiveFailures + 1 : 1;
1540
+ slot.lastFailureAt = now;
1541
+ slot.restarts += 1;
1542
+ slot.lastError = `exited code=${code} signal=${signal}`;
1543
+
1544
+ if (slot.consecutiveFailures >= MAX_CONSECUTIVE_RESTARTS) {
1545
+ slot.status = 'failed';
1546
+ slot.lastError =
1547
+ `Circuit breaker tripped: ${slot.consecutiveFailures} consecutive non-clean exits ` +
1548
+ `within ${Math.round(RESTART_FAILURE_WINDOW_MS / 1000)}s (last: code=${code} signal=${signal}). ` +
1549
+ `Most recent on macOS is jetsam OOM (signal=SIGKILL with code=null); ` +
1550
+ `the previous incarnation's vite/tsserver/preview-manager children were reaped to ` +
1551
+ `prevent further RSS growth. Stop, fix the workspace, and call resetFailure(projectId) ` +
1552
+ `(or stop(projectId)) to allow another spawn attempt.`;
1553
+ this.releasePort(slot.agentPort);
1554
+ slot.agentPort = 0;
1555
+ slot.apiServerPort = 0;
1556
+ if (slot.restartTimer) { clearTimeout(slot.restartTimer); slot.restartTimer = null; }
1557
+ if (slot.idleTimer) { clearTimeout(slot.idleTimer); slot.idleTimer = null; }
1558
+ this.log.error(`[WorkerRuntimeManager] ${slot.lastError}`);
1559
+ return;
1560
+ }
1561
+
1562
+ const delay = this.restartBackoffMs(slot.restarts);
1563
+ slot.status = 'restarting';
1564
+ this.log.warn(
1565
+ `[WorkerRuntimeManager] restarting ${slot.projectId} in ${Math.round(delay / 1000)}s ` +
1566
+ `(restart #${slot.restarts}, consecutive failures ${slot.consecutiveFailures}/${MAX_CONSECUTIVE_RESTARTS})`,
1567
+ );
1568
+ if (slot.restartTimer) clearTimeout(slot.restartTimer);
1569
+ slot.restartTimer = setTimeout(() => {
1570
+ slot.restartTimer = null;
1571
+ if (slot.status === 'failed' || this.stopped) return;
1572
+ slot.startPromise = this.doStart(slot).then((r) => {
1573
+ slot.startPromise = null;
1574
+ return r;
1575
+ }).catch((err) => {
1576
+ slot.startPromise = null;
1577
+ this.log.error(`[WorkerRuntimeManager] restart of ${slot.projectId} failed: ${err?.message ?? err}`);
1578
+ return slot;
1579
+ });
1580
+ }, delay);
1581
+ try { slot.restartTimer.unref?.(); } catch { /* unref is best-effort */ }
1582
+ }
1583
+
1584
+ private restartBackoffMs(restarts: number): number {
1585
+ const base = Math.min(RESTART_BACKOFF_BASE_MS * Math.pow(2, Math.max(0, restarts - 1)), RESTART_BACKOFF_MAX_MS);
1586
+ const jitter = base * 0.2 * Math.random();
1587
+ return base + jitter;
1588
+ }
1589
+
1590
+ private armIdleTimer(slot: InternalRuntime): void {
1591
+ if (slot.idleTimer) {
1592
+ clearTimeout(slot.idleTimer);
1593
+ slot.idleTimer = null;
1594
+ }
1595
+ const idleMs = this.opts.idleMs ?? RUNTIME_IDLE_MS;
1596
+ // `idleMs <= 0` or non-finite disables eviction. Used by desktop /
1597
+ // `SHOGO_LOCAL_MODE=true` where reaping a "stale" runtime really
1598
+ // means killing the in-flight chat stream of the only user.
1599
+ if (!Number.isFinite(idleMs) || idleMs <= 0) return;
1600
+ slot.idleTimer = setTimeout(() => {
1601
+ const since = Date.now() - slot.lastUsedAt;
1602
+ if (since < idleMs) {
1603
+ // Got touched between scheduling and firing — re-arm.
1604
+ this.armIdleTimer(slot);
1605
+ return;
1606
+ }
1607
+ this.log.log(`[WorkerRuntimeManager] idle-evicting ${slot.projectId} after ${Math.round(since / 1000)}s`);
1608
+ void this.stop(slot.projectId).catch((err) => {
1609
+ this.log.warn(`[WorkerRuntimeManager] idle stop failed: ${err?.message ?? err}`);
1610
+ });
1611
+ }, idleMs);
1612
+ }
1613
+
1614
+ private async allocatePort(): Promise<number> {
1615
+ const range = PORT_RANGE_END - PORT_RANGE_START;
1616
+ const maxAttempts = Math.min(range, 50);
1617
+ for (let i = 0; i < maxAttempts; i++) {
1618
+ const candidate = PORT_RANGE_START + Math.floor(Math.random() * range);
1619
+ // Reserve a contiguous per-runtime block so the agent port, its API
1620
+ // server AND every preview sidecar (WORKSPACE_API_PORT_BASE + idx) live
1621
+ // in a range that no other warm runtime can overlap.
1622
+ if (candidate + RUNTIME_PORT_BLOCK - 1 > PORT_RANGE_END) continue;
1623
+ let blockFree = true;
1624
+ for (let off = 0; off < RUNTIME_PORT_BLOCK; off++) {
1625
+ if (this.usedPorts.has(candidate + off)) { blockFree = false; break; }
1626
+ }
1627
+ if (!blockFree) continue;
1628
+ // Liveness-probe only the two ports we bind eagerly (agent + its API
1629
+ // server). The sidecar ports are bound lazily by the agent-runtime and
1630
+ // guarded by its own leaked-process force-kill, so probing the whole
1631
+ // block here would just slow allocation down.
1632
+ const agentInUse = await this.isPortListening(candidate);
1633
+ const apiInUse = await this.isPortListening(candidate + API_PORT_OFFSET);
1634
+ if (agentInUse || apiInUse) continue;
1635
+ for (let off = 0; off < RUNTIME_PORT_BLOCK; off++) this.usedPorts.add(candidate + off);
1636
+ return candidate;
1637
+ }
1638
+ throw new Error(
1639
+ `Cannot allocate port in range ${PORT_RANGE_START}-${PORT_RANGE_END} after ${maxAttempts} attempts`,
1640
+ );
1641
+ }
1642
+
1643
+ private releasePort(port: number): void {
1644
+ if (!port) return;
1645
+ for (let off = 0; off < RUNTIME_PORT_BLOCK; off++) this.usedPorts.delete(port + off);
1646
+ }
1647
+
1648
+ private async isPortListening(port: number): Promise<boolean> {
1649
+ const controller = new AbortController();
1650
+ const timer = setTimeout(() => controller.abort(), 250);
1651
+ try {
1652
+ await fetch(`http://127.0.0.1:${port}/`, { method: 'HEAD', signal: controller.signal });
1653
+ clearTimeout(timer);
1654
+ return true;
1655
+ } catch {
1656
+ clearTimeout(timer);
1657
+ return false;
1658
+ }
1659
+ }
1660
+
1661
+ /**
1662
+ * Kernel-level TCP-accept probe. Resolves `true` if a TCP connection
1663
+ * to `127.0.0.1:port` succeeds within {@link TCP_CONNECT_TIMEOUT_MS},
1664
+ * `false` for any failure mode (refused, timed out, host unreachable).
1665
+ *
1666
+ * This is intentionally distinct from {@link isPortListening} (which
1667
+ * fires an HTTP HEAD): we want to know whether `Bun.serve()` has
1668
+ * bound the socket, NOT whether its request handler is responding.
1669
+ * Once the kernel has the listener, the agent-runtime's
1670
+ * `export default { port, fetch }` has been evaluated — which is
1671
+ * sufficient evidence to clear the boot gate even if the event loop
1672
+ * is still busy JIT-compiling the rest of the TS dep graph. Without
1673
+ * this distinction every `--conditions=development` cold boot on
1674
+ * Windows hits the HEALTH_BOOT_TIMEOUT_MS ceiling because /health
1675
+ * is the very last thing the saturated event loop gets to.
1676
+ */
1677
+ private tcpProbe(port: number): Promise<boolean> {
1678
+ return new Promise<boolean>((resolve) => {
1679
+ let settled = false;
1680
+ const finish = (ok: boolean) => {
1681
+ if (settled) return;
1682
+ settled = true;
1683
+ try {
1684
+ socket.destroy();
1685
+ } catch {
1686
+ /* socket may already be torn down */
1687
+ }
1688
+ resolve(ok);
1689
+ };
1690
+ const socket = createConnection({ host: '127.0.0.1', port });
1691
+ socket.setTimeout(TCP_CONNECT_TIMEOUT_MS);
1692
+ socket.once('connect', () => finish(true));
1693
+ socket.once('error', () => finish(false));
1694
+ socket.once('timeout', () => finish(false));
1695
+ });
1696
+ }
1697
+
1698
+ /**
1699
+ * Wait for a freshly-spawned agent-runtime to be HTTP-responsive on
1700
+ * /health. This is the gate that drives the slot's `'starting'` →
1701
+ * `'running'` transition; every consumer of `status === 'running'`
1702
+ * (`/sandbox/url`, AgentProxy, the iframe preview readiness probe)
1703
+ * assumes the runtime can actually serve requests, so this gate
1704
+ * must mean exactly that — not "the kernel accepted a TCP listener
1705
+ * on the port".
1706
+ *
1707
+ * Loop semantics:
1708
+ *
1709
+ * 1. **HTTP /health 2xx → return.** Happy path; the event loop
1710
+ * is responsive and the slot can transition to `'running'`.
1711
+ *
1712
+ * 2. **Child exits → throw.** Same as before — surface the
1713
+ * exit code/signal so restart-with-backoff can recover from
1714
+ * crashes-during-boot without burning the full timeout.
1715
+ *
1716
+ * 3. **TCP listening + stdout silent > {@link STDOUT_PROGRESS_WINDOW_MS}
1717
+ * → throw.** TCP up but no log lines for 30s is "wedged":
1718
+ * Bun is past `Bun.serve()` (so the kernel has the port) but
1719
+ * something inside the runtime spun the event loop to death
1720
+ * (infinite loop, deadlock). Bail so the restart loop can
1721
+ * SIGTERM and respawn instead of spinning the full timeoutMs.
1722
+ *
1723
+ * 4. **`timeoutMs` elapsed → throw.** Final ceiling. After the
1724
+ * 2026-05 fix that deferred LSP + IndexEngine out of the
1725
+ * critical-path boot sequence in agent-runtime, a healthy
1726
+ * Windows cold boot is ~8-12 s; the 30 s budget gives a
1727
+ * comfortable margin without hiding real hangs.
1728
+ *
1729
+ * Progress logging: every {@link HEALTH_PROGRESS_LOG_MS} we emit a
1730
+ * single line summarizing where the wait stands so the dev/operator
1731
+ * watching `[dev:all]` sees something happen during the slow path
1732
+ * instead of staring at a silent terminal for a minute.
1733
+ *
1734
+ * What changed vs the pre-2026-05 fast path: that revision returned
1735
+ * as soon as TCP-listening + recent stdout was true, treating "the
1736
+ * child is making progress" as ready. In practice the child was
1737
+ * still saturated for tens of seconds after that point, so the UI
1738
+ * would start its preview iframe load and the API server its agent
1739
+ * proxy, both of which then hit their own (4-15s) timeouts and
1740
+ * surfaced as the "Connection timed out — The agent runtime could
1741
+ * not be reached" toast plus "[AgentProxy] timeout, retrying" log
1742
+ * spam. The new gate trades 20-60s of additional boot wait for a
1743
+ * `'running'` signal the rest of the stack can actually trust.
1744
+ */
1745
+ private async waitForHealth(slot: InternalRuntime, timeoutMs: number): Promise<void> {
1746
+ const port = slot.agentPort;
1747
+ const proc = slot.proc;
1748
+ if (!proc) {
1749
+ throw new Error(`waitForHealth: slot ${slot.projectId} has no spawned process`);
1750
+ }
1751
+ const startedAt = Date.now();
1752
+ const deadline = startedAt + timeoutMs;
1753
+ let lastError: string | null = null;
1754
+ let lastTcpListening = false;
1755
+ let lastTcpAt = 0;
1756
+ let httpAttempts = 0;
1757
+ let tcpAttempts = 0;
1758
+ let iteration = 0;
1759
+ let lastProgressLogAt = startedAt;
1760
+
1761
+ while (Date.now() < deadline) {
1762
+ iteration++;
1763
+ if (proc.exitCode !== null || proc.signalCode != null || proc.killed) {
1764
+ throw new Error(
1765
+ `agent-runtime exited (code=${proc.exitCode}, signal=${proc.signalCode}) before becoming healthy on port ${port}`,
1766
+ );
1767
+ }
1768
+
1769
+ // Primary signal: HTTP /health. This is the only signal that
1770
+ // declares the runtime ready — TCP-listening alone is not enough
1771
+ // (see class docstring above for the rationale).
1772
+ httpAttempts++;
1773
+ const controller = new AbortController();
1774
+ const t = setTimeout(() => controller.abort(), 1500);
1775
+ try {
1776
+ const resp = await fetch(`http://127.0.0.1:${port}/health`, {
1777
+ method: 'GET',
1778
+ signal: controller.signal,
1779
+ });
1780
+ clearTimeout(t);
1781
+ if (resp.ok) {
1782
+ this.log.log(
1783
+ `[WorkerRuntimeManager] /health ready for ${slot.projectId} on port ${port} ` +
1784
+ `(HTTP ${resp.status} after ${Date.now() - startedAt}ms, ${iteration} iter, ${httpAttempts} http)`,
1785
+ );
1786
+ return;
1787
+ }
1788
+ // Non-2xx is recorded but doesn't short-circuit — the runtime
1789
+ // may briefly serve 503 while initializing post-bind.
1790
+ lastError = `HTTP /health returned ${resp.status}`;
1791
+ } catch (err: any) {
1792
+ clearTimeout(t);
1793
+ const name = err?.name ?? 'Error';
1794
+ const code = err?.code ?? err?.cause?.code;
1795
+ lastError = `HTTP /health failed: ${name}${code ? `(${code})` : ''}: ${err?.message ?? err}`;
1796
+ }
1797
+
1798
+ // Secondary signal: TCP listener + stdout activity. Now used ONLY
1799
+ // as a wedge detector — if TCP is up but stdout has been silent
1800
+ // for the progress window, abandon the wait so the restart loop
1801
+ // can recover. Never declares the runtime ready on its own.
1802
+ tcpAttempts++;
1803
+ lastTcpListening = await this.tcpProbe(port);
1804
+ if (lastTcpListening) {
1805
+ lastTcpAt = Date.now();
1806
+ const sinceStdoutMs = Date.now() - slot.lastStdoutAt;
1807
+ if (sinceStdoutMs >= STDOUT_PROGRESS_WINDOW_MS) {
1808
+ throw new Error(
1809
+ `agent-runtime wedged on port ${port}: TCP listening but stdout silent for ` +
1810
+ `${sinceStdoutMs}ms (> ${STDOUT_PROGRESS_WINDOW_MS}ms window); ` +
1811
+ `${httpAttempts} /health attempts, last error: ${lastError ?? 'n/a'}`,
1812
+ );
1813
+ }
1814
+ }
1815
+
1816
+ const now = Date.now();
1817
+ if (now - lastProgressLogAt >= HEALTH_PROGRESS_LOG_MS) {
1818
+ const elapsedMs = now - startedAt;
1819
+ const sinceStdoutMs = now - slot.lastStdoutAt;
1820
+ const sinceTcpMs = lastTcpAt > 0 ? now - lastTcpAt : null;
1821
+ this.log.log(
1822
+ `[WorkerRuntimeManager] still waiting for /health on ${slot.projectId} ` +
1823
+ `port ${port} (${(elapsedMs / 1000).toFixed(1)}s elapsed, ` +
1824
+ `tcpListening=${lastTcpListening}${sinceTcpMs != null ? `(${sinceTcpMs}ms ago)` : ''}, ` +
1825
+ `lastStdout=${sinceStdoutMs}ms ago, ${httpAttempts} http, ${tcpAttempts} tcp, ` +
1826
+ `lastError=${lastError ?? 'n/a'})`,
1827
+ );
1828
+ lastProgressLogAt = now;
1829
+ }
1830
+
1831
+ await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
1832
+ }
1833
+
1834
+ if (proc.exitCode !== null || proc.signalCode != null || proc.killed) {
1835
+ throw new Error(
1836
+ `agent-runtime exited (code=${proc.exitCode}, signal=${proc.signalCode}) before becoming healthy on port ${port}`,
1837
+ );
1838
+ }
1839
+ throw new Error(
1840
+ `Timeout waiting for agent-runtime /health on port ${port} ` +
1841
+ `after ${iteration} iter (httpAttempts=${httpAttempts}, tcpAttempts=${tcpAttempts}, ` +
1842
+ `tcpListening=${lastTcpListening}, lastError=${lastError ?? 'n/a'})`,
1843
+ );
1844
+ }
1845
+
1846
+ private async waitForExit(proc: ChildProcess, timeoutMs: number): Promise<void> {
1847
+ if (proc.exitCode !== null || proc.signalCode != null || proc.killed) return;
1848
+ await new Promise<void>((resolve) => {
1849
+ const t = setTimeout(() => {
1850
+ try { proc.kill('SIGKILL'); } catch { /* already gone */ }
1851
+ resolve();
1852
+ }, timeoutMs);
1853
+ proc.once('exit', () => {
1854
+ clearTimeout(t);
1855
+ resolve();
1856
+ });
1857
+ });
1858
+ }
1859
+
1860
+ private snapshot(r: InternalRuntime): RuntimeStatusInfo {
1861
+ return {
1862
+ projectId: r.projectId,
1863
+ status: r.status,
1864
+ agentPort: r.agentPort || undefined,
1865
+ apiServerPort: r.apiServerPort || undefined,
1866
+ pid: r.proc?.pid,
1867
+ startedAt: r.startedAt || undefined,
1868
+ lastUsedAt: r.lastUsedAt,
1869
+ restarts: r.restarts,
1870
+ lastError: r.lastError,
1871
+ };
1872
+ }
1873
+ }