@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.
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/bin/shogo.mjs +38 -0
- package/dist/cli.mjs +64 -0
- package/package.json +62 -0
- package/src/cli.ts +199 -0
- package/src/commands/agent.ts +123 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/project-checkout.ts +179 -0
- package/src/commands/project-pull.ts +133 -0
- package/src/commands/project-push.ts +89 -0
- package/src/commands/runtime.ts +104 -0
- package/src/commands/start.ts +290 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/agent-launch.test.ts +92 -0
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
- package/src/lib/__tests__/config.test.ts +141 -0
- package/src/lib/__tests__/db-doctor.test.ts +204 -0
- package/src/lib/__tests__/git-cloner.test.ts +382 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
- package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
- package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
- package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
- package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
- package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
- package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +320 -0
- package/src/lib/cloud-sync-watcher.ts +322 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/db-doctor.ts +355 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +53 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/resource-limits.ts +340 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +1873 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +710 -0
- 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
|
+
}
|