@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.
@@ -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
+ }