@shendeguize/dsh-agent-sidecar 0.1.0
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 +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar daemon lifecycle supervisor — the probe-adopt-else-host state
|
|
3
|
+
* machine of design §4.a (.local/tasks/make_dsh_mode/design/dsh_plugin_design.md).
|
|
4
|
+
*
|
|
5
|
+
* Pure dependency-injected module: imports nothing from cordis/dsh. The
|
|
6
|
+
* plugin entry (index.ts) wires the real seams — Unix-socket `ping`,
|
|
7
|
+
* `ctx.subprocess.spawn` running `<sidecarCommand> daemon run`, read-only
|
|
8
|
+
* LaunchAgent detection via `agent-sidecar service status` — and registers
|
|
9
|
+
* `stop()` inside a `ctx.effect` disposer.
|
|
10
|
+
*
|
|
11
|
+
* Ownership contract: the supervisor only ever terminates processes it
|
|
12
|
+
* spawned itself (HOSTING/HOSTED). Externally managed daemons (ADOPTED,
|
|
13
|
+
* DEFER) are never killed — teardown merely disconnects.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type SupervisorState =
|
|
17
|
+
| 'probe'
|
|
18
|
+
| 'adopted'
|
|
19
|
+
| 'defer'
|
|
20
|
+
| 'reprobe'
|
|
21
|
+
| 'hosting'
|
|
22
|
+
| 'hosted'
|
|
23
|
+
| 'backoff'
|
|
24
|
+
| 'failed'
|
|
25
|
+
|
|
26
|
+
export type SupervisorPolicy = 'adopt-or-host' | 'adopt-only' | 'off'
|
|
27
|
+
|
|
28
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
29
|
+
|
|
30
|
+
/** Daemon self-description returned by the Unix-socket `ping` op. */
|
|
31
|
+
export interface PingInfo {
|
|
32
|
+
pid: number
|
|
33
|
+
version: string
|
|
34
|
+
http: {
|
|
35
|
+
enabled: boolean
|
|
36
|
+
host?: string
|
|
37
|
+
port?: number
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Minimal handle over a spawned `daemon run` foreground child process. */
|
|
42
|
+
export interface DaemonProcess {
|
|
43
|
+
/** Settles once the process exits, for any reason. */
|
|
44
|
+
readonly exited: Promise<number | null>
|
|
45
|
+
/** Tree-scoped SIGTERM → grace → SIGKILL; safe to call more than once. */
|
|
46
|
+
terminate(): Promise<void>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Opaque timer handle so Node globals and injected fake timers interoperate. */
|
|
50
|
+
export type TimerHandle = unknown
|
|
51
|
+
|
|
52
|
+
export interface SupervisorDeps {
|
|
53
|
+
/** One socket ping round-trip; resolves null on any failure (no socket, timeout, bad payload). */
|
|
54
|
+
ping(): Promise<PingInfo | null>
|
|
55
|
+
/** Spawn the daemon as a supervised foreground child. */
|
|
56
|
+
spawnDaemon(): DaemonProcess
|
|
57
|
+
/** Read-only macOS LaunchAgent detection; true means launchd owns the daemon. */
|
|
58
|
+
detectLaunchAgent(): Promise<boolean>
|
|
59
|
+
log(level: LogLevel, msg: string, meta?: Record<string, unknown>): void
|
|
60
|
+
/** Injectable timer pair (fake timers in tests). Defaults to globals. */
|
|
61
|
+
setTimeout?(fn: () => void, ms: number): TimerHandle
|
|
62
|
+
clearTimeout?(handle: TimerHandle): void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SupervisorOptions {
|
|
66
|
+
policy: SupervisorPolicy
|
|
67
|
+
/** Consecutive hosting failures that trip FAILED. Default 5. */
|
|
68
|
+
backoffLimit?: number
|
|
69
|
+
/** First backoff delay; doubles on each consecutive failure. Default 1000. */
|
|
70
|
+
backoffBaseMs?: number
|
|
71
|
+
/** Ceiling for the exponential backoff. Default 30000. */
|
|
72
|
+
backoffCapMs?: number
|
|
73
|
+
/** DEFER re-probe cadence. Default 5000. */
|
|
74
|
+
probeIntervalMs?: number
|
|
75
|
+
/** ADOPTED health re-ping cadence. Default 5000. */
|
|
76
|
+
adoptedRepingMs?: number
|
|
77
|
+
/** Consecutive ADOPTED ping misses before REPROBE. Default 3. */
|
|
78
|
+
adoptedFailureLimit?: number
|
|
79
|
+
/** HOSTING readiness window before the spawn counts as failed. Default 5000. */
|
|
80
|
+
hostReadyTimeoutMs?: number
|
|
81
|
+
/** Ping cadence while waiting for a hosted daemon to become ready. Default 500. */
|
|
82
|
+
hostReadyPingIntervalMs?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type StateListener = (state: SupervisorState, previous: SupervisorState) => void
|
|
86
|
+
|
|
87
|
+
const defaultSetTimeout = (fn: () => void, ms: number): TimerHandle =>
|
|
88
|
+
globalThis.setTimeout(fn, ms)
|
|
89
|
+
|
|
90
|
+
const defaultClearTimeout = (handle: TimerHandle): void => {
|
|
91
|
+
globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const describeError = (error: unknown): string =>
|
|
95
|
+
error instanceof Error ? error.message : String(error)
|
|
96
|
+
|
|
97
|
+
export class DaemonSupervisor {
|
|
98
|
+
private readonly deps: SupervisorDeps
|
|
99
|
+
private readonly opts: Required<SupervisorOptions>
|
|
100
|
+
|
|
101
|
+
private _state: SupervisorState = 'probe'
|
|
102
|
+
private _lastPing: PingInfo | null = null
|
|
103
|
+
private readonly listeners = new Set<StateListener>()
|
|
104
|
+
private readonly timers = new Set<TimerHandle>()
|
|
105
|
+
/** Only ever non-null for a process this supervisor spawned itself. */
|
|
106
|
+
private proc: DaemonProcess | null = null
|
|
107
|
+
/**
|
|
108
|
+
* Invalidation token for async continuations (ping/detect results, process
|
|
109
|
+
* exit watchers): each macro transition bumps it, so continuations started
|
|
110
|
+
* under an older epoch abandon instead of acting on a stale world.
|
|
111
|
+
*/
|
|
112
|
+
private epoch = 0
|
|
113
|
+
private started = false
|
|
114
|
+
private stopped = false
|
|
115
|
+
/** Consecutive hosting failures (readiness timeout or early exit). */
|
|
116
|
+
private hostFailures = 0
|
|
117
|
+
/** Consecutive ADOPTED re-ping misses. */
|
|
118
|
+
private pingFailures = 0
|
|
119
|
+
|
|
120
|
+
constructor(deps: SupervisorDeps, options: SupervisorOptions) {
|
|
121
|
+
this.deps = deps
|
|
122
|
+
this.opts = {
|
|
123
|
+
policy: options.policy,
|
|
124
|
+
backoffLimit: options.backoffLimit ?? 5,
|
|
125
|
+
backoffBaseMs: options.backoffBaseMs ?? 1000,
|
|
126
|
+
backoffCapMs: options.backoffCapMs ?? 30_000,
|
|
127
|
+
probeIntervalMs: options.probeIntervalMs ?? 5000,
|
|
128
|
+
adoptedRepingMs: options.adoptedRepingMs ?? 5000,
|
|
129
|
+
adoptedFailureLimit: options.adoptedFailureLimit ?? 3,
|
|
130
|
+
hostReadyTimeoutMs: options.hostReadyTimeoutMs ?? 5000,
|
|
131
|
+
hostReadyPingIntervalMs: options.hostReadyPingIntervalMs ?? 500,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get state(): SupervisorState {
|
|
136
|
+
return this._state
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Last successful ping payload; null until the daemon answered once. */
|
|
140
|
+
get lastPing(): PingInfo | null {
|
|
141
|
+
return this._lastPing
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Subscribe to state transitions; returns an unsubscribe function. */
|
|
145
|
+
onStateChange(listener: StateListener): () => void {
|
|
146
|
+
this.listeners.add(listener)
|
|
147
|
+
return () => {
|
|
148
|
+
this.listeners.delete(listener)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
start(): void {
|
|
153
|
+
if (this.started || this.stopped) return
|
|
154
|
+
this.started = true
|
|
155
|
+
if (this.opts.policy === 'off') {
|
|
156
|
+
// Terminal quiescence: no probing, no timers, no spawn. DEFER is the
|
|
157
|
+
// closest state in the vocabulary to "lifecycle is not ours to manage".
|
|
158
|
+
this.deps.log('info', 'daemon management policy is off; supervisor standing down')
|
|
159
|
+
this.setState('defer')
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
void this.runDetermination('probe')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Tear everything down for the ctx.effect disposer: probe/backoff timers
|
|
167
|
+
* first, then terminate a self-spawned daemon. Adopted or launchd-managed
|
|
168
|
+
* daemons are never touched. Idempotent.
|
|
169
|
+
*/
|
|
170
|
+
async stop(): Promise<void> {
|
|
171
|
+
if (this.stopped) return
|
|
172
|
+
this.stopped = true
|
|
173
|
+
this.epoch += 1
|
|
174
|
+
this.clearAllTimers()
|
|
175
|
+
this.listeners.clear()
|
|
176
|
+
const proc = this.proc
|
|
177
|
+
this.proc = null
|
|
178
|
+
if (proc) {
|
|
179
|
+
this.deps.log('info', 'stopping supervisor: terminating self-hosted daemon')
|
|
180
|
+
try {
|
|
181
|
+
await proc.terminate()
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this.deps.log('warn', 'terminate during stop failed', { error: describeError(error) })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** From FAILED only: reset the failure budget and re-run the determination. */
|
|
189
|
+
retry(): void {
|
|
190
|
+
if (this.stopped) return
|
|
191
|
+
if (this._state !== 'failed') {
|
|
192
|
+
this.deps.log('debug', 'retry ignored outside FAILED state', { state: this._state })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
this.hostFailures = 0
|
|
196
|
+
this.pingFailures = 0
|
|
197
|
+
this.deps.log('info', 'retry requested: failure budget reset, re-probing')
|
|
198
|
+
void this.runDetermination('probe')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------- probe
|
|
202
|
+
|
|
203
|
+
/** Shared PROBE/REPROBE determination: ping → adopt, else LaunchAgent → defer, else host. */
|
|
204
|
+
private async runDetermination(entry: 'probe' | 'reprobe'): Promise<void> {
|
|
205
|
+
const ep = ++this.epoch
|
|
206
|
+
this.clearAllTimers()
|
|
207
|
+
this.setState(entry)
|
|
208
|
+
const info = await this.safePing()
|
|
209
|
+
if (this.invalidated(ep)) return
|
|
210
|
+
if (info) {
|
|
211
|
+
this.enterAdopted(info)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
let managed = false
|
|
215
|
+
try {
|
|
216
|
+
managed = await this.deps.detectLaunchAgent()
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.deps.log('warn', 'LaunchAgent detection failed; assuming absent', {
|
|
219
|
+
error: describeError(error),
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
if (this.invalidated(ep)) return
|
|
223
|
+
if (managed) {
|
|
224
|
+
this.enterDefer('LaunchAgent installed; launchd owns daemon liveness')
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (this.opts.policy === 'adopt-only') {
|
|
228
|
+
this.enterDefer('policy adopt-only forbids spawning')
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
this.enterHosting()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// -------------------------------------------------------------- adopted
|
|
235
|
+
|
|
236
|
+
private enterAdopted(info: PingInfo): void {
|
|
237
|
+
this.clearAllTimers()
|
|
238
|
+
this._lastPing = info
|
|
239
|
+
this.pingFailures = 0
|
|
240
|
+
this.hostFailures = 0
|
|
241
|
+
this.deps.log('info', 'adopted existing daemon', { pid: info.pid, version: info.version })
|
|
242
|
+
this.setState('adopted')
|
|
243
|
+
this.scheduleAdoptedPing()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private scheduleAdoptedPing(): void {
|
|
247
|
+
this.schedule(this.opts.adoptedRepingMs, () => {
|
|
248
|
+
void this.adoptedPing()
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async adoptedPing(): Promise<void> {
|
|
253
|
+
const ep = this.epoch
|
|
254
|
+
const info = await this.safePing()
|
|
255
|
+
if (this.invalidated(ep) || this._state !== 'adopted') return
|
|
256
|
+
if (info) {
|
|
257
|
+
this._lastPing = info
|
|
258
|
+
this.pingFailures = 0
|
|
259
|
+
this.scheduleAdoptedPing()
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
this.pingFailures += 1
|
|
263
|
+
this.deps.log('warn', 'adopted daemon missed ping', {
|
|
264
|
+
misses: this.pingFailures,
|
|
265
|
+
limit: this.opts.adoptedFailureLimit,
|
|
266
|
+
})
|
|
267
|
+
if (this.pingFailures >= this.opts.adoptedFailureLimit) {
|
|
268
|
+
this.pingFailures = 0
|
|
269
|
+
void this.runDetermination('reprobe')
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
this.scheduleAdoptedPing()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------- defer
|
|
276
|
+
|
|
277
|
+
/** External management (launchd, or adopt-only policy): re-probe periodically, never spawn. */
|
|
278
|
+
private enterDefer(reason: string): void {
|
|
279
|
+
this.clearAllTimers()
|
|
280
|
+
this.deps.log('info', 'deferring daemon management', { reason })
|
|
281
|
+
this.setState('defer')
|
|
282
|
+
this.scheduleDeferProbe()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private scheduleDeferProbe(): void {
|
|
286
|
+
this.schedule(this.opts.probeIntervalMs, () => {
|
|
287
|
+
void this.deferProbe()
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private async deferProbe(): Promise<void> {
|
|
292
|
+
const ep = this.epoch
|
|
293
|
+
const info = await this.safePing()
|
|
294
|
+
if (this.invalidated(ep) || this._state !== 'defer') return
|
|
295
|
+
if (info) {
|
|
296
|
+
this.enterAdopted(info)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
this.scheduleDeferProbe()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -------------------------------------------------------------- hosting
|
|
303
|
+
|
|
304
|
+
private enterHosting(): void {
|
|
305
|
+
const ep = ++this.epoch
|
|
306
|
+
this.clearAllTimers()
|
|
307
|
+
this.setState('hosting')
|
|
308
|
+
let proc: DaemonProcess
|
|
309
|
+
try {
|
|
310
|
+
proc = this.deps.spawnDaemon()
|
|
311
|
+
} catch (error) {
|
|
312
|
+
this.deps.log('error', 'failed to spawn daemon', { error: describeError(error) })
|
|
313
|
+
this.enterBackoff('spawn-error', null)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
this.proc = proc
|
|
317
|
+
// The watcher survives into HOSTED (same epoch); any later transition
|
|
318
|
+
// bumps the epoch and thereby detaches it.
|
|
319
|
+
proc.exited.then(
|
|
320
|
+
(code) => {
|
|
321
|
+
if (this.invalidated(ep)) return
|
|
322
|
+
this.proc = null
|
|
323
|
+
this.deps.log('warn', 'hosted daemon exited', { code, state: this._state })
|
|
324
|
+
this.enterBackoff('daemon-exit', code)
|
|
325
|
+
},
|
|
326
|
+
(error: unknown) => {
|
|
327
|
+
if (this.invalidated(ep)) return
|
|
328
|
+
this.proc = null
|
|
329
|
+
this.deps.log('warn', 'hosted daemon exit watch failed', { error: describeError(error) })
|
|
330
|
+
this.enterBackoff('daemon-exit', null)
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
this.schedule(this.opts.hostReadyTimeoutMs, () => {
|
|
334
|
+
if (this.invalidated(ep)) return
|
|
335
|
+
this.deps.log('warn', 'hosted daemon readiness timeout', {
|
|
336
|
+
timeoutMs: this.opts.hostReadyTimeoutMs,
|
|
337
|
+
})
|
|
338
|
+
this.disposeProc()
|
|
339
|
+
this.enterBackoff('ready-timeout', null)
|
|
340
|
+
})
|
|
341
|
+
this.scheduleReadyPoll(ep)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private scheduleReadyPoll(ep: number): void {
|
|
345
|
+
this.schedule(this.opts.hostReadyPingIntervalMs, () => {
|
|
346
|
+
void this.readyPoll(ep)
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private async readyPoll(ep: number): Promise<void> {
|
|
351
|
+
if (this.invalidated(ep)) return
|
|
352
|
+
const info = await this.safePing()
|
|
353
|
+
if (this.invalidated(ep) || this._state !== 'hosting') return
|
|
354
|
+
if (info) {
|
|
355
|
+
this.enterHosted(info)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
this.scheduleReadyPoll(ep)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private enterHosted(info: PingInfo): void {
|
|
362
|
+
// Deliberately no epoch bump: the process exit watcher from enterHosting
|
|
363
|
+
// must stay attached so a crash in HOSTED still lands in BACKOFF.
|
|
364
|
+
this.clearAllTimers()
|
|
365
|
+
this._lastPing = info
|
|
366
|
+
this.hostFailures = 0
|
|
367
|
+
this.pingFailures = 0
|
|
368
|
+
this.deps.log('info', 'hosted daemon ready', { pid: info.pid, version: info.version })
|
|
369
|
+
this.setState('hosted')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// -------------------------------------------------------------- backoff
|
|
373
|
+
|
|
374
|
+
private enterBackoff(reason: string, code: number | null): void {
|
|
375
|
+
this.epoch += 1
|
|
376
|
+
this.clearAllTimers()
|
|
377
|
+
this.hostFailures += 1
|
|
378
|
+
this.setState('backoff')
|
|
379
|
+
if (this.hostFailures >= this.opts.backoffLimit) {
|
|
380
|
+
this.deps.log('error', 'hosting failure budget exhausted; giving up', {
|
|
381
|
+
failures: this.hostFailures,
|
|
382
|
+
reason,
|
|
383
|
+
})
|
|
384
|
+
this.setState('failed')
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
const delayMs = Math.min(
|
|
388
|
+
this.opts.backoffBaseMs * 2 ** (this.hostFailures - 1),
|
|
389
|
+
this.opts.backoffCapMs,
|
|
390
|
+
)
|
|
391
|
+
this.deps.log('warn', 'hosting failed; backing off', {
|
|
392
|
+
reason,
|
|
393
|
+
code,
|
|
394
|
+
failures: this.hostFailures,
|
|
395
|
+
delayMs,
|
|
396
|
+
})
|
|
397
|
+
// Re-run the full determination after the delay: if a daemon appeared
|
|
398
|
+
// externally in the meantime we adopt it instead of fighting the
|
|
399
|
+
// single-instance lock with another doomed spawn.
|
|
400
|
+
this.schedule(delayMs, () => {
|
|
401
|
+
void this.runDetermination('probe')
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// -------------------------------------------------------------- helpers
|
|
406
|
+
|
|
407
|
+
private invalidated(ep: number): boolean {
|
|
408
|
+
return this.stopped || this.epoch !== ep
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private async safePing(): Promise<PingInfo | null> {
|
|
412
|
+
try {
|
|
413
|
+
return await this.deps.ping()
|
|
414
|
+
} catch (error) {
|
|
415
|
+
this.deps.log('debug', 'ping threw; treating as unreachable', {
|
|
416
|
+
error: describeError(error),
|
|
417
|
+
})
|
|
418
|
+
return null
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Fire-and-forget terminate of the self-spawned process (readiness timeout path). */
|
|
423
|
+
private disposeProc(): void {
|
|
424
|
+
const proc = this.proc
|
|
425
|
+
this.proc = null
|
|
426
|
+
if (!proc) return
|
|
427
|
+
proc.terminate().catch((error: unknown) => {
|
|
428
|
+
this.deps.log('warn', 'terminate failed', { error: describeError(error) })
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private setState(next: SupervisorState): void {
|
|
433
|
+
if (this._state === next) return
|
|
434
|
+
const previous = this._state
|
|
435
|
+
this._state = next
|
|
436
|
+
this.deps.log('debug', 'supervisor state transition', { from: previous, to: next })
|
|
437
|
+
for (const listener of [...this.listeners]) {
|
|
438
|
+
try {
|
|
439
|
+
listener(next, previous)
|
|
440
|
+
} catch (error) {
|
|
441
|
+
this.deps.log('warn', 'state listener threw', { error: describeError(error) })
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private schedule(ms: number, fn: () => void): TimerHandle {
|
|
447
|
+
const set = this.deps.setTimeout ?? defaultSetTimeout
|
|
448
|
+
let handle: TimerHandle
|
|
449
|
+
handle = set(() => {
|
|
450
|
+
this.timers.delete(handle)
|
|
451
|
+
if (this.stopped) return
|
|
452
|
+
fn()
|
|
453
|
+
}, ms)
|
|
454
|
+
this.timers.add(handle)
|
|
455
|
+
return handle
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private clearAllTimers(): void {
|
|
459
|
+
const clear = this.deps.clearTimeout ?? defaultClearTimeout
|
|
460
|
+
for (const handle of this.timers) clear(handle)
|
|
461
|
+
this.timers.clear()
|
|
462
|
+
}
|
|
463
|
+
}
|