@shogo-ai/worker 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +432 -0
  3. package/bin/shogo.mjs +38 -0
  4. package/dist/cli.mjs +64 -0
  5. package/package.json +62 -0
  6. package/src/cli.ts +199 -0
  7. package/src/commands/agent.ts +123 -0
  8. package/src/commands/config.ts +29 -0
  9. package/src/commands/doctor.ts +128 -0
  10. package/src/commands/login.ts +126 -0
  11. package/src/commands/logs.ts +24 -0
  12. package/src/commands/project-checkout.ts +179 -0
  13. package/src/commands/project-pull.ts +133 -0
  14. package/src/commands/project-push.ts +89 -0
  15. package/src/commands/runtime.ts +104 -0
  16. package/src/commands/start.ts +290 -0
  17. package/src/commands/status.ts +22 -0
  18. package/src/commands/stop.ts +13 -0
  19. package/src/lib/__tests__/agent-launch.test.ts +92 -0
  20. package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
  21. package/src/lib/__tests__/cloud-login.test.ts +218 -0
  22. package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
  23. package/src/lib/__tests__/config.test.ts +141 -0
  24. package/src/lib/__tests__/db-doctor.test.ts +204 -0
  25. package/src/lib/__tests__/git-cloner.test.ts +382 -0
  26. package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
  27. package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
  28. package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
  29. package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
  30. package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
  31. package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
  32. package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
  33. package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
  34. package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
  35. package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
  36. package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
  37. package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
  38. package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
  39. package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
  40. package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
  41. package/src/lib/api-discovery.ts +36 -0
  42. package/src/lib/cloud-login.ts +320 -0
  43. package/src/lib/cloud-sync-watcher.ts +322 -0
  44. package/src/lib/config.ts +68 -0
  45. package/src/lib/db-doctor.ts +355 -0
  46. package/src/lib/device-id.ts +27 -0
  47. package/src/lib/git-cloner.ts +354 -0
  48. package/src/lib/paths.ts +53 -0
  49. package/src/lib/preflight.ts +158 -0
  50. package/src/lib/process-manager.ts +123 -0
  51. package/src/lib/resource-limits.ts +340 -0
  52. package/src/lib/runtime-install.ts +371 -0
  53. package/src/lib/runtime-manager.ts +1873 -0
  54. package/src/lib/runtime-resolver.ts +136 -0
  55. package/src/lib/transport.ts +202 -0
  56. package/src/lib/tunnel.ts +710 -0
  57. package/src/lib/user-agent.ts +28 -0
@@ -0,0 +1,310 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Coverage for the {@link WorkerRuntimeManager} circuit breaker and
5
+ * process-group reaping introduced after a single user's project ran
6
+ * away with 54 consecutive `signal=SIGKILL` jetsam exits, each cycle
7
+ * leaking the agent-runtime's vite / preview-manager / LSP children
8
+ * (see main.log lines 5258–7093 for the prod reproduction).
9
+ *
10
+ * The unfixed code:
11
+ * - Spawned the runtime with `detached: false` and only ever called
12
+ * `proc.kill(...)` on the parent, so the kernel left the child
13
+ * subprocess tree alive after each jetsam SIGKILL of the parent.
14
+ * Symptom: `Force-killed leaked process N on port 37273` 10 times
15
+ * in a row, EADDRINUSE on the next spawn.
16
+ * - Had no max-restart cap; `handleExit` incremented forever, so
17
+ * once jetsam fired the first time the worker was locked into a
18
+ * ~1/minute respawn loop until the user quit the app. Symptom:
19
+ * `restart #54` in the same project's log.
20
+ *
21
+ * This file pins three contracts:
22
+ *
23
+ * 1. Every non-clean exit reaps the runtime's process group via the
24
+ * recorded PID (the orphan-sweep that wasn't happening before).
25
+ * 2. After {@link MAX_CONSECUTIVE_RESTARTS} consecutive non-clean
26
+ * exits within {@link RESTART_FAILURE_WINDOW_MS}, the slot
27
+ * transitions to `'failed'` and the manager stops scheduling
28
+ * restarts. `ensureRunning(...)` then refuses to silently
29
+ * respawn until the operator calls `resetFailure(...)`.
30
+ * 3. A clean exit (code=0, signal=null) does NOT increment the
31
+ * failure budget — bounded chat sessions that exit normally
32
+ * after their work is done should leave the slot in the same
33
+ * "healthy" state they found it in.
34
+ */
35
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
36
+ import { WorkerRuntimeManager } from '../runtime-manager.ts';
37
+
38
+ interface FakeProc {
39
+ exitCode: number | null;
40
+ signalCode: NodeJS.Signals | null;
41
+ killed: boolean;
42
+ pid: number;
43
+ kill: (signal?: NodeJS.Signals | number) => boolean;
44
+ once: (event: string, cb: (...args: unknown[]) => void) => FakeProc;
45
+ on: (event: string, cb: (...args: unknown[]) => void) => FakeProc;
46
+ }
47
+
48
+ function makeFakeProc(pid = 99999): FakeProc {
49
+ const proc: FakeProc = {
50
+ exitCode: null,
51
+ signalCode: null,
52
+ killed: false,
53
+ pid,
54
+ kill(_signal?: NodeJS.Signals | number) {
55
+ proc.killed = true;
56
+ return true;
57
+ },
58
+ once(_event, _cb) {
59
+ return proc;
60
+ },
61
+ on(_event, _cb) {
62
+ return proc;
63
+ },
64
+ };
65
+ return proc;
66
+ }
67
+
68
+ function insertRunningSlot(mgr: WorkerRuntimeManager, projectId: string, pid = 99999) {
69
+ const proc = makeFakeProc(pid);
70
+ const slot = {
71
+ projectId,
72
+ agentPort: 0,
73
+ apiServerPort: 0,
74
+ status: 'running' as const,
75
+ proc,
76
+ pid,
77
+ startedAt: Date.now(),
78
+ lastUsedAt: Date.now(),
79
+ restarts: 0,
80
+ consecutiveFailures: 0,
81
+ lastFailureAt: 0,
82
+ graceTimer: null,
83
+ restartTimer: null,
84
+ idleTimer: null,
85
+ spawnConfig: {} as never,
86
+ startPromise: null,
87
+ };
88
+ (mgr as unknown as { runtimes: Map<string, typeof slot> }).runtimes.set(projectId, slot);
89
+ return slot;
90
+ }
91
+
92
+ function handleExit(
93
+ mgr: WorkerRuntimeManager,
94
+ slot: ReturnType<typeof insertRunningSlot>,
95
+ code: number | null,
96
+ signal: NodeJS.Signals | null,
97
+ ): void {
98
+ (mgr as unknown as {
99
+ handleExit: (s: typeof slot, c: number | null, sg: NodeJS.Signals | null) => void;
100
+ }).handleExit(slot, code, signal);
101
+ }
102
+
103
+ const SILENT = { log: () => {}, warn: () => {}, error: () => {} } as const;
104
+
105
+ // Capture process.kill calls so we can pin that handleExit reaps the
106
+ // process group (negative pid argument) instead of just the parent.
107
+ type KillCall = { target: number; signal: NodeJS.Signals | number | undefined };
108
+ let killCalls: KillCall[] = [];
109
+ const realProcessKill = process.kill;
110
+
111
+ beforeEach(() => {
112
+ killCalls = [];
113
+ // Replace process.kill with a spy that ALSO doesn't actually signal
114
+ // anything (the fake pid 99999 might collide with a real process on
115
+ // the CI host).
116
+ (process as unknown as { kill: typeof process.kill }).kill = ((
117
+ pid: number,
118
+ signal?: NodeJS.Signals | number,
119
+ ) => {
120
+ killCalls.push({ target: pid, signal });
121
+ return true;
122
+ }) as typeof process.kill;
123
+ });
124
+
125
+ afterEach(() => {
126
+ (process as unknown as { kill: typeof process.kill }).kill = realProcessKill;
127
+ });
128
+
129
+ describe('WorkerRuntimeManager process-group reaping on non-clean exit', () => {
130
+ it('non-clean exit fires SIGKILL at the negative pid (the process group)', () => {
131
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
132
+ const slot = insertRunningSlot(mgr, 'proj-reap', 12345);
133
+
134
+ handleExit(mgr, slot, null, 'SIGKILL');
135
+
136
+ const pgKills = killCalls.filter((c) => c.target === -12345);
137
+ if (process.platform === 'win32') {
138
+ // killProcessGroup is a no-op on Windows; the inner preview-manager
139
+ // owns its own port-scoped reaper there.
140
+ expect(pgKills).toHaveLength(0);
141
+ } else {
142
+ expect(pgKills).toHaveLength(1);
143
+ expect(pgKills[0]!.signal).toBe('SIGKILL');
144
+ }
145
+ });
146
+
147
+ it('clean exit (code=0, signal=null) does NOT reap the process group', () => {
148
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
149
+ const slot = insertRunningSlot(mgr, 'proj-clean', 12346);
150
+
151
+ handleExit(mgr, slot, 0, null);
152
+
153
+ expect(killCalls.filter((c) => c.target === -12346)).toHaveLength(0);
154
+ // Slot is removed from the map on clean exit.
155
+ const runtimes = (mgr as unknown as { runtimes: Map<string, unknown> }).runtimes;
156
+ expect(runtimes.has('proj-clean')).toBe(false);
157
+ });
158
+
159
+ it('handleExit clears the recorded pid so a follow-up call is a no-op', () => {
160
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
161
+ const slot = insertRunningSlot(mgr, 'proj-pid-clear', 12347);
162
+
163
+ handleExit(mgr, slot, null, 'SIGKILL');
164
+ expect(slot.pid).toBeNull();
165
+
166
+ killCalls = [];
167
+ handleExit(mgr, slot, null, 'SIGKILL');
168
+ // No pid recorded → no further -pid kills should be issued.
169
+ expect(killCalls.filter((c) => c.target === -12347)).toHaveLength(0);
170
+ });
171
+ });
172
+
173
+ describe('WorkerRuntimeManager circuit breaker on consecutive failures', () => {
174
+ it('parks the slot in failed after MAX_CONSECUTIVE_RESTARTS consecutive jetsam SIGKILLs', () => {
175
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
176
+ const slot = insertRunningSlot(mgr, 'proj-jetsam', 22222);
177
+
178
+ // 8 is the constant in runtime-manager.ts; if it changes the
179
+ // assertion below has to follow. Hard-coding the expected count
180
+ // here surfaces the contract change at review time.
181
+ for (let i = 0; i < 8; i++) {
182
+ handleExit(mgr, slot, null, 'SIGKILL');
183
+ }
184
+
185
+ expect(slot.status).toBe('failed');
186
+ expect(slot.consecutiveFailures).toBe(8);
187
+ expect(slot.restartTimer).toBeNull();
188
+ expect(slot.lastError).toMatch(/Circuit breaker tripped/);
189
+ // The port is released so a future resetFailure() can re-allocate
190
+ // from a clean state.
191
+ expect(slot.agentPort).toBe(0);
192
+ expect(slot.apiServerPort).toBe(0);
193
+ });
194
+
195
+ it('still schedules a restart on the Nth non-clean exit when N < MAX', () => {
196
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
197
+ const slot = insertRunningSlot(mgr, 'proj-retry', 22223);
198
+
199
+ for (let i = 0; i < 3; i++) {
200
+ handleExit(mgr, slot, null, 'SIGKILL');
201
+ }
202
+
203
+ expect(slot.status).toBe('restarting');
204
+ expect(slot.consecutiveFailures).toBe(3);
205
+ expect(slot.restartTimer).not.toBeNull();
206
+ // Clean up the scheduled timer so the test runner exits.
207
+ if (slot.restartTimer) {
208
+ clearTimeout(slot.restartTimer);
209
+ slot.restartTimer = null;
210
+ }
211
+ });
212
+
213
+ it('clean exit between failures does NOT increment the failure budget', () => {
214
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
215
+ const slot = insertRunningSlot(mgr, 'proj-mixed', 22224);
216
+
217
+ handleExit(mgr, slot, null, 'SIGKILL');
218
+ expect(slot.consecutiveFailures).toBe(1);
219
+
220
+ // A clean exit removes the slot from the map. Re-insert a fresh
221
+ // slot to model "user reopened the project after a healthy
222
+ // shutdown" — its failure budget should be the default zero, not
223
+ // a continuation of the previous slot's counter.
224
+ handleExit(mgr, slot, 0, null);
225
+ const runtimes = (mgr as unknown as { runtimes: Map<string, unknown> }).runtimes;
226
+ expect(runtimes.has('proj-mixed')).toBe(false);
227
+
228
+ const freshSlot = insertRunningSlot(mgr, 'proj-mixed', 22225);
229
+ expect(freshSlot.consecutiveFailures).toBe(0);
230
+ });
231
+
232
+ it('a failure OUTSIDE the rolling window resets the counter to 1', () => {
233
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
234
+ const slot = insertRunningSlot(mgr, 'proj-window', 22226);
235
+
236
+ handleExit(mgr, slot, null, 'SIGKILL');
237
+ expect(slot.consecutiveFailures).toBe(1);
238
+ if (slot.restartTimer) { clearTimeout(slot.restartTimer); slot.restartTimer = null; }
239
+
240
+ // Backdate the last failure to outside the window (default 5min).
241
+ // Date.now() is stable enough inside one test run; we just shift
242
+ // the recorded timestamp into the past.
243
+ slot.lastFailureAt = Date.now() - (10 * 60 * 1000);
244
+
245
+ handleExit(mgr, slot, null, 'SIGKILL');
246
+ expect(slot.consecutiveFailures).toBe(1);
247
+ if (slot.restartTimer) { clearTimeout(slot.restartTimer); slot.restartTimer = null; }
248
+ });
249
+
250
+ it('exits triggered by stop() (status=stopping) do NOT count toward the failure budget', () => {
251
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
252
+ const slot = insertRunningSlot(mgr, 'proj-stop', 22227);
253
+ slot.status = 'stopping';
254
+
255
+ handleExit(mgr, slot, null, 'SIGTERM');
256
+
257
+ expect(slot.consecutiveFailures).toBe(0);
258
+ expect(slot.status).toBe('stopped');
259
+ // And the process group is NOT re-reaped — stop() already did that
260
+ // before signalling the parent.
261
+ expect(killCalls.filter((c) => c.target === -22227)).toHaveLength(0);
262
+ });
263
+ });
264
+
265
+ describe('WorkerRuntimeManager resetFailure', () => {
266
+ it('clears a failed slot so the next ensureRunning can re-spawn', () => {
267
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
268
+ const slot = insertRunningSlot(mgr, 'proj-reset', 33333);
269
+ for (let i = 0; i < 8; i++) handleExit(mgr, slot, null, 'SIGKILL');
270
+ expect(slot.status).toBe('failed');
271
+
272
+ const reset = mgr.resetFailure('proj-reset');
273
+ expect(reset).toBe(true);
274
+
275
+ const runtimes = (mgr as unknown as { runtimes: Map<string, unknown> }).runtimes;
276
+ expect(runtimes.has('proj-reset')).toBe(false);
277
+ });
278
+
279
+ it('returns false for a running slot — guard against accidental resets', () => {
280
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
281
+ insertRunningSlot(mgr, 'proj-running', 33334);
282
+
283
+ expect(mgr.resetFailure('proj-running')).toBe(false);
284
+ const runtimes = (mgr as unknown as { runtimes: Map<string, unknown> }).runtimes;
285
+ // Slot is preserved when resetFailure is a no-op.
286
+ expect(runtimes.has('proj-running')).toBe(true);
287
+ });
288
+
289
+ it('returns false for an unknown projectId', () => {
290
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
291
+ expect(mgr.resetFailure('does-not-exist')).toBe(false);
292
+ });
293
+ });
294
+
295
+ describe('WorkerRuntimeManager.ensureRunning refuses failed slots', () => {
296
+ it('throws a directive error pointing at resetFailure when the slot is parked', async () => {
297
+ const mgr = new WorkerRuntimeManager({ logger: SILENT });
298
+ const slot = insertRunningSlot(mgr, 'proj-locked', 44444);
299
+ for (let i = 0; i < 8; i++) handleExit(mgr, slot, null, 'SIGKILL');
300
+
301
+ // The fake spawn config isn't reached — ensureRunning guards on
302
+ // status before doStart — so we can pass an empty object.
303
+ await expect(
304
+ mgr.ensureRunning('proj-locked', {
305
+ cloudUrl: 'https://example.invalid',
306
+ apiKey: 'unused',
307
+ }),
308
+ ).rejects.toThrow(/Circuit breaker tripped|resetFailure/);
309
+ });
310
+ });
@@ -0,0 +1,189 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Tests for the belt-and-suspenders `TREE_SITTER_WASM_DIR` injection
5
+ * added to `WorkerRuntimeManager.buildEnv` in PR #2 ("Bundle tree-
6
+ * sitter WASMs alongside compiled agent-runtime binary").
7
+ *
8
+ * The contract we verify:
9
+ *
10
+ * 1. When the worker spawns an `agent-runtime` binary at
11
+ * `/path/to/shogo-agent-runtime`, the spawn env contains
12
+ * `TREE_SITTER_WASM_DIR=/path/to/tree-sitter-wasm`. Operators
13
+ * who run `env | grep TREE_SITTER` on a debugging session see
14
+ * exactly where the runtime is configured to look.
15
+ *
16
+ * 2. An operator-provided override on the worker host
17
+ * (`process.env.TREE_SITTER_WASM_DIR=/custom/path`) flows
18
+ * through to the spawned runtime — `buildEnv` does NOT clobber
19
+ * a pre-existing env value. The runtime's resolver also reads
20
+ * the env first, so the override stack-traces correctly.
21
+ *
22
+ * 3. When a per-project `extraEnv` block sets the same variable,
23
+ * the per-project value wins over both the inherited env and
24
+ * the binary-adjacent default. (extraEnv is documented as the
25
+ * last-merged layer; we keep that invariant.)
26
+ *
27
+ * `buildEnv` is a private method. We invoke it via a typed cast —
28
+ * the same pattern other tests in this directory use for accessing
29
+ * private internals (`runtime-manager-describe-rejection.test.ts`).
30
+ * The signature of `buildEnv(slot, runtimeBinPath)` is exercised
31
+ * directly with a synthesized `slot` rather than going through the
32
+ * full spawn pipeline, because the spawn pipeline requires a real
33
+ * binary on disk. The test surface is the env-shape contract, not
34
+ * the spawn-orchestration logic (which is covered elsewhere).
35
+ */
36
+
37
+ import { describe, expect, it } from 'bun:test';
38
+ import { dirname, join } from 'node:path';
39
+ import { WorkerRuntimeManager } from '../runtime-manager';
40
+
41
+ /**
42
+ * Construct a fake `InternalRuntime` slot just rich enough for
43
+ * `buildEnv` to read every field it touches. Mirrors the shape of
44
+ * `InternalRuntime` in runtime-manager.ts; if that shape grows new
45
+ * fields used by buildEnv, this fixture must follow.
46
+ */
47
+ function fakeSlot(overrides: Record<string, unknown> = {}): unknown {
48
+ return {
49
+ projectId: 'proj-tree-sitter-env-test',
50
+ agentPort: 41000,
51
+ apiServerPort: 41100,
52
+ spawnConfig: {
53
+ cloudUrl: 'https://cloud.test',
54
+ apiKey: 'api-key',
55
+ ...((overrides.spawnConfig as Record<string, unknown>) ?? {}),
56
+ },
57
+ ...overrides,
58
+ };
59
+ }
60
+
61
+ describe('WorkerRuntimeManager.buildEnv — TREE_SITTER_WASM_DIR injection', () => {
62
+ it('exports TREE_SITTER_WASM_DIR=dirname(runtimeBinPath)/tree-sitter-wasm by default', () => {
63
+ const mgr = new WorkerRuntimeManager({
64
+ env: {} as NodeJS.ProcessEnv, // start from a clean env so we can isolate the injection
65
+ }) as unknown as { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
66
+
67
+ const runtimeBinPath = '/opt/shogo/runtime/shogo-agent-runtime-linux-x64';
68
+ const env = mgr.buildEnv(fakeSlot(), runtimeBinPath);
69
+
70
+ expect(env.TREE_SITTER_WASM_DIR).toBe(join(dirname(runtimeBinPath), 'tree-sitter-wasm'));
71
+ expect(env.TREE_SITTER_WASM_DIR).toBe('/opt/shogo/runtime/tree-sitter-wasm');
72
+ });
73
+
74
+ it('honors a worker-host override when process.env already sets TREE_SITTER_WASM_DIR', () => {
75
+ const mgr = new WorkerRuntimeManager({
76
+ env: {
77
+ TREE_SITTER_WASM_DIR: '/operator/override/path',
78
+ } as NodeJS.ProcessEnv,
79
+ }) as unknown as { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
80
+
81
+ const env = mgr.buildEnv(fakeSlot(), '/opt/shogo/runtime/shogo-agent-runtime-linux-x64');
82
+ expect(env.TREE_SITTER_WASM_DIR).toBe('/operator/override/path');
83
+ });
84
+
85
+ it('per-project extraEnv override beats both worker-host env and binary-adjacent default', () => {
86
+ const mgr = new WorkerRuntimeManager({
87
+ env: {
88
+ TREE_SITTER_WASM_DIR: '/worker/host/path',
89
+ } as NodeJS.ProcessEnv,
90
+ }) as unknown as { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
91
+
92
+ const env = mgr.buildEnv(
93
+ fakeSlot({
94
+ spawnConfig: {
95
+ cloudUrl: 'https://cloud.test',
96
+ apiKey: 'api-key',
97
+ extraEnv: { TREE_SITTER_WASM_DIR: '/per/project/override' },
98
+ },
99
+ }),
100
+ '/opt/shogo/runtime/shogo-agent-runtime-linux-x64',
101
+ );
102
+ expect(env.TREE_SITTER_WASM_DIR).toBe('/per/project/override');
103
+ });
104
+
105
+ it('preserves the cli-worker contract: PROJECT_ID, PORT, SHOGO_API_KEY co-exist with TREE_SITTER_WASM_DIR', () => {
106
+ // Drive-by: assert injecting the new variable doesn't clobber the
107
+ // existing env keys. Reduces blast radius if a future refactor of
108
+ // buildEnv reorders the assignments.
109
+ const mgr = new WorkerRuntimeManager({
110
+ env: {} as NodeJS.ProcessEnv,
111
+ }) as unknown as { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
112
+
113
+ const env = mgr.buildEnv(
114
+ fakeSlot({ projectId: 'co-exist-check', agentPort: 42424, apiServerPort: 42525 }),
115
+ '/runtime/shogo-agent-runtime',
116
+ );
117
+
118
+ expect(env.PROJECT_ID).toBe('co-exist-check');
119
+ expect(env.PORT).toBe('42424');
120
+ expect(env.API_SERVER_PORT).toBe('42525');
121
+ expect(env.SHOGO_API_KEY).toBe('api-key');
122
+ expect(env.TREE_SITTER_WASM_DIR).toBe('/runtime/tree-sitter-wasm');
123
+ });
124
+ });
125
+
126
+ describe('WorkerRuntimeManager.buildEnv — WORKSPACE_API_PORT_BASE (per-runtime preview sidecar base)', () => {
127
+ // Regression: every workspace runtime previously fell back to the fixed
128
+ // default base (3101) for its preview sidecars, so the first project of each
129
+ // concurrently-warm runtime bound the SAME port — crash-looping the
130
+ // preview-manager and SIGKILL-restart-storming the agent-runtime. The base
131
+ // must be derived from the runtime's unique agent port instead.
132
+ function mgr() {
133
+ return new WorkerRuntimeManager({ env: {} as NodeJS.ProcessEnv }) as unknown as {
134
+ buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv;
135
+ };
136
+ }
137
+
138
+ it('anchors WORKSPACE_API_PORT_BASE at agentPort + 2 (above agent + its API server)', () => {
139
+ const env = mgr().buildEnv(
140
+ fakeSlot({ agentPort: 37646, apiServerPort: 37647 }),
141
+ '/runtime/shogo-agent-runtime',
142
+ );
143
+ expect(env.WORKSPACE_API_PORT_BASE).toBe('37648');
144
+ });
145
+
146
+ it('gives two distinct runtimes distinct sidecar bases (no cross-runtime 3101 collision)', () => {
147
+ const m = mgr();
148
+ const a = m.buildEnv(fakeSlot({ agentPort: 37646 }), '/runtime/shogo-agent-runtime');
149
+ const b = m.buildEnv(fakeSlot({ agentPort: 37404 }), '/runtime/shogo-agent-runtime');
150
+ expect(a.WORKSPACE_API_PORT_BASE).not.toBe(b.WORKSPACE_API_PORT_BASE);
151
+ expect(a.WORKSPACE_API_PORT_BASE).toBe('37648');
152
+ expect(b.WORKSPACE_API_PORT_BASE).toBe('37406');
153
+ });
154
+ });
155
+
156
+ describe('WorkerRuntimeManager.allocatePort — per-runtime port block reservation', () => {
157
+ function rawMgr() {
158
+ return new WorkerRuntimeManager({ env: {} as NodeJS.ProcessEnv }) as unknown as {
159
+ allocatePort(): Promise<number>;
160
+ releasePort(port: number): void;
161
+ usedPorts: Set<number>;
162
+ };
163
+ }
164
+
165
+ it('reserves a contiguous block so a second allocation never overlaps the first runtime sidecar range', async () => {
166
+ const m = rawMgr();
167
+ const p1 = await m.allocatePort();
168
+ const p2 = await m.allocatePort();
169
+ // The two blocks [p, p+15] must be disjoint — otherwise runtime 2's agent
170
+ // port (or a sidecar) could land inside runtime 1's sidecar range.
171
+ const block1 = new Set<number>();
172
+ for (let off = 0; off < 16; off++) block1.add(p1 + off);
173
+ for (let off = 0; off < 16; off++) {
174
+ expect(block1.has(p2 + off)).toBe(false);
175
+ }
176
+ });
177
+
178
+ it('releasePort frees the whole block so the ports can be reused', async () => {
179
+ const m = rawMgr();
180
+ const p = await m.allocatePort();
181
+ // Sidecar ports (p+2 … p+15) are reserved, not just p and p+1.
182
+ expect(m.usedPorts.has(p + 2)).toBe(true);
183
+ expect(m.usedPorts.has(p + 15)).toBe(true);
184
+ m.releasePort(p);
185
+ for (let off = 0; off < 16; off++) {
186
+ expect(m.usedPorts.has(p + off)).toBe(false);
187
+ }
188
+ });
189
+ });