@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,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
|
+
});
|