@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,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Coverage for `WorkerRuntimeManager.describeRejection` — the
|
|
5
|
+
* structured-rejection hook that the {@link WorkerTunnel} reads when
|
|
6
|
+
* `resolveLocalUrl` returns null.
|
|
7
|
+
*
|
|
8
|
+
* The tunnel echoes the returned `code` + `message` (plus the original
|
|
9
|
+
* path) into the 502 body so a Studio client reading the response can
|
|
10
|
+
* tell exactly what the worker refused to do. This file pins the wire
|
|
11
|
+
* shape so a future refactor doesn't silently change the body Studio
|
|
12
|
+
* relies on.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from 'bun:test';
|
|
15
|
+
import { WorkerRuntimeManager } from '../runtime-manager.ts';
|
|
16
|
+
|
|
17
|
+
describe('WorkerRuntimeManager.describeRejection', () => {
|
|
18
|
+
it('flags non-/agent paths with CLI_WORKER_HAS_NO_DATA_API and the original path', () => {
|
|
19
|
+
const mgr = new WorkerRuntimeManager({});
|
|
20
|
+
const r = mgr.describeRejection('/api/projects?workspaceId=abc', 'p-1');
|
|
21
|
+
expect(r.code).toBe('CLI_WORKER_HAS_NO_DATA_API');
|
|
22
|
+
// Path in the message must be the pathname only — `?workspaceId=abc`
|
|
23
|
+
// is verbose and the tunnel echoes the full path separately.
|
|
24
|
+
expect(r.message).toContain('/api/projects');
|
|
25
|
+
expect(r.message).toContain('cli-worker only serves /agent/* paths');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('flags /agent paths missing a project context with CLI_WORKER_NO_PROJECT_FOR_PATH', () => {
|
|
29
|
+
const mgr = new WorkerRuntimeManager({});
|
|
30
|
+
const r = mgr.describeRejection('/agent/chat', undefined);
|
|
31
|
+
expect(r.code).toBe('CLI_WORKER_NO_PROJECT_FOR_PATH');
|
|
32
|
+
expect(r.message).toContain('/agent/chat');
|
|
33
|
+
expect(r.message).toContain('projectId=none');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('preserves the projectId when one is provided but no runtime exists', () => {
|
|
37
|
+
const mgr = new WorkerRuntimeManager({});
|
|
38
|
+
const r = mgr.describeRejection('/agent/chat', 'proj-42');
|
|
39
|
+
expect(r.code).toBe('CLI_WORKER_NO_PROJECT_FOR_PATH');
|
|
40
|
+
expect(r.message).toContain('projectId=proj-42');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the GIT-mode branch of `WorkerRuntimeManager.maybeAutoPull`.
|
|
5
|
+
*
|
|
6
|
+
* Strategy:
|
|
7
|
+
* - Mock `../git-cloner` so `cloneProject()` doesn't actually spawn git.
|
|
8
|
+
* - Mock `globalThis.fetch` for the `.shogo/` SQLite top-up pass and
|
|
9
|
+
* the manifest endpoint.
|
|
10
|
+
* - Assert that:
|
|
11
|
+
* * a fresh project triggers `cloneProject()`
|
|
12
|
+
* * the `.shogo/` top-up issues a manifest+download for only those entries
|
|
13
|
+
* * a populated `.git/` dir skips re-clone
|
|
14
|
+
* * cloneProject failure flips the mode to 'files' and runs the
|
|
15
|
+
* file-transport fallback
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
19
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
// ─── git-cloner fakes injected via `autoPull.gitOps` ────────────────
|
|
24
|
+
type CloneCall = { apiUrl: string; apiKey: string; projectId: string; localDir: string; shallow?: boolean };
|
|
25
|
+
const cloneCalls: CloneCall[] = [];
|
|
26
|
+
let cloneShouldThrow = false;
|
|
27
|
+
const gitOps = {
|
|
28
|
+
cloneProject: async (opts: CloneCall) => {
|
|
29
|
+
cloneCalls.push(opts);
|
|
30
|
+
if (cloneShouldThrow) throw new Error('simulated clone failure');
|
|
31
|
+
mkdirSync(join(opts.localDir, '.git'), { recursive: true });
|
|
32
|
+
writeFileSync(join(opts.localDir, 'AGENTS.md'), 'AGENT');
|
|
33
|
+
return { commitSha: 'deadbeef' };
|
|
34
|
+
},
|
|
35
|
+
gitIsAvailable: async () => true,
|
|
36
|
+
isGitRepo: (dir: string) => existsSync(join(dir, '.git')),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ─── fetch shim ────────────────────────────────────────────────────
|
|
40
|
+
let scriptedFetch: typeof fetch | null = null;
|
|
41
|
+
const originalFetch = globalThis.fetch;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
cloneCalls.length = 0;
|
|
44
|
+
cloneShouldThrow = false;
|
|
45
|
+
globalThis.fetch = ((input: any, init?: RequestInit) => {
|
|
46
|
+
if (!scriptedFetch) throw new Error('no scripted fetch set');
|
|
47
|
+
return scriptedFetch(input, init);
|
|
48
|
+
}) as any;
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
globalThis.fetch = originalFetch;
|
|
52
|
+
scriptedFetch = null;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function scriptShogoTopup(files: Array<{ path: string; content: string }>) {
|
|
56
|
+
return (async (input: any, init?: RequestInit) => {
|
|
57
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
58
|
+
if (url.includes('/workspace/manifest')) {
|
|
59
|
+
return new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
ok: true,
|
|
62
|
+
projectId: 'p',
|
|
63
|
+
source: 's3',
|
|
64
|
+
generatedAt: '',
|
|
65
|
+
files: files.map((f) => ({ path: f.path, size: f.content.length, lastModified: null, etag: null })),
|
|
66
|
+
}),
|
|
67
|
+
{ status: 200 },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (url.includes('/s3/presign')) {
|
|
71
|
+
const body = JSON.parse((init?.body as string) || '{}') as { files: Array<{ path: string; action: string }> };
|
|
72
|
+
return new Response(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
ok: true,
|
|
75
|
+
urls: body.files.map((f) => ({ path: f.path, action: f.action, url: `https://dl.test/${encodeURIComponent(f.path)}` })),
|
|
76
|
+
}),
|
|
77
|
+
{ status: 200 },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (url.startsWith('https://dl.test/')) {
|
|
81
|
+
const path = decodeURIComponent(url.slice('https://dl.test/'.length));
|
|
82
|
+
const file = files.find((f) => f.path === path);
|
|
83
|
+
return file
|
|
84
|
+
? new Response(file.content, { status: 200 })
|
|
85
|
+
: new Response('not found', { status: 404 });
|
|
86
|
+
}
|
|
87
|
+
return new Response('unhandled', { status: 500 });
|
|
88
|
+
}) as unknown as typeof fetch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('WorkerRuntimeManager auto-pull (git mode)', () => {
|
|
92
|
+
let dir: string;
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
dir = mkdtempSync(join(tmpdir(), 'wrm-git-pull-'));
|
|
95
|
+
});
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('clones via git when git is available and the dir is empty', async () => {
|
|
101
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
102
|
+
scriptedFetch = scriptShogoTopup([]); // no .shogo entries
|
|
103
|
+
const mgr = new WorkerRuntimeManager({
|
|
104
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: true, gitOps },
|
|
105
|
+
});
|
|
106
|
+
const result = await mgr.ensurePulled('proj-g1', {
|
|
107
|
+
cloudUrl: 'https://api.test',
|
|
108
|
+
apiKey: 'shogo_sk_x',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.projectDir).toBe(join(dir, 'proj-g1'));
|
|
112
|
+
expect(cloneCalls.length).toBe(1);
|
|
113
|
+
expect(cloneCalls[0]!.projectId).toBe('proj-g1');
|
|
114
|
+
expect(cloneCalls[0]!.localDir).toBe(join(dir, 'proj-g1'));
|
|
115
|
+
expect(cloneCalls[0]!.shallow).toBe(true);
|
|
116
|
+
// The stub created AGENTS.md.
|
|
117
|
+
expect(existsSync(join(dir, 'proj-g1', 'AGENTS.md'))).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('downloads .shogo/* via the file transport after a successful git clone', async () => {
|
|
121
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
122
|
+
scriptedFetch = scriptShogoTopup([
|
|
123
|
+
{ path: '.shogo/db.sqlite', content: 'SQLITE-DB' },
|
|
124
|
+
{ path: '.shogo/snapshots/2024.sqlite', content: 'SNAP' },
|
|
125
|
+
// non-.shogo entries in the manifest must be ignored by the top-up.
|
|
126
|
+
{ path: 'src/App.tsx', content: 'APP' },
|
|
127
|
+
]);
|
|
128
|
+
const mgr = new WorkerRuntimeManager({
|
|
129
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: true, gitOps },
|
|
130
|
+
});
|
|
131
|
+
await mgr.ensurePulled('proj-g2', {
|
|
132
|
+
cloudUrl: 'https://api.test',
|
|
133
|
+
apiKey: 'shogo_sk_x',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(cloneCalls.length).toBe(1);
|
|
137
|
+
const target = join(dir, 'proj-g2');
|
|
138
|
+
// .shogo/* was downloaded via the file transport.
|
|
139
|
+
expect(existsSync(join(target, '.shogo', 'db.sqlite'))).toBe(true);
|
|
140
|
+
expect(existsSync(join(target, '.shogo', 'snapshots', '2024.sqlite'))).toBe(true);
|
|
141
|
+
// src/App.tsx was NOT downloaded — git handles that.
|
|
142
|
+
expect(existsSync(join(target, 'src', 'App.tsx'))).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('skips re-cloning when the local dir already has a .git/', async () => {
|
|
146
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
147
|
+
const target = join(dir, 'proj-g3');
|
|
148
|
+
mkdirSync(join(target, '.git'), { recursive: true });
|
|
149
|
+
writeFileSync(join(target, 'AGENTS.md'), 'existing');
|
|
150
|
+
|
|
151
|
+
scriptedFetch = scriptShogoTopup([]); // top-up still fires but is a no-op
|
|
152
|
+
const mgr = new WorkerRuntimeManager({
|
|
153
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: true, gitOps },
|
|
154
|
+
});
|
|
155
|
+
await mgr.ensurePulled('proj-g3', {
|
|
156
|
+
cloudUrl: 'https://api.test',
|
|
157
|
+
apiKey: 'shogo_sk_x',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(cloneCalls.length).toBe(0);
|
|
161
|
+
// Existing content is preserved.
|
|
162
|
+
expect(existsSync(join(target, 'AGENTS.md'))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('falls back to file transport when cloneProject throws', async () => {
|
|
166
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
167
|
+
cloneShouldThrow = true;
|
|
168
|
+
// Manifest serves a real file so the fallback downloadAll can produce something.
|
|
169
|
+
scriptedFetch = scriptShogoTopup([
|
|
170
|
+
{ path: 'fallback.txt', content: 'FROM-FILES' },
|
|
171
|
+
]);
|
|
172
|
+
const warnings: string[] = [];
|
|
173
|
+
const logger = {
|
|
174
|
+
log: () => {},
|
|
175
|
+
warn: (...args: any[]) => warnings.push(args.join(' ')),
|
|
176
|
+
error: () => {},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const mgr = new WorkerRuntimeManager({
|
|
180
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: true, logger, gitOps },
|
|
181
|
+
});
|
|
182
|
+
await mgr.ensurePulled('proj-g4', {
|
|
183
|
+
cloudUrl: 'https://api.test',
|
|
184
|
+
apiKey: 'shogo_sk_x',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(cloneCalls.length).toBe(1);
|
|
188
|
+
expect(warnings.some((w) => w.includes('git clone failed'))).toBe(true);
|
|
189
|
+
expect(existsSync(join(dir, 'proj-g4', 'fallback.txt'))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('uses file transport (not git) when useGit=false', async () => {
|
|
193
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
194
|
+
scriptedFetch = scriptShogoTopup([
|
|
195
|
+
{ path: 'plain.txt', content: 'P' },
|
|
196
|
+
]);
|
|
197
|
+
const mgr = new WorkerRuntimeManager({
|
|
198
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: false, gitOps },
|
|
199
|
+
});
|
|
200
|
+
await mgr.ensurePulled('proj-g5', {
|
|
201
|
+
cloudUrl: 'https://api.test',
|
|
202
|
+
apiKey: 'shogo_sk_x',
|
|
203
|
+
});
|
|
204
|
+
expect(cloneCalls.length).toBe(0);
|
|
205
|
+
expect(existsSync(join(dir, 'proj-g5', 'plain.txt'))).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Coverage for `WorkerRuntimeManager` idle eviction.
|
|
5
|
+
*
|
|
6
|
+
* The "chat cut mid-stream" symptom in production was: the agent-proxy
|
|
7
|
+
* never refreshed `lastUsedAt` on a long stream, so a 16-minute Opus
|
|
8
|
+
* turn looked identical to a 16-minute idle slot to the manager and
|
|
9
|
+
* `stop()` fired mid-stream. The reaper itself is correct — it's
|
|
10
|
+
* `lastUsedAt` that wasn't being kept fresh by the surrounding HTTP
|
|
11
|
+
* code.
|
|
12
|
+
*
|
|
13
|
+
* The reproduction here pins both halves of the contract:
|
|
14
|
+
*
|
|
15
|
+
* 1. With NO `touch()` calls inside the idle window, the reaper
|
|
16
|
+
* DOES fire — the slot is killed, the process is SIGTERM'd, and
|
|
17
|
+
* the runtimes map is cleared. This is the bug surface.
|
|
18
|
+
*
|
|
19
|
+
* 2. With periodic `touch()` calls (modelling the agent-proxy
|
|
20
|
+
* forwarding chunks / the AI proxy receiving tool-call requests),
|
|
21
|
+
* the reaper NEVER fires across multiple idle windows — each
|
|
22
|
+
* touch resets the timer.
|
|
23
|
+
*
|
|
24
|
+
* The remaining tests pin the desktop / `SHOGO_LOCAL_MODE=true` opt-out:
|
|
25
|
+
* `idleMs: 0` (or any non-finite value) disables the reaper without
|
|
26
|
+
* ever arming a timer, so a long chat in local mode is not bounded by
|
|
27
|
+
* the cloud's 15-minute window even if a touch hook breaks somewhere.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, expect, it } from 'bun:test';
|
|
30
|
+
import { WorkerRuntimeManager } from '../runtime-manager.ts';
|
|
31
|
+
|
|
32
|
+
interface FakeProc {
|
|
33
|
+
exitCode: number | null;
|
|
34
|
+
signalCode: NodeJS.Signals | null;
|
|
35
|
+
killed: boolean;
|
|
36
|
+
pid: number;
|
|
37
|
+
kill: (signal?: NodeJS.Signals | number) => boolean;
|
|
38
|
+
once: (event: string, cb: (...args: unknown[]) => void) => FakeProc;
|
|
39
|
+
on: (event: string, cb: (...args: unknown[]) => void) => FakeProc;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeFakeProc(): FakeProc {
|
|
43
|
+
const proc: FakeProc = {
|
|
44
|
+
// `waitForExit` short-circuits when `exitCode !== null`, so a
|
|
45
|
+
// synchronous "already exited" fake keeps stop() from blocking on
|
|
46
|
+
// an exit listener that never fires.
|
|
47
|
+
exitCode: 0,
|
|
48
|
+
signalCode: null,
|
|
49
|
+
killed: false,
|
|
50
|
+
pid: 99999,
|
|
51
|
+
kill(_signal?: NodeJS.Signals | number) {
|
|
52
|
+
proc.killed = true;
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
once(_event, _cb) {
|
|
56
|
+
return proc;
|
|
57
|
+
},
|
|
58
|
+
on(_event, _cb) {
|
|
59
|
+
return proc;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
return proc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function insertRunningSlot(mgr: WorkerRuntimeManager, projectId: string) {
|
|
66
|
+
const proc = makeFakeProc();
|
|
67
|
+
const slot = {
|
|
68
|
+
projectId,
|
|
69
|
+
// 0 short-circuits releasePort and avoids polluting usedPorts.
|
|
70
|
+
agentPort: 0,
|
|
71
|
+
apiServerPort: 0,
|
|
72
|
+
status: 'running' as const,
|
|
73
|
+
proc,
|
|
74
|
+
startedAt: Date.now(),
|
|
75
|
+
lastUsedAt: Date.now(),
|
|
76
|
+
restarts: 0,
|
|
77
|
+
restartTimer: null,
|
|
78
|
+
idleTimer: null,
|
|
79
|
+
spawnConfig: {} as never,
|
|
80
|
+
startPromise: null,
|
|
81
|
+
};
|
|
82
|
+
// The runtimes map and armIdleTimer are private; tests reach in
|
|
83
|
+
// deliberately because the public surface (ensureRunning) requires a
|
|
84
|
+
// real binary on disk.
|
|
85
|
+
(mgr as unknown as { runtimes: Map<string, typeof slot> }).runtimes.set(
|
|
86
|
+
projectId,
|
|
87
|
+
slot,
|
|
88
|
+
);
|
|
89
|
+
return slot;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function armIdle(mgr: WorkerRuntimeManager, slot: ReturnType<typeof insertRunningSlot>): void {
|
|
93
|
+
(mgr as unknown as { armIdleTimer: (s: typeof slot) => void }).armIdleTimer(slot);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SILENT = { log: () => {}, warn: () => {}, error: () => {} } as const;
|
|
97
|
+
|
|
98
|
+
describe('WorkerRuntimeManager idle eviction', () => {
|
|
99
|
+
it('reproduction: with no touches inside the idle window, the reaper kills the runtime (mid-stream cut)', async () => {
|
|
100
|
+
// 50ms idle window stands in for the production 15min — what we
|
|
101
|
+
// want to pin is "no `touch()` for >= idleMs ⇒ stop() fires", not
|
|
102
|
+
// the magnitude.
|
|
103
|
+
const mgr = new WorkerRuntimeManager({ idleMs: 50, logger: SILENT });
|
|
104
|
+
const slot = insertRunningSlot(mgr, 'proj-stream');
|
|
105
|
+
|
|
106
|
+
armIdle(mgr, slot);
|
|
107
|
+
expect(slot.idleTimer).not.toBeNull();
|
|
108
|
+
|
|
109
|
+
// Simulate an agent-proxy that forwards bytes for >idleMs but
|
|
110
|
+
// never calls `touch()` — exactly the production bug. The
|
|
111
|
+
// manager has no notion of in-flight HTTP, so it evicts.
|
|
112
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
113
|
+
|
|
114
|
+
const runtimes = (mgr as unknown as { runtimes: Map<string, unknown> }).runtimes;
|
|
115
|
+
expect(runtimes.has('proj-stream')).toBe(false);
|
|
116
|
+
expect(slot.proc.killed).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('fix: periodic touch() inside the idle window keeps the runtime alive across multiple windows', async () => {
|
|
120
|
+
const idleMs = 50;
|
|
121
|
+
const mgr = new WorkerRuntimeManager({ idleMs, logger: SILENT });
|
|
122
|
+
const slot = insertRunningSlot(mgr, 'proj-active');
|
|
123
|
+
|
|
124
|
+
armIdle(mgr, slot);
|
|
125
|
+
|
|
126
|
+
// Models the activity hooks the API server will install:
|
|
127
|
+
// - agent-proxy `touch(projectId)` on each forwarded SSE chunk,
|
|
128
|
+
// - AI proxy `touch(projectId)` after token decode.
|
|
129
|
+
// We tick every (idleMs / 5) for ~3x the idle window so the
|
|
130
|
+
// assertion lands well past where an unfixed reaper would fire.
|
|
131
|
+
const touchInterval = setInterval(() => mgr.touch('proj-active'), Math.max(5, idleMs / 5));
|
|
132
|
+
await new Promise((r) => setTimeout(r, idleMs * 3));
|
|
133
|
+
clearInterval(touchInterval);
|
|
134
|
+
|
|
135
|
+
const runtimes = (mgr as unknown as { runtimes: Map<string, unknown> }).runtimes;
|
|
136
|
+
expect(runtimes.has('proj-active')).toBe(true);
|
|
137
|
+
expect(slot.proc.killed).toBe(false);
|
|
138
|
+
|
|
139
|
+
// Sanity: once activity stops, the reaper does fire — the slot is
|
|
140
|
+
// not somehow "stuck alive". This pins the resume-eviction-on-idle
|
|
141
|
+
// half of the contract so a future regression that disables the
|
|
142
|
+
// reaper outright still gets caught.
|
|
143
|
+
await new Promise((r) => setTimeout(r, idleMs * 3));
|
|
144
|
+
expect(runtimes.has('proj-active')).toBe(false);
|
|
145
|
+
expect(slot.proc.killed).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('touch() on an unknown projectId is a safe no-op', () => {
|
|
149
|
+
const mgr = new WorkerRuntimeManager({ idleMs: 50, logger: SILENT });
|
|
150
|
+
expect(() => mgr.touch('does-not-exist')).not.toThrow();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('idleMs=0 disables the reaper (no timer is armed) — desktop opt-out', async () => {
|
|
154
|
+
const mgr = new WorkerRuntimeManager({ idleMs: 0, logger: SILENT });
|
|
155
|
+
const slot = insertRunningSlot(mgr, 'proj-local');
|
|
156
|
+
|
|
157
|
+
armIdle(mgr, slot);
|
|
158
|
+
expect(slot.idleTimer).toBeNull();
|
|
159
|
+
|
|
160
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
161
|
+
const runtimes = (mgr as unknown as { runtimes: Map<string, unknown> }).runtimes;
|
|
162
|
+
expect(runtimes.has('proj-local')).toBe(true);
|
|
163
|
+
expect(slot.proc.killed).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('idleMs=Infinity also disables the reaper', () => {
|
|
167
|
+
const mgr = new WorkerRuntimeManager({
|
|
168
|
+
idleMs: Number.POSITIVE_INFINITY,
|
|
169
|
+
logger: SILENT,
|
|
170
|
+
});
|
|
171
|
+
const slot = insertRunningSlot(mgr, 'proj-inf');
|
|
172
|
+
|
|
173
|
+
armIdle(mgr, slot);
|
|
174
|
+
expect(slot.idleTimer).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('negative idleMs disables the reaper (defensive)', () => {
|
|
178
|
+
const mgr = new WorkerRuntimeManager({ idleMs: -1, logger: SILENT });
|
|
179
|
+
const slot = insertRunningSlot(mgr, 'proj-neg');
|
|
180
|
+
|
|
181
|
+
armIdle(mgr, slot);
|
|
182
|
+
expect(slot.idleTimer).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('idleMs > 0 still arms a timer (cloud regression)', () => {
|
|
186
|
+
const mgr = new WorkerRuntimeManager({ idleMs: 1_000, logger: SILENT });
|
|
187
|
+
const slot = insertRunningSlot(mgr, 'proj-cloud');
|
|
188
|
+
|
|
189
|
+
armIdle(mgr, slot);
|
|
190
|
+
expect(slot.idleTimer).not.toBeNull();
|
|
191
|
+
if (slot.idleTimer) {
|
|
192
|
+
clearTimeout(slot.idleTimer);
|
|
193
|
+
slot.idleTimer = null;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|