@shogo-ai/worker 2.3.9 → 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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* The "is this runtime busy?" decision, shared by every eviction path.
|
|
5
|
+
*
|
|
6
|
+
* Before this, each pool (background LRU, preview LRU, the worker's global
|
|
7
|
+
* `maxRuntimes` LRU) called a live HTTP probe at eviction time and read
|
|
8
|
+
* "could not tell" as "idle". On a host saturated by builds that probe timed
|
|
9
|
+
* out and the host kept killing runtimes that were running tasks: one
|
|
10
|
+
* Windows desktop evicted a background runtime 146 times in 24h while keeping
|
|
11
|
+
* one 0 times (SHO-143).
|
|
12
|
+
*
|
|
13
|
+
* Three rules fix that, and they live here rather than in each manager:
|
|
14
|
+
*
|
|
15
|
+
* 1. The decision reads a CACHED activity sample, never a probe taken while
|
|
16
|
+
* the host is deciding to kill something. A sample older than
|
|
17
|
+
* {@link ACTIVITY_STALE_MS} is not evidence of idleness.
|
|
18
|
+
* 2. Unknown is BUSY. No sample, a failed probe, a stale sample, a runtime
|
|
19
|
+
* that is still starting — all mean "keep", because killing it might kill
|
|
20
|
+
* work we simply could not see.
|
|
21
|
+
* 3. Only provably-dead runtimes are reaped: the process exited, or the
|
|
22
|
+
* runtime refused connections for longer than {@link DEAD_PROBE_MS}.
|
|
23
|
+
*
|
|
24
|
+
* This module is pure (no I/O, no imports) so both the worker manager and the
|
|
25
|
+
* desktop API manager decide identically and it can be unit-tested directly.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** How often each running runtime's activity is re-read. */
|
|
29
|
+
export const ACTIVITY_SAMPLE_MS = 10 * 1000
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A sample older than this is re-read once more before a runtime is killed.
|
|
33
|
+
*
|
|
34
|
+
* The timer above keeps the cache warm, but a turn can start inside the gap
|
|
35
|
+
* between two samples. Two seconds bounds "we killed work that began a moment
|
|
36
|
+
* ago" without turning the decision back into a probe on the critical path —
|
|
37
|
+
* and because a failed probe now reads as `unknown` (keep), re-asking is
|
|
38
|
+
* never the reason a runtime dies.
|
|
39
|
+
*/
|
|
40
|
+
export const ACTIVITY_RESAMPLE_BEFORE_EVICT_MS = 2 * 1000
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A sample older than this is not evidence of anything. Three missed samples
|
|
44
|
+
* (rather than one) keeps a host that is briefly CPU-starved from reading as
|
|
45
|
+
* "idle" — which is exactly how the 146 evictions happened.
|
|
46
|
+
*/
|
|
47
|
+
export const ACTIVITY_STALE_MS = 30 * 1000
|
|
48
|
+
|
|
49
|
+
/** A runtime that has not finished starting is never an eviction victim. */
|
|
50
|
+
export const STARTUP_GRACE_MS = 2 * 60 * 1000
|
|
51
|
+
|
|
52
|
+
/** Refusing connections for this long means the runtime is dead, not busy. */
|
|
53
|
+
export const DEAD_PROBE_MS = 60 * 1000
|
|
54
|
+
|
|
55
|
+
export interface ActivityCounts {
|
|
56
|
+
/** Agent turns in flight (chat, schedule, heartbeat, channel). */
|
|
57
|
+
turns: number
|
|
58
|
+
/** Registry tasks still running, whether or not they will notify. */
|
|
59
|
+
tasks: number
|
|
60
|
+
/** Open response streams. */
|
|
61
|
+
streams: number
|
|
62
|
+
/** Live child processes of the runtime's execs. */
|
|
63
|
+
children: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const ZERO_COUNTS: ActivityCounts = { turns: 0, tasks: 0, streams: 0, children: 0 }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One cached reading of a runtime's own activity. `ok: false` means the last
|
|
70
|
+
* probe failed — the counts are then the last known values, kept only for the
|
|
71
|
+
* log line; the decision treats the sample as unknown.
|
|
72
|
+
*/
|
|
73
|
+
export interface ActivitySample {
|
|
74
|
+
counts: ActivityCounts
|
|
75
|
+
/** When the host observed this reading (epoch ms). */
|
|
76
|
+
at: number
|
|
77
|
+
ok: boolean
|
|
78
|
+
error?: string
|
|
79
|
+
/** First failure of the current run of failures, epoch ms. */
|
|
80
|
+
failingSince?: number | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type BusyReason =
|
|
84
|
+
| 'terminal'
|
|
85
|
+
| 'inflight-call'
|
|
86
|
+
| 'grace'
|
|
87
|
+
| 'unknown'
|
|
88
|
+
| 'stale'
|
|
89
|
+
| 'work'
|
|
90
|
+
| 'idle'
|
|
91
|
+
| 'dead'
|
|
92
|
+
|
|
93
|
+
export interface BusyDecision {
|
|
94
|
+
/** Never evict while true. */
|
|
95
|
+
busy: boolean
|
|
96
|
+
/** Provably gone: reap it, don't evict it. */
|
|
97
|
+
dead: boolean
|
|
98
|
+
reason: BusyReason
|
|
99
|
+
counts: ActivityCounts
|
|
100
|
+
/** Age of the sample the decision was made from, when there is one. */
|
|
101
|
+
sampleAgeMs?: number
|
|
102
|
+
/** `turns=0 tasks=2 streams=0 children=0`, for the decision log line. */
|
|
103
|
+
countsText: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface DecideBusyInput {
|
|
107
|
+
/** Epoch ms "now". */
|
|
108
|
+
now: number
|
|
109
|
+
/** When the runtime process was started, epoch ms. */
|
|
110
|
+
startedAt: number
|
|
111
|
+
/** Worker/API status string for the runtime ('running', 'starting', …). */
|
|
112
|
+
status: string
|
|
113
|
+
/** The cached activity reading, if there is one. */
|
|
114
|
+
sample?: ActivitySample | null
|
|
115
|
+
/** Open PTY terminals attached to this runtime. */
|
|
116
|
+
terminalSessions?: number
|
|
117
|
+
/** In-flight project calls / card hand-offs targeting this runtime. */
|
|
118
|
+
inflightCalls?: number
|
|
119
|
+
/** The runtime's process has exited (code or signal). */
|
|
120
|
+
processExited?: boolean
|
|
121
|
+
startupGraceMs?: number
|
|
122
|
+
staleMs?: number
|
|
123
|
+
deadAfterMs?: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function countsText(counts: ActivityCounts): string {
|
|
127
|
+
return `turns=${counts.turns} tasks=${counts.tasks} streams=${counts.streams} children=${counts.children}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function totalWork(counts: ActivityCounts): number {
|
|
131
|
+
return counts.turns + counts.tasks + counts.streams + counts.children
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function decision(
|
|
135
|
+
busy: boolean,
|
|
136
|
+
dead: boolean,
|
|
137
|
+
reason: BusyReason,
|
|
138
|
+
counts: ActivityCounts,
|
|
139
|
+
sampleAgeMs?: number,
|
|
140
|
+
): BusyDecision {
|
|
141
|
+
return { busy, dead, reason, counts, countsText: countsText(counts), ...(sampleAgeMs != null ? { sampleAgeMs } : {}) }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Decide whether a runtime may be evicted. Order matters: the cheap, certain
|
|
146
|
+
* "someone is working" signals come first, then anything we cannot verify
|
|
147
|
+
* (which keeps the runtime), and only then idle.
|
|
148
|
+
*/
|
|
149
|
+
export function decideBusy(input: DecideBusyInput): BusyDecision {
|
|
150
|
+
const {
|
|
151
|
+
now,
|
|
152
|
+
startedAt,
|
|
153
|
+
status,
|
|
154
|
+
sample,
|
|
155
|
+
processExited = false,
|
|
156
|
+
startupGraceMs = STARTUP_GRACE_MS,
|
|
157
|
+
staleMs = ACTIVITY_STALE_MS,
|
|
158
|
+
deadAfterMs = DEAD_PROBE_MS,
|
|
159
|
+
} = input
|
|
160
|
+
const terminalSessions = input.terminalSessions ?? 0
|
|
161
|
+
const inflightCalls = input.inflightCalls ?? 0
|
|
162
|
+
const counts = sample?.counts ?? ZERO_COUNTS
|
|
163
|
+
const sampleAgeMs = sample ? Math.max(0, now - sample.at) : undefined
|
|
164
|
+
|
|
165
|
+
// A shell attached to this runtime is the strongest "someone is working"
|
|
166
|
+
// signal there is (SHO-107) — evicting it kills their terminal mid-command.
|
|
167
|
+
if (terminalSessions > 0) return decision(true, false, 'terminal', counts, sampleAgeMs)
|
|
168
|
+
if (inflightCalls > 0) return decision(true, false, 'inflight-call', counts, sampleAgeMs)
|
|
169
|
+
|
|
170
|
+
// Provably gone. Reaped as dead, not evicted.
|
|
171
|
+
if (processExited) return decision(false, true, 'dead', counts, sampleAgeMs)
|
|
172
|
+
|
|
173
|
+
// Still booting: the gateway is not up, so no probe could see its work yet.
|
|
174
|
+
if (status === 'starting' || status === 'restarting') {
|
|
175
|
+
return decision(true, false, 'grace', counts, sampleAgeMs)
|
|
176
|
+
}
|
|
177
|
+
const age = now - (startedAt || now)
|
|
178
|
+
if (age >= 0 && age < startupGraceMs) return decision(true, false, 'grace', counts, sampleAgeMs)
|
|
179
|
+
|
|
180
|
+
if (status !== 'running') {
|
|
181
|
+
// Neither running nor provably dead — we cannot see it, so keep it.
|
|
182
|
+
return decision(true, false, 'unknown', counts, sampleAgeMs)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!sample) return decision(true, false, 'unknown', counts, sampleAgeMs)
|
|
186
|
+
if (!sample.ok) {
|
|
187
|
+
const failingFor = sample.failingSince != null ? now - sample.failingSince : 0
|
|
188
|
+
// A runtime that refuses connections is dead once it has done so for long
|
|
189
|
+
// enough that a hung-but-working runtime would have answered.
|
|
190
|
+
if (failingFor >= deadAfterMs) return decision(false, true, 'dead', counts, sampleAgeMs)
|
|
191
|
+
return decision(true, false, 'unknown', counts, sampleAgeMs)
|
|
192
|
+
}
|
|
193
|
+
if ((sampleAgeMs ?? 0) > staleMs) return decision(true, false, 'stale', counts, sampleAgeMs)
|
|
194
|
+
if (totalWork(counts) > 0) return decision(true, false, 'work', counts, sampleAgeMs)
|
|
195
|
+
return decision(false, false, 'idle', counts, sampleAgeMs)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Human phrase for the `keeping`/`evicting` log line. */
|
|
199
|
+
export function describeBusyReason(d: BusyDecision, idleMs?: number): string {
|
|
200
|
+
switch (d.reason) {
|
|
201
|
+
case 'terminal':
|
|
202
|
+
return 'open terminal'
|
|
203
|
+
case 'inflight-call':
|
|
204
|
+
return 'in-flight project call'
|
|
205
|
+
case 'grace':
|
|
206
|
+
return 'starting up'
|
|
207
|
+
case 'unknown':
|
|
208
|
+
return `unknown activity${d.sampleAgeMs != null ? ` (last sample ${Math.round(d.sampleAgeMs / 1000)}s old)` : ' (no sample yet)'}`
|
|
209
|
+
case 'stale':
|
|
210
|
+
return `stale activity (last sample ${Math.round((d.sampleAgeMs ?? 0) / 1000)}s old)`
|
|
211
|
+
case 'work':
|
|
212
|
+
return d.countsText
|
|
213
|
+
case 'dead':
|
|
214
|
+
return 'process gone'
|
|
215
|
+
case 'idle':
|
|
216
|
+
return idleMs != null ? `idle ${Math.round(idleMs / 60_000)}m, ${d.countsText}` : `idle, ${d.countsText}`
|
|
217
|
+
}
|
|
218
|
+
}
|