@shogo-ai/worker 2.3.8 → 2.3.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/dist/cli.mjs +29 -27
- package/package.json +2 -1
- package/src/lib/__tests__/runtime-activity.test.ts +152 -0
- package/src/lib/__tests__/runtime-manager-active-work.test.ts +15 -3
- package/src/lib/__tests__/runtime-manager-busy-guard.test.ts +363 -0
- package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +8 -1
- package/src/lib/__tests__/runtime-manager-on-runtime-gone.test.ts +7 -1
- package/src/lib/__tests__/runtime-manager-vite-watch.e2e.test.ts +55 -1
- package/src/lib/runtime-activity.ts +218 -0
- package/src/lib/runtime-manager.ts +300 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shogo-ai/worker",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.10",
|
|
4
4
|
"description": "Shogo Cloud Agent Worker — run Shogo agents on your own machine (laptop, devbox, CI).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shogo Technologies, Inc.",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
".": "./src/cli.ts",
|
|
20
20
|
"./tunnel": "./src/lib/tunnel.ts",
|
|
21
21
|
"./runtime-manager": "./src/lib/runtime-manager.ts",
|
|
22
|
+
"./runtime-activity": "./src/lib/runtime-activity.ts",
|
|
22
23
|
"./resource-limits": "./src/lib/resource-limits.ts",
|
|
23
24
|
"./cloud-sync-watcher": "./src/lib/cloud-sync-watcher.ts",
|
|
24
25
|
"./git-cloner": "./src/lib/git-cloner.ts",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* The eviction verdict, behaviour-first (SHO-143).
|
|
5
|
+
*
|
|
6
|
+
* The rule these pin: a runtime is only evicted when the host can PROVE it is
|
|
7
|
+
* idle. Anything unverifiable — no sample, a failed probe, a sample older than
|
|
8
|
+
* the staleness window, a runtime still starting — keeps it. A Windows desktop
|
|
9
|
+
* evicted a background runtime 146 times in 24h with 0 keeps; every one of
|
|
10
|
+
* those came from "could not tell" being read as "idle".
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from 'bun:test';
|
|
13
|
+
import {
|
|
14
|
+
ACTIVITY_STALE_MS,
|
|
15
|
+
DEAD_PROBE_MS,
|
|
16
|
+
STARTUP_GRACE_MS,
|
|
17
|
+
decideBusy,
|
|
18
|
+
type ActivitySample,
|
|
19
|
+
} from '../runtime-activity.ts';
|
|
20
|
+
|
|
21
|
+
const NOW = 1_800_000_000_000;
|
|
22
|
+
const OLD_START = NOW - 30 * 60_000;
|
|
23
|
+
|
|
24
|
+
function sample(over: Partial<ActivitySample> = {}, counts = { turns: 0, tasks: 0, streams: 0, children: 0 }): ActivitySample {
|
|
25
|
+
return { counts, at: NOW - 5_000, ok: true, ...over };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('decideBusy — unknown is busy', () => {
|
|
29
|
+
it('keeps a runtime with no sample yet', () => {
|
|
30
|
+
const d = decideBusy({ now: NOW, startedAt: OLD_START, status: 'running', sample: null });
|
|
31
|
+
expect(d.busy).toBe(true);
|
|
32
|
+
expect(d.dead).toBe(false);
|
|
33
|
+
expect(d.reason).toBe('unknown');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('keeps a runtime whose last probe failed', () => {
|
|
37
|
+
const d = decideBusy({
|
|
38
|
+
now: NOW,
|
|
39
|
+
startedAt: OLD_START,
|
|
40
|
+
status: 'running',
|
|
41
|
+
sample: sample({ ok: false, error: 'no answer', failingSince: NOW - 5_000 }),
|
|
42
|
+
});
|
|
43
|
+
expect(d.busy).toBe(true);
|
|
44
|
+
expect(d.reason).toBe('unknown');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('keeps a runtime whose sample has aged out of the staleness window', () => {
|
|
48
|
+
const d = decideBusy({
|
|
49
|
+
now: NOW,
|
|
50
|
+
startedAt: OLD_START,
|
|
51
|
+
status: 'running',
|
|
52
|
+
sample: sample({ at: NOW - ACTIVITY_STALE_MS - 1_000 }),
|
|
53
|
+
});
|
|
54
|
+
expect(d.busy).toBe(true);
|
|
55
|
+
expect(d.reason).toBe('stale');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('treats a status it cannot vouch for as busy, not idle', () => {
|
|
59
|
+
const d = decideBusy({ now: NOW, startedAt: OLD_START, status: 'stopping', sample: sample() });
|
|
60
|
+
expect(d.busy).toBe(true);
|
|
61
|
+
expect(d.reason).toBe('unknown');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('decideBusy — real work is busy', () => {
|
|
66
|
+
it.each([
|
|
67
|
+
['turns', { turns: 1, tasks: 0, streams: 0, children: 0 }],
|
|
68
|
+
['tasks', { turns: 0, tasks: 2, streams: 0, children: 0 }],
|
|
69
|
+
['streams', { turns: 0, tasks: 0, streams: 1, children: 0 }],
|
|
70
|
+
['children', { turns: 0, tasks: 0, streams: 0, children: 1 }],
|
|
71
|
+
])('keeps a runtime with %s in flight', (_label, counts) => {
|
|
72
|
+
const d = decideBusy({ now: NOW, startedAt: OLD_START, status: 'running', sample: sample({}, counts) });
|
|
73
|
+
expect(d.busy).toBe(true);
|
|
74
|
+
expect(d.reason).toBe('work');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps a runtime with an open terminal even when everything else is idle', () => {
|
|
78
|
+
const d = decideBusy({ now: NOW, startedAt: OLD_START, status: 'running', sample: sample(), terminalSessions: 1 });
|
|
79
|
+
expect(d.busy).toBe(true);
|
|
80
|
+
expect(d.reason).toBe('terminal');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps a runtime with a project call in flight', () => {
|
|
84
|
+
const d = decideBusy({ now: NOW, startedAt: OLD_START, status: 'running', sample: sample(), inflightCalls: 1 });
|
|
85
|
+
expect(d.busy).toBe(true);
|
|
86
|
+
expect(d.reason).toBe('inflight-call');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps a runtime that is still starting, whatever the sample says', () => {
|
|
90
|
+
const d = decideBusy({ now: NOW, startedAt: NOW - 1_000, status: 'starting', sample: sample() });
|
|
91
|
+
expect(d.busy).toBe(true);
|
|
92
|
+
expect(d.reason).toBe('grace');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('keeps a freshly started runtime for the whole startup grace', () => {
|
|
96
|
+
const d = decideBusy({
|
|
97
|
+
now: NOW,
|
|
98
|
+
startedAt: NOW - (STARTUP_GRACE_MS - 1_000),
|
|
99
|
+
status: 'running',
|
|
100
|
+
sample: sample(),
|
|
101
|
+
});
|
|
102
|
+
expect(d.busy).toBe(true);
|
|
103
|
+
expect(d.reason).toBe('grace');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('counts the log line for a busy runtime', () => {
|
|
107
|
+
const d = decideBusy({
|
|
108
|
+
now: NOW,
|
|
109
|
+
startedAt: OLD_START,
|
|
110
|
+
status: 'running',
|
|
111
|
+
sample: sample({}, { turns: 0, tasks: 2, streams: 0, children: 1 }),
|
|
112
|
+
});
|
|
113
|
+
expect(d.countsText).toBe('turns=0 tasks=2 streams=0 children=1');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('decideBusy — only proof of death reaps', () => {
|
|
118
|
+
it('calls an exited process dead, not busy', () => {
|
|
119
|
+
const d = decideBusy({ now: NOW, startedAt: OLD_START, status: 'running', processExited: true });
|
|
120
|
+
expect(d.dead).toBe(true);
|
|
121
|
+
expect(d.busy).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps a runtime that refuses connections briefly, then calls it dead', () => {
|
|
125
|
+
const refusedJustNow = decideBusy({
|
|
126
|
+
now: NOW,
|
|
127
|
+
startedAt: OLD_START,
|
|
128
|
+
status: 'running',
|
|
129
|
+
sample: sample({ ok: false, failingSince: NOW - 10_000 }),
|
|
130
|
+
});
|
|
131
|
+
expect(refusedJustNow.busy).toBe(true);
|
|
132
|
+
expect(refusedJustNow.dead).toBe(false);
|
|
133
|
+
|
|
134
|
+
const refusedForAges = decideBusy({
|
|
135
|
+
now: NOW,
|
|
136
|
+
startedAt: OLD_START,
|
|
137
|
+
status: 'running',
|
|
138
|
+
sample: sample({ ok: false, failingSince: NOW - DEAD_PROBE_MS - 1_000 }),
|
|
139
|
+
});
|
|
140
|
+
expect(refusedForAges.dead).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('decideBusy — idle is the only evictable answer', () => {
|
|
145
|
+
it('reports idle with zero counts and a fresh sample', () => {
|
|
146
|
+
const d = decideBusy({ now: NOW, startedAt: OLD_START, status: 'running', sample: sample() });
|
|
147
|
+
expect(d.busy).toBe(false);
|
|
148
|
+
expect(d.dead).toBe(false);
|
|
149
|
+
expect(d.reason).toBe('idle');
|
|
150
|
+
expect(d.countsText).toBe('turns=0 tasks=0 streams=0 children=0');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -54,7 +54,10 @@ function insertSlot(mgr: WorkerRuntimeManager, projectId: string, extra: Record<
|
|
|
54
54
|
status: 'running',
|
|
55
55
|
proc: fakeProc(),
|
|
56
56
|
pid: null,
|
|
57
|
-
|
|
57
|
+
// Ten minutes up, i.e. past the 2-minute startup grace: a runtime that just
|
|
58
|
+
// started is never an eviction victim (SHO-143), and these tests are about
|
|
59
|
+
// what happens once it is settled.
|
|
60
|
+
startedAt: Date.now() - 10 * 60_000,
|
|
58
61
|
lastUsedAt: Date.now(),
|
|
59
62
|
restarts: 0,
|
|
60
63
|
consecutiveFailures: 0,
|
|
@@ -125,14 +128,23 @@ describe('WorkerRuntimeManager active-work gating', () => {
|
|
|
125
128
|
expect(slot.status).toBe('running');
|
|
126
129
|
expect(slot.idleTimer).not.toBeNull();
|
|
127
130
|
|
|
128
|
-
|
|
131
|
+
// A current runtime always reports its counts; zeros are what "idle" means.
|
|
132
|
+
activity = { activeTurns: 0, activeTasks: 0, activeStreams: 0 };
|
|
133
|
+
// The reaper decides from the cached sample and only re-asks once that
|
|
134
|
+
// sample has aged out (SHO-143: never probe because we just decided to
|
|
135
|
+
// kill). Age it the way the sampler would on its next tick.
|
|
136
|
+
;(slot as unknown as { activity?: { at: number } }).activity = {
|
|
137
|
+
counts: { turns: 1, tasks: 0, streams: 0, children: 0 },
|
|
138
|
+
at: Date.now() - 60_000,
|
|
139
|
+
ok: true,
|
|
140
|
+
};
|
|
129
141
|
await sleep(200);
|
|
130
142
|
expect(gone).toEqual(['busy']);
|
|
131
143
|
});
|
|
132
144
|
|
|
133
145
|
it('LRU cap skips busy runtimes and evicts the idle one', async () => {
|
|
134
146
|
const busy = activityServer(() => ({ activeTasks: 1 }));
|
|
135
|
-
const idle = activityServer(() => ({}));
|
|
147
|
+
const idle = activityServer(() => ({ activeTurns: 0, activeTasks: 0, activeStreams: 0 }));
|
|
136
148
|
const gone: string[] = [];
|
|
137
149
|
const mgr = new WorkerRuntimeManager({
|
|
138
150
|
logger: SILENT,
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* The cap must never be paid for with someone's work (SHO-143).
|
|
5
|
+
*
|
|
6
|
+
* Before this, the LRU asked the runtime "are you busy?" with a 3-second HTTP
|
|
7
|
+
* probe at the exact moment it wanted to kill it, and read a timeout as "no".
|
|
8
|
+
* On a host busy with builds that meant every victim looked idle: 146
|
|
9
|
+
* background evictions in 24h, 0 keeps. These tests make the probe hang, fail
|
|
10
|
+
* and lie, and require the runtime to survive each one.
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
13
|
+
import { WorkerRuntimeManager } from '../runtime-manager.ts';
|
|
14
|
+
import { ZERO_COUNTS } from '../runtime-activity.ts';
|
|
15
|
+
|
|
16
|
+
type Line = string;
|
|
17
|
+
const servers: Array<{ stop: (force?: boolean) => void }> = [];
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
for (const s of servers.splice(0)) s.stop(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function logs() {
|
|
23
|
+
const lines: Line[] = [];
|
|
24
|
+
const logger = {
|
|
25
|
+
log: (m: string) => lines.push(m),
|
|
26
|
+
warn: (m: string) => lines.push(m),
|
|
27
|
+
error: (m: string) => lines.push(m),
|
|
28
|
+
};
|
|
29
|
+
return { lines, logger };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fakeProc() {
|
|
33
|
+
const proc = {
|
|
34
|
+
exitCode: null as number | null,
|
|
35
|
+
signalCode: null,
|
|
36
|
+
killed: false,
|
|
37
|
+
pid: 4242,
|
|
38
|
+
kill() {
|
|
39
|
+
proc.killed = true;
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
42
|
+
once() {
|
|
43
|
+
return proc;
|
|
44
|
+
},
|
|
45
|
+
on() {
|
|
46
|
+
return proc;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
return proc;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function insertSlot(mgr: WorkerRuntimeManager, projectId: string, extra: Record<string, unknown> = {}) {
|
|
53
|
+
const slot = {
|
|
54
|
+
projectId,
|
|
55
|
+
agentPort: 0,
|
|
56
|
+
apiServerPort: 0,
|
|
57
|
+
status: 'running',
|
|
58
|
+
proc: fakeProc(),
|
|
59
|
+
pid: 4242,
|
|
60
|
+
startedAt: Date.now() - 10 * 60_000,
|
|
61
|
+
lastUsedAt: Date.now() - 10 * 60_000,
|
|
62
|
+
restarts: 0,
|
|
63
|
+
consecutiveFailures: 0,
|
|
64
|
+
lastFailureAt: 0,
|
|
65
|
+
restartTimestamps: [] as number[],
|
|
66
|
+
breakerTrips: 0,
|
|
67
|
+
restartTimer: null as ReturnType<typeof setTimeout> | null,
|
|
68
|
+
idleTimer: null as ReturnType<typeof setTimeout> | null,
|
|
69
|
+
graceTimer: null,
|
|
70
|
+
limitWatchdog: null,
|
|
71
|
+
spawnConfig: {} as never,
|
|
72
|
+
startPromise: null,
|
|
73
|
+
runtimeToken: 'tok',
|
|
74
|
+
...extra,
|
|
75
|
+
};
|
|
76
|
+
(mgr as unknown as { runtimes: Map<string, typeof slot> }).runtimes.set(projectId, slot);
|
|
77
|
+
return slot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An activity endpoint that answers correctly, counts calls, or hangs. */
|
|
81
|
+
function activityServer(opts: {
|
|
82
|
+
response?: () => Record<string, unknown>;
|
|
83
|
+
hangMs?: number;
|
|
84
|
+
status?: number;
|
|
85
|
+
token?: string;
|
|
86
|
+
}) {
|
|
87
|
+
const seen: number[] = [];
|
|
88
|
+
const server = Bun.serve({
|
|
89
|
+
port: 0,
|
|
90
|
+
hostname: '127.0.0.1',
|
|
91
|
+
async fetch(req) {
|
|
92
|
+
seen.push(Date.now());
|
|
93
|
+
if ((req.headers.get('x-runtime-token') ?? '') !== (opts.token ?? 'tok')) {
|
|
94
|
+
return new Response('unauthorized', { status: 401 });
|
|
95
|
+
}
|
|
96
|
+
if (opts.hangMs) await new Promise((r) => setTimeout(r, opts.hangMs));
|
|
97
|
+
if (opts.status && opts.status >= 400) return new Response('boom', { status: opts.status });
|
|
98
|
+
return Response.json({
|
|
99
|
+
idleSeconds: 0,
|
|
100
|
+
activeSessions: 1,
|
|
101
|
+
activeTurns: 0,
|
|
102
|
+
activeTasks: 0,
|
|
103
|
+
activeStreams: 0,
|
|
104
|
+
activeChildren: 0,
|
|
105
|
+
...(opts.response?.() ?? {}),
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
servers.push(server);
|
|
110
|
+
return { port: server.port as number, seen };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
114
|
+
|
|
115
|
+
describe('WorkerRuntimeManager — a busy runtime is never the LRU victim', () => {
|
|
116
|
+
it('does not evict a runtime whose activity probe hangs (the Samsara case)', async () => {
|
|
117
|
+
// The probe never answers inside the 3s budget, exactly like a host
|
|
118
|
+
// saturated by Unreal builds. The runtime is running a task.
|
|
119
|
+
const hung = activityServer({ hangMs: 6_000 });
|
|
120
|
+
const idle = activityServer({ response: () => ({}) });
|
|
121
|
+
const { lines, logger } = logs();
|
|
122
|
+
const gone: string[] = [];
|
|
123
|
+
const mgr = new WorkerRuntimeManager({ logger, maxRuntimes: 1, onRuntimeGone: (id) => gone.push(id) });
|
|
124
|
+
|
|
125
|
+
insertSlot(mgr, 'busy-hung-probe', { agentPort: hung.port, lastUsedAt: Date.now() - 20 * 60_000 });
|
|
126
|
+
insertSlot(mgr, 'settled-idle', { agentPort: idle.port, lastUsedAt: Date.now() - 10 * 60_000 });
|
|
127
|
+
insertSlot(mgr, 'just-started');
|
|
128
|
+
|
|
129
|
+
(mgr as unknown as { enforceMaxRuntimes: (k: string) => void }).enforceMaxRuntimes('just-started');
|
|
130
|
+
// The pass samples every candidate in parallel, so it settles when the
|
|
131
|
+
// hung probe hits its 3s budget.
|
|
132
|
+
await sleep(3_800);
|
|
133
|
+
|
|
134
|
+
// The runtime we could not question is kept; the one we could question and
|
|
135
|
+
// that answered "nothing running" is the one that goes.
|
|
136
|
+
expect(gone).toEqual(['settled-idle']);
|
|
137
|
+
expect(lines.some((l) => l.includes('keeping busy-hung-probe') && l.includes('unknown'))).toBe(true);
|
|
138
|
+
expect(lines.some((l) => l.includes('LRU-evicting settled-idle') && l.includes('turns=0'))).toBe(true);
|
|
139
|
+
}, 15_000);
|
|
140
|
+
|
|
141
|
+
it('does not evict a runtime whose activity endpoint reports no task counts (older runtime)', async () => {
|
|
142
|
+
// An older runtime answers liveness only. Zeros would make it look idle, so
|
|
143
|
+
// the answer is treated as unknown and the runtime is kept.
|
|
144
|
+
const legacy = activityServer({
|
|
145
|
+
response: () => ({
|
|
146
|
+
activeTurns: undefined,
|
|
147
|
+
activeTasks: undefined,
|
|
148
|
+
activeStreams: undefined,
|
|
149
|
+
activeChildren: undefined,
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
const { lines, logger } = logs();
|
|
153
|
+
const gone: string[] = [];
|
|
154
|
+
const mgr = new WorkerRuntimeManager({ logger, maxRuntimes: 1, onRuntimeGone: (id) => gone.push(id) });
|
|
155
|
+
insertSlot(mgr, 'legacy', { agentPort: legacy.port, lastUsedAt: Date.now() - 20 * 60_000 });
|
|
156
|
+
insertSlot(mgr, 'new');
|
|
157
|
+
|
|
158
|
+
(mgr as unknown as { enforceMaxRuntimes: (k: string) => void }).enforceMaxRuntimes('new');
|
|
159
|
+
await sleep(300);
|
|
160
|
+
|
|
161
|
+
expect(gone).toEqual([]);
|
|
162
|
+
expect(lines.some((l) => l.includes('keeping legacy') && l.includes('unknown'))).toBe(true);
|
|
163
|
+
}, 15_000);
|
|
164
|
+
|
|
165
|
+
it('does not evict a runtime whose activity endpoint answers 500', async () => {
|
|
166
|
+
const broken = activityServer({ status: 500 });
|
|
167
|
+
const { logger } = logs();
|
|
168
|
+
const gone: string[] = [];
|
|
169
|
+
const mgr = new WorkerRuntimeManager({ logger, maxRuntimes: 1, onRuntimeGone: (id) => gone.push(id) });
|
|
170
|
+
insertSlot(mgr, 'broken', { agentPort: broken.port, lastUsedAt: Date.now() - 20 * 60_000 });
|
|
171
|
+
insertSlot(mgr, 'new');
|
|
172
|
+
|
|
173
|
+
(mgr as unknown as { enforceMaxRuntimes: (k: string) => void }).enforceMaxRuntimes('new');
|
|
174
|
+
await sleep(300);
|
|
175
|
+
|
|
176
|
+
expect(gone).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('keeps a runtime that is within its startup grace, before any sample exists', async () => {
|
|
180
|
+
const { logger } = logs();
|
|
181
|
+
const gone: string[] = [];
|
|
182
|
+
const mgr = new WorkerRuntimeManager({ logger, onRuntimeGone: (id) => gone.push(id) });
|
|
183
|
+
const slot = insertSlot(mgr, 'booting', { status: 'starting', startedAt: Date.now(), agentPort: 0 });
|
|
184
|
+
|
|
185
|
+
const decision = mgr.activityDecision('booting');
|
|
186
|
+
expect(decision.busy).toBe(true);
|
|
187
|
+
expect(decision.reason).toBe('grace');
|
|
188
|
+
expect(gone).toEqual([]);
|
|
189
|
+
expect(slot.status).toBe('starting');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('keeps a runtime mid-turn — the regression for 146 evictions / 0 keeps', async () => {
|
|
193
|
+
const midTurn = activityServer({ response: () => ({ activeTurns: 1 }) });
|
|
194
|
+
const { logger } = logs();
|
|
195
|
+
const gone: string[] = [];
|
|
196
|
+
const mgr = new WorkerRuntimeManager({ logger, maxRuntimes: 1, onRuntimeGone: (id) => gone.push(id) });
|
|
197
|
+
insertSlot(mgr, 'mid-turn', { agentPort: midTurn.port, lastUsedAt: Date.now() - 20 * 60_000 });
|
|
198
|
+
insertSlot(mgr, 'new');
|
|
199
|
+
|
|
200
|
+
(mgr as unknown as { enforceMaxRuntimes: (k: string) => void }).enforceMaxRuntimes('new');
|
|
201
|
+
await sleep(300);
|
|
202
|
+
|
|
203
|
+
expect(gone).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('WorkerRuntimeManager — the decision reads a cached sample', () => {
|
|
208
|
+
it('sampling on the timer, not at eviction time, is what the verdict uses', async () => {
|
|
209
|
+
const server = activityServer({ response: () => ({ activeTasks: 1, activeChildren: 1 }) });
|
|
210
|
+
const { logger } = logs();
|
|
211
|
+
const mgr = new WorkerRuntimeManager({ logger });
|
|
212
|
+
const slot = insertSlot(mgr, 'p', { agentPort: server.port });
|
|
213
|
+
|
|
214
|
+
await mgr.sampleAllActivity();
|
|
215
|
+
const afterSample = server.seen.length;
|
|
216
|
+
|
|
217
|
+
const decision = mgr.activityDecision('p');
|
|
218
|
+
expect(decision.busy).toBe(true);
|
|
219
|
+
expect(decision.reason).toBe('work');
|
|
220
|
+
expect(decision.countsText).toBe('turns=0 tasks=1 streams=0 children=1');
|
|
221
|
+
// No probe at decision time at all.
|
|
222
|
+
expect(server.seen.length).toBe(afterSample);
|
|
223
|
+
expect(slot.activity?.ok).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('records a failed sample as unknown rather than as zero work', async () => {
|
|
227
|
+
const server = activityServer({ hangMs: 5_000 });
|
|
228
|
+
const { logger } = logs();
|
|
229
|
+
const mgr = new WorkerRuntimeManager({ logger });
|
|
230
|
+
const slot = insertSlot(mgr, 'p', { agentPort: server.port });
|
|
231
|
+
|
|
232
|
+
await mgr.sampleActivity(slot);
|
|
233
|
+
|
|
234
|
+
expect(slot.activity?.ok).toBe(false);
|
|
235
|
+
expect(mgr.activityDecision('p').busy).toBe(true);
|
|
236
|
+
expect(mgr.activityDecision('p').reason).toBe('unknown');
|
|
237
|
+
}, 10_000);
|
|
238
|
+
|
|
239
|
+
it('reaps a runtime whose process is gone as dead, not as an eviction', async () => {
|
|
240
|
+
const { logger } = logs();
|
|
241
|
+
const stopped: string[] = [];
|
|
242
|
+
const mgr = new WorkerRuntimeManager({ logger, maxRuntimes: 1 });
|
|
243
|
+
insertSlot(mgr, 'dead', { proc: null, status: 'running' });
|
|
244
|
+
insertSlot(mgr, 'new');
|
|
245
|
+
|
|
246
|
+
const decision = mgr.activityDecision('dead');
|
|
247
|
+
expect(decision.dead).toBe(true);
|
|
248
|
+
|
|
249
|
+
const originalStop = (mgr as unknown as { stop: (id: string, signal?: string, reason?: string) => Promise<void> }).stop;
|
|
250
|
+
(mgr as unknown as { stop: (id: string, signal?: string, reason?: string) => Promise<void> }).stop = async (
|
|
251
|
+
id: string,
|
|
252
|
+
_signal?: string,
|
|
253
|
+
reason?: string,
|
|
254
|
+
) => {
|
|
255
|
+
stopped.push(`${id}:${reason}`);
|
|
256
|
+
void originalStop;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
(mgr as unknown as { enforceMaxRuntimes: (k: string) => void }).enforceMaxRuntimes('new');
|
|
260
|
+
await sleep(200);
|
|
261
|
+
|
|
262
|
+
expect(stopped).toEqual(['dead:dead']);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('WorkerRuntimeManager — a stale verdict is refreshed before it kills anything', () => {
|
|
267
|
+
it('re-probes a runtime whose cached "idle" aged out, and keeps it when the turn has started since', async () => {
|
|
268
|
+
// The timer sampled `turns=0` a minute ago; a turn started inside that gap.
|
|
269
|
+
// Evicting on the stale verdict is exactly the SHO-143 failure.
|
|
270
|
+
const server = activityServer({ response: () => ({ activeTurns: 1 }) });
|
|
271
|
+
const { lines, logger } = logs();
|
|
272
|
+
const gone: string[] = [];
|
|
273
|
+
const mgr = new WorkerRuntimeManager({ logger, maxRuntimes: 1, onRuntimeGone: (id) => gone.push(id) });
|
|
274
|
+
insertSlot(mgr, 'stale-idle', {
|
|
275
|
+
agentPort: server.port,
|
|
276
|
+
lastUsedAt: Date.now() - 20 * 60_000,
|
|
277
|
+
activity: { counts: { ...ZERO_COUNTS }, at: Date.now() - 60_000, ok: true },
|
|
278
|
+
});
|
|
279
|
+
insertSlot(mgr, 'new');
|
|
280
|
+
|
|
281
|
+
(mgr as unknown as { enforceMaxRuntimes: (k: string) => void }).enforceMaxRuntimes('new');
|
|
282
|
+
await sleep(400);
|
|
283
|
+
|
|
284
|
+
expect(gone).toEqual([]);
|
|
285
|
+
expect(server.seen.length).toBe(1);
|
|
286
|
+
expect(lines.some((l) => l.includes('keeping stale-idle') && l.includes('turns=1'))).toBe(true);
|
|
287
|
+
}, 15_000);
|
|
288
|
+
|
|
289
|
+
it('does not probe a runtime whose verdict is fresh, so eviction never waits on the wire', async () => {
|
|
290
|
+
const server = activityServer({ response: () => ({}) });
|
|
291
|
+
const { logger } = logs();
|
|
292
|
+
const gone: string[] = [];
|
|
293
|
+
const mgr = new WorkerRuntimeManager({ logger, maxRuntimes: 1, onRuntimeGone: (id) => gone.push(id) });
|
|
294
|
+
insertSlot(mgr, 'fresh-idle', {
|
|
295
|
+
agentPort: server.port,
|
|
296
|
+
lastUsedAt: Date.now() - 20 * 60_000,
|
|
297
|
+
activity: { counts: { ...ZERO_COUNTS }, at: Date.now(), ok: true },
|
|
298
|
+
});
|
|
299
|
+
insertSlot(mgr, 'new');
|
|
300
|
+
|
|
301
|
+
(mgr as unknown as { enforceMaxRuntimes: (k: string) => void }).enforceMaxRuntimes('new');
|
|
302
|
+
await sleep(200);
|
|
303
|
+
|
|
304
|
+
expect(gone).toEqual(['fresh-idle']);
|
|
305
|
+
expect(server.seen.length).toBe(0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('refreshActivity re-reads only when the cached sample is stale', async () => {
|
|
309
|
+
const server = activityServer({ response: () => ({ activeTurns: 1 }) });
|
|
310
|
+
const { logger } = logs();
|
|
311
|
+
const mgr = new WorkerRuntimeManager({ logger });
|
|
312
|
+
const slot = insertSlot(mgr, 'p', {
|
|
313
|
+
agentPort: server.port,
|
|
314
|
+
activity: { counts: { ...ZERO_COUNTS }, at: Date.now(), ok: true },
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await mgr.refreshActivity('p');
|
|
318
|
+
expect(server.seen.length).toBe(0);
|
|
319
|
+
|
|
320
|
+
slot.activity = { ...(slot.activity as { counts: typeof ZERO_COUNTS; ok: boolean }), at: Date.now() - 60_000 };
|
|
321
|
+
await mgr.refreshActivity('p');
|
|
322
|
+
|
|
323
|
+
expect(server.seen.length).toBe(1);
|
|
324
|
+
expect(mgr.activityDecision('p').reason).toBe('work');
|
|
325
|
+
}, 15_000);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('WorkerRuntimeManager — unexplained exits say why', () => {
|
|
329
|
+
it('logs the exit code, the uptime, the last activity and the stderr tail', async () => {
|
|
330
|
+
const { lines, logger } = logs();
|
|
331
|
+
const mgr = new WorkerRuntimeManager({ logger });
|
|
332
|
+
const slot = insertSlot(mgr, 'crasher');
|
|
333
|
+
(slot as unknown as { stderrTail: string[] }).stderrTail = ['FATAL heap out of memory', 'killed'];
|
|
334
|
+
|
|
335
|
+
(mgr as unknown as { handleExit: (s: unknown, c: number | null, sig: string | null) => void }).handleExit(
|
|
336
|
+
slot,
|
|
337
|
+
1,
|
|
338
|
+
null,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const line = lines.find((l) => l.includes('exited WITHOUT a stop request'));
|
|
342
|
+
expect(line).toBeDefined();
|
|
343
|
+
expect(line).toContain('code=1');
|
|
344
|
+
expect(line).toContain('last activity turns=0');
|
|
345
|
+
expect(line).toContain('FATAL heap out of memory');
|
|
346
|
+
expect(line).toContain('killed');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('does not warn when the stop was ours', async () => {
|
|
350
|
+
const { lines, logger } = logs();
|
|
351
|
+
const mgr = new WorkerRuntimeManager({ logger });
|
|
352
|
+
const slot = insertSlot(mgr, 'stopping');
|
|
353
|
+
slot.status = 'stopping';
|
|
354
|
+
|
|
355
|
+
(mgr as unknown as { handleExit: (s: unknown, c: number | null, sig: string | null) => void }).handleExit(
|
|
356
|
+
slot,
|
|
357
|
+
0,
|
|
358
|
+
null,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
expect(lines.some((l) => l.includes('exited WITHOUT a stop request'))).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -71,8 +71,15 @@ function insertRunningSlot(mgr: WorkerRuntimeManager, projectId: string) {
|
|
|
71
71
|
apiServerPort: 0,
|
|
72
72
|
status: 'running' as const,
|
|
73
73
|
proc,
|
|
74
|
-
|
|
74
|
+
// Settled, i.e. past the startup grace: a runtime that just booted is
|
|
75
|
+
// never an eviction victim (SHO-143).
|
|
76
|
+
startedAt: Date.now() - 10 * 60_000,
|
|
75
77
|
lastUsedAt: Date.now(),
|
|
78
|
+
// The activity sampler has already run and last saw this runtime doing
|
|
79
|
+
// nothing — the only state the idle reaper may act on (SHO-143). Without a
|
|
80
|
+
// sample the verdict is `unknown`, which keeps the runtime.
|
|
81
|
+
activity: { counts: { turns: 0, tasks: 0, streams: 0, children: 0 }, at: Date.now(), ok: true },
|
|
82
|
+
|
|
76
83
|
restarts: 0,
|
|
77
84
|
restartTimer: null,
|
|
78
85
|
idleTimer: null,
|
|
@@ -32,8 +32,14 @@ function insertSlot(mgr: WorkerRuntimeManager, projectId: string, extra: Record<
|
|
|
32
32
|
status: 'running',
|
|
33
33
|
proc: makeFakeProc(),
|
|
34
34
|
pid: null,
|
|
35
|
-
|
|
35
|
+
// Settled, i.e. past the startup grace: a runtime that just booted is
|
|
36
|
+
// never an eviction victim (SHO-143).
|
|
37
|
+
startedAt: Date.now() - 10 * 60_000,
|
|
36
38
|
lastUsedAt: Date.now(),
|
|
39
|
+
// The activity sampler has already run and last saw this runtime doing
|
|
40
|
+
// nothing — the only state the idle reaper may act on (SHO-143). Without a
|
|
41
|
+
// sample the verdict is `unknown`, which keeps the runtime.
|
|
42
|
+
activity: { counts: { turns: 0, tasks: 0, streams: 0, children: 0 }, at: Date.now(), ok: true },
|
|
37
43
|
restarts: 0,
|
|
38
44
|
consecutiveFailures: 0,
|
|
39
45
|
lastFailureAt: 0,
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { afterEach, describe, expect, it } from 'bun:test'
|
|
5
5
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
6
|
-
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs'
|
|
6
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { execFileSync } from 'node:child_process'
|
|
7
8
|
import { join } from 'node:path'
|
|
8
9
|
import { WorkerRuntimeManager } from '../runtime-manager.ts'
|
|
9
10
|
import { writeViteWatchPidfile } from '@shogo-ai/sdk/vite-watch'
|
|
@@ -60,4 +61,57 @@ describe.skipIf(!RUN_INTEGRATION)('WorkerRuntimeManager Vite watch cleanup e2e',
|
|
|
60
61
|
new Promise((_, reject) => setTimeout(() => reject(new Error('watch process did not exit')), 2_000)),
|
|
61
62
|
])
|
|
62
63
|
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* SHO-144: the pidfile only ever knew about vite. An exec shell and the
|
|
67
|
+
* preview API sidecar lead their own groups too, and a runtime that was
|
|
68
|
+
* SIGKILLed left them with no trail at all. The ledger is that trail; this
|
|
69
|
+
* asserts the boot sweep uses it, and that it refuses a reused pid.
|
|
70
|
+
*/
|
|
71
|
+
it('reaps the child groups a dead runtime left in the ledger', async () => {
|
|
72
|
+
const projectsDir = mkdtempSync(join('/tmp', 'shogo-worker-ledger-e2e-'))
|
|
73
|
+
tempDirs.push(projectsDir)
|
|
74
|
+
const projectDir = join(projectsDir, 'project-ledger')
|
|
75
|
+
mkdirSync(join(projectDir, '.shogo', 'run'), { recursive: true })
|
|
76
|
+
|
|
77
|
+
const spawnSleeper = (label: string) =>
|
|
78
|
+
spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', label], {
|
|
79
|
+
detached: true,
|
|
80
|
+
stdio: 'ignore',
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const real = spawnSleeper('exec')
|
|
84
|
+
const reused = spawnSleeper('unrelated')
|
|
85
|
+
expect(real.pid).toBeDefined()
|
|
86
|
+
expect(reused.pid).toBeDefined()
|
|
87
|
+
real.unref()
|
|
88
|
+
reused.unref()
|
|
89
|
+
child = null
|
|
90
|
+
// Registered as group leaders so the ledger's `kill(-pgid)` has a group.
|
|
91
|
+
const realCommand = execFileSync('ps', ['-p', String(real.pid), '-o', 'command='], { encoding: 'utf8' }).trim()
|
|
92
|
+
writeFileSync(
|
|
93
|
+
join(projectDir, '.shogo', 'run', 'children.json'),
|
|
94
|
+
JSON.stringify([
|
|
95
|
+
// Ours: the command line still matches what was recorded.
|
|
96
|
+
{ pgid: real.pid, pid: real.pid, label: 'exec', commandFingerprint: realCommand, startedAt: Date.now() },
|
|
97
|
+
// A pid that now belongs to something else: must never be signalled.
|
|
98
|
+
{ pgid: reused.pid, pid: reused.pid, label: 'api-sidecar', commandFingerprint: 'bun run server.tsx', startedAt: Date.now() },
|
|
99
|
+
]) + '\n',
|
|
100
|
+
'utf8',
|
|
101
|
+
)
|
|
102
|
+
const realExited = new Promise<void>((resolve) => real.once('exit', () => resolve()))
|
|
103
|
+
|
|
104
|
+
new WorkerRuntimeManager({
|
|
105
|
+
autoPull: { enabled: false, projectsDir },
|
|
106
|
+
logger: { log: () => {}, warn: () => {}, error: () => {} },
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
await Promise.race([
|
|
110
|
+
realExited,
|
|
111
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('ledger entry was not reaped')), 5_000)),
|
|
112
|
+
])
|
|
113
|
+
// The unrelated process survives: a reused pid is dropped, never signalled.
|
|
114
|
+
expect(() => process.kill(reused.pid as number, 0)).not.toThrow()
|
|
115
|
+
reused.kill('SIGKILL')
|
|
116
|
+
})
|
|
63
117
|
})
|