agent-yes 1.169.1 → 1.170.1
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/{SUPPORTED_CLIS-Bwg9VqLI.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
- package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
- package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
- package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
- package/dist/cli.js +10 -212
- package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
- package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
- package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
- package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/notifyDaemon-D5jtpCAR.js +591 -0
- package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
- package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
- package/dist/pidStore-CLxwp0qI.js +5 -0
- package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
- package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
- package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
- package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
- package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
- package/dist/rustBinary-DsO0IlNq.js +228 -0
- package/dist/{schedule-CtVvSyaN.js → schedule-ChTPIcXf.js} +7 -7
- package/dist/{serve-CLmk5ujF.js → serve-De1gAWNI.js} +120 -26
- package/dist/{setup-CYbXOcpH.js → setup-nlMqyEPB.js} +3 -3
- package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
- package/dist/spawnGate-BFhva-2F.js +5 -0
- package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
- package/dist/{subcommands--EUush_X.js → subcommands-BFlpV23b.js} +1049 -44
- package/dist/subcommands-DLDpwrfG.js +9 -0
- package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
- package/dist/{ts-Bbhf5JbN.js → ts-DsxejviJ.js} +8 -8
- package/dist/{versionChecker-BmWkWhcJ.js → versionChecker-Czk3aHqy.js} +2 -2
- package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
- package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
- package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
- package/lab/ui/console-logic.js +79 -10
- package/lab/ui/index.html +174 -8
- package/lab/ui/landing.html +11 -1
- package/package.json +1 -1
- package/scripts/build-rs.ts +9 -5
- package/ts/badges.spec.ts +51 -1
- package/ts/badges.ts +35 -1
- package/ts/notifyDaemon.spec.ts +276 -0
- package/ts/notifyDaemon.ts +698 -0
- package/ts/notifyInbox.spec.ts +192 -0
- package/ts/notifyInbox.ts +294 -0
- package/ts/notifyRouter.spec.ts +355 -0
- package/ts/notifyRouter.ts +288 -0
- package/ts/notifyStore.spec.ts +308 -0
- package/ts/notifyStore.ts +439 -0
- package/ts/reaper.spec.ts +18 -1
- package/ts/reaper.ts +33 -1
- package/ts/rustBinary.ts +30 -0
- package/ts/serve.ts +180 -14
- package/ts/subcommands.spec.ts +341 -0
- package/ts/subcommands.ts +764 -29
- package/ts/workspaceConfig.spec.ts +48 -0
- package/ts/workspaceConfig.ts +46 -3
- package/dist/SUPPORTED_CLIS-DiL99G3j.js +0 -8
- package/dist/pidStore-5qyLX3Fh.js +0 -5
- package/dist/spawnGate-CQ1Il3Xk.js +0 -5
- package/dist/subcommands-1ZcaHYkb.js +0 -9
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ay notifyd` — the always-on-while-watched detection engine. Polls every
|
|
3
|
+
* WATCHED agent's live state (the runtime-agnostic query layer, so BOTH Rust and
|
|
4
|
+
* TS children are covered), runs the pure debounce router (`notifyRouter.ts`),
|
|
5
|
+
* and appends the decided edges — enriched with a payload the parent can act on
|
|
6
|
+
* without tailing — into each parent's inbox (`notifyStore.ts`).
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle: a host singleton, guarded by an mkdir lock that records its owner
|
|
9
|
+
* pid so a crashed daemon's stale lock is detected and STOLEN (never a permanent
|
|
10
|
+
* deadlock). Scope + liveness are driven by the WATCHER REGISTRY: the daemon only
|
|
11
|
+
* processes children whose parent is currently running `ay notify watch` (so an
|
|
12
|
+
* unrelated agent never gets an inbox), and it stays alive as long as any watcher
|
|
13
|
+
* heartbeat is live, self-exiting only after a grace window with no watchers.
|
|
14
|
+
* `ay notify watch` re-ensures the daemon every poll, so a parent that watches
|
|
15
|
+
* BEFORE spawning children (or across a fan-out gap) always has a live daemon.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
21
|
+
import {
|
|
22
|
+
type ChildObservation,
|
|
23
|
+
type RouterState,
|
|
24
|
+
stepRouter,
|
|
25
|
+
} from "./notifyRouter.ts";
|
|
26
|
+
import {
|
|
27
|
+
type NotifyEvent,
|
|
28
|
+
daemonLockDir,
|
|
29
|
+
daemonLockOwnerPath,
|
|
30
|
+
notifyDir,
|
|
31
|
+
} from "./notifyInbox.ts";
|
|
32
|
+
import {
|
|
33
|
+
appendEvent,
|
|
34
|
+
gcInboxes,
|
|
35
|
+
hostId,
|
|
36
|
+
listInboxParents,
|
|
37
|
+
liveWatchers,
|
|
38
|
+
readInbox,
|
|
39
|
+
shouldStealLock,
|
|
40
|
+
} from "./notifyStore.ts";
|
|
41
|
+
import { deriveLiveState, isPidAlive, listRecords, renderLogTailLines } from "./subcommands.ts";
|
|
42
|
+
import { logger } from "./logger.ts";
|
|
43
|
+
|
|
44
|
+
const POLL_MS = 2000;
|
|
45
|
+
const GC_EVERY_TICKS = 30; // ~every 60s
|
|
46
|
+
const IDLE_EXIT_GRACE_MS = 60_000; // exit if no watcher this long
|
|
47
|
+
|
|
48
|
+
const LS_OPTS = { all: true, active: false, json: false, latest: false, cwdScope: null } as const;
|
|
49
|
+
|
|
50
|
+
/** Short git HEAD of a cwd, best-effort, never blocks emission. */
|
|
51
|
+
function gitHead(cwd: string): Promise<string | null> {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
execFile(
|
|
54
|
+
"git",
|
|
55
|
+
["-C", cwd, "rev-parse", "--short", "HEAD"],
|
|
56
|
+
{ timeout: 1500, windowsHide: true },
|
|
57
|
+
(err, stdout) => resolve(err ? null : stdout.trim() || null),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Last few rendered log lines, compacted, so the parent needn't tail. */
|
|
63
|
+
async function recentTail(logFile: string | null | undefined): Promise<string | null> {
|
|
64
|
+
if (!logFile) return null;
|
|
65
|
+
const lines = await renderLogTailLines(logFile, 12).catch(() => null);
|
|
66
|
+
if (!lines) return null;
|
|
67
|
+
const compact = lines
|
|
68
|
+
.map((l) => l.replace(/\s+$/, ""))
|
|
69
|
+
.filter((l) => l.trim())
|
|
70
|
+
.slice(-8);
|
|
71
|
+
return compact.length ? compact.join("\n").slice(0, 1200) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Seed the router's prior state from what each inbox has ALREADY recorded, so a
|
|
76
|
+
* daemon restart does not re-emit a baseline the parent already saw.
|
|
77
|
+
*
|
|
78
|
+
* pid-reuse guard: seed a child ONLY if it is still in the registry with the
|
|
79
|
+
* SAME start time as the inbox recorded (`liveChildren`: pid → started_at). A pid
|
|
80
|
+
* whose live start time differs is a NEW child recycling the pid — seeding it
|
|
81
|
+
* would suppress its first notification, so we skip it (it starts fresh). A pid
|
|
82
|
+
* absent from the registry (reaped) is skipped too: it won't be observed again,
|
|
83
|
+
* and if the pid is later reused, that new child was never seeded.
|
|
84
|
+
*/
|
|
85
|
+
export async function reconcileFromInboxes(
|
|
86
|
+
host: string,
|
|
87
|
+
liveChildren: Map<number, number>,
|
|
88
|
+
watchedParents: Set<number>,
|
|
89
|
+
): Promise<RouterState> {
|
|
90
|
+
const state: RouterState = new Map();
|
|
91
|
+
for (const parent of await listInboxParents(host)) {
|
|
92
|
+
// Scope to CURRENTLY-watched parents only. Seeding an unwatched parent's
|
|
93
|
+
// inbox would let this daemon carry its children forward and later write a
|
|
94
|
+
// synthetic exited into an inbox nobody is watching — violating "nothing
|
|
95
|
+
// happens unless you watch".
|
|
96
|
+
if (!watchedParents.has(parent)) continue;
|
|
97
|
+
const events = await readInbox(host, parent);
|
|
98
|
+
const lastByChild = new Map<number, NotifyEvent>();
|
|
99
|
+
for (const e of events) lastByChild.set(e.child_pid, e);
|
|
100
|
+
for (const [childPid, last] of lastByChild) {
|
|
101
|
+
const liveStarted = liveChildren.get(childPid);
|
|
102
|
+
if (liveStarted === undefined) continue; // reaped / gone — nothing to suppress
|
|
103
|
+
// Seed ONLY on a positively-matched identity. An event with no/zero
|
|
104
|
+
// child_started_at can't be verified, so we don't seed it — better to risk
|
|
105
|
+
// one duplicate edge than to suppress a recycled pid's first notification.
|
|
106
|
+
if (!last.child_started_at || liveStarted !== last.child_started_at) continue;
|
|
107
|
+
const seededState =
|
|
108
|
+
last.edge === "exited"
|
|
109
|
+
? "stopped"
|
|
110
|
+
: last.edge === "needs_input"
|
|
111
|
+
? "needs_input"
|
|
112
|
+
: last.edge === "idle"
|
|
113
|
+
? "idle"
|
|
114
|
+
: "active";
|
|
115
|
+
state.set(childPid, {
|
|
116
|
+
parent_pid: last.parent_pid,
|
|
117
|
+
wrapper_pid: last.child_wrapper_pid,
|
|
118
|
+
// Carry BOTH start times into the seeded state, so the hot-path pid-reuse
|
|
119
|
+
// guard can fire on the next observation and a synthetic exited from this
|
|
120
|
+
// seed still stamps identity (else the guard is blind until re-observed).
|
|
121
|
+
started_at: last.child_started_at,
|
|
122
|
+
parent_started_at: last.parent_started_at,
|
|
123
|
+
cli: last.cli,
|
|
124
|
+
cwd: last.cwd,
|
|
125
|
+
state: seededState,
|
|
126
|
+
// "Never suppress, duplicate OK": do NOT seed the idle/needs_input emitted
|
|
127
|
+
// memory. If notifyd was down while a child went idle→active→idle, the new
|
|
128
|
+
// idle episode (the "probably done" signal — this feature's whole point)
|
|
129
|
+
// must NOT be swallowed as the "same" pre-restart episode. So a restart
|
|
130
|
+
// RE-CONFIRMS idle/needs_input as a fresh episode (a duplicate on the wire
|
|
131
|
+
// is acceptable; a lost edge is not). Only `exited` — a terminal state
|
|
132
|
+
// that can't recur for the same child — keeps its emitted flag to avoid a
|
|
133
|
+
// pointless duplicate. Identity (start times) is still seeded so the
|
|
134
|
+
// hot-path pid-reuse guard works on the next observation.
|
|
135
|
+
idleSince: null,
|
|
136
|
+
idleEmitted: false,
|
|
137
|
+
inNeedsInput: false,
|
|
138
|
+
needsInputQuestion: null,
|
|
139
|
+
// Identity-aware: `last` already matched this incarnation's start time, so
|
|
140
|
+
// exitedEmitted is true ONLY if THIS child's last recorded edge is exited.
|
|
141
|
+
// A pid reused by a NEW child (whose last edge is e.g. idle) does NOT
|
|
142
|
+
// inherit the prior incarnation's exit — its own exit still fires.
|
|
143
|
+
exitedEmitted: last.edge === "exited",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return state;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface ObserveResult {
|
|
151
|
+
obs: ChildObservation[];
|
|
152
|
+
/** child pid → the parent's started_at, for the pid-reuse guard. */
|
|
153
|
+
parentStartedAt: Map<number, number>;
|
|
154
|
+
/** child pid → the child's OWN started_at, stamped into events (reuse guard). */
|
|
155
|
+
childStartedAt: Map<number, number>;
|
|
156
|
+
/** log file per child pid, for payload enrichment. */
|
|
157
|
+
logFiles: Map<number, string | null>;
|
|
158
|
+
/**
|
|
159
|
+
* pid → started_at for EVERY currently-alive child in the registry (regardless
|
|
160
|
+
* of whether its parent is watching) — so the router can tell a truly-dead (or
|
|
161
|
+
* pid-reused) child from one it merely stopped observing when a watcher lapsed,
|
|
162
|
+
* identity-aware.
|
|
163
|
+
*/
|
|
164
|
+
aliveChildStartedAt: Map<number, number>;
|
|
165
|
+
watcherCount: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Observe every child WHOSE PARENT IS WATCHING. Nothing else gets an inbox. */
|
|
169
|
+
async function observeChildren(): Promise<ObserveResult> {
|
|
170
|
+
const watching = await liveWatchers();
|
|
171
|
+
const records = await listRecords(undefined, LS_OPTS);
|
|
172
|
+
// Parent wrapper pid → its own started_at (for the pid-reuse stamp).
|
|
173
|
+
const startedAtByWrapper = new Map<number, number>();
|
|
174
|
+
for (const r of records) {
|
|
175
|
+
if (typeof r.wrapper_pid === "number" && r.wrapper_pid > 0)
|
|
176
|
+
startedAtByWrapper.set(r.wrapper_pid, r.started_at);
|
|
177
|
+
}
|
|
178
|
+
const obs: ChildObservation[] = [];
|
|
179
|
+
const parentStartedAt = new Map<number, number>();
|
|
180
|
+
const childStartedAt = new Map<number, number>();
|
|
181
|
+
const logFiles = new Map<number, string | null>();
|
|
182
|
+
// Liveness of ALL children (any parent), pid → started_at — the router uses
|
|
183
|
+
// this to avoid false-exiting a child it merely stopped observing (watcher
|
|
184
|
+
// lapsed), while still exiting a pid reused by a different child. Only a CURRENT
|
|
185
|
+
// (non-exited) record whose pid is alive counts as liveness evidence: a stale
|
|
186
|
+
// `exited` record must not vouch for a pid that a different process now reuses
|
|
187
|
+
// (which would make the router suppress the old child's exited forever).
|
|
188
|
+
const aliveChildStartedAt = new Map<number, number>();
|
|
189
|
+
for (const r of records) {
|
|
190
|
+
if (
|
|
191
|
+
typeof r.parent_pid === "number" &&
|
|
192
|
+
r.parent_pid > 0 &&
|
|
193
|
+
r.status !== "exited" &&
|
|
194
|
+
isPidAlive(r.pid)
|
|
195
|
+
)
|
|
196
|
+
aliveChildStartedAt.set(r.pid, r.started_at);
|
|
197
|
+
}
|
|
198
|
+
for (const r of records) {
|
|
199
|
+
const parent = r.parent_pid;
|
|
200
|
+
if (typeof parent !== "number" || parent <= 0) continue;
|
|
201
|
+
if (!watching.has(parent)) continue; // scope: only watched parents' children
|
|
202
|
+
// Cross-session guard: a child cannot predate its parent, so a child of THIS
|
|
203
|
+
// watcher incarnation must have started at/after it. Exclude a stale orphan
|
|
204
|
+
// spawned under a PRIOR agent that held this pid (now recycled by the current
|
|
205
|
+
// watcher) — otherwise its state/tail/question would be mis-delivered to an
|
|
206
|
+
// unrelated session. (The child record carries parent_pid but not the parent's
|
|
207
|
+
// start time, so we rely on the invariant child.started_at >= parent.started_at;
|
|
208
|
+
// pids are unique among live processes, so pid-match + this bound pins the
|
|
209
|
+
// exact incarnation.)
|
|
210
|
+
const watcherStart = watching.get(parent) ?? 0;
|
|
211
|
+
if (watcherStart > 0 && r.started_at < watcherStart) continue;
|
|
212
|
+
const { state, question } = await deriveLiveState(r);
|
|
213
|
+
// Parent start time: prefer the WATCHER's self-reported value (authoritative,
|
|
214
|
+
// never 0) over a registry-wrapper lookup that can miss and stamp a 0 — a 0
|
|
215
|
+
// would slip past the reader's parent pid-reuse guard.
|
|
216
|
+
const parentStart = watching.get(parent) || startedAtByWrapper.get(parent) || 0;
|
|
217
|
+
obs.push({
|
|
218
|
+
pid: r.pid,
|
|
219
|
+
wrapper_pid: r.wrapper_pid ?? undefined,
|
|
220
|
+
started_at: r.started_at,
|
|
221
|
+
parent_pid: parent,
|
|
222
|
+
parent_started_at: parentStart,
|
|
223
|
+
cli: r.cli,
|
|
224
|
+
cwd: r.cwd,
|
|
225
|
+
state,
|
|
226
|
+
question,
|
|
227
|
+
});
|
|
228
|
+
parentStartedAt.set(r.pid, parentStart);
|
|
229
|
+
childStartedAt.set(r.pid, r.started_at);
|
|
230
|
+
logFiles.set(r.pid, r.log_file ?? null);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
obs,
|
|
234
|
+
parentStartedAt,
|
|
235
|
+
childStartedAt,
|
|
236
|
+
logFiles,
|
|
237
|
+
aliveChildStartedAt,
|
|
238
|
+
watcherCount: watching.size,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Registry snapshot: every record's pid → its started_at (for reconcile guard). */
|
|
243
|
+
async function liveChildrenSnapshot(): Promise<Map<number, number>> {
|
|
244
|
+
const records = await listRecords(undefined, LS_OPTS).catch(() => []);
|
|
245
|
+
const m = new Map<number, number>();
|
|
246
|
+
for (const r of records) m.set(r.pid, r.started_at);
|
|
247
|
+
return m;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface DaemonOptions {
|
|
251
|
+
intervalMs?: number;
|
|
252
|
+
/** Run a single tick and return (for tests / `--once`), no lock, no loop. */
|
|
253
|
+
once?: boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// How long an owner heartbeat stays "trusted". A background timer refreshes it
|
|
257
|
+
// every OWNER_TTL/3 (decoupled from tick duration). Set comfortably ABOVE any
|
|
258
|
+
// realistic SYNCHRONOUS event-loop block, so a slow tick can't let the heartbeat
|
|
259
|
+
// go stale and have another `watch` steal the singleton lock → double daemon.
|
|
260
|
+
// The daemon loop is all `await`s (log render + git are async, reconcile runs
|
|
261
|
+
// once at startup), so it never blocks the loop for seconds — but a single-
|
|
262
|
+
// threaded JS process can only bound, not eliminate, an event-loop-block false
|
|
263
|
+
// steal (a worker-isolated heartbeat would be needed to close it; tracked as a
|
|
264
|
+
// follow-up). 30s also bounds the `stop` pid-reuse window (mitigated further by
|
|
265
|
+
// the pid+started_at re-check before SIGTERM).
|
|
266
|
+
const OWNER_TTL_MS = 30_000;
|
|
267
|
+
|
|
268
|
+
export interface DaemonIdentity {
|
|
269
|
+
pid: number;
|
|
270
|
+
started_at: number;
|
|
271
|
+
ts: number;
|
|
272
|
+
/** The owner fencing token at validation time — pins THIS incarnation. */
|
|
273
|
+
token: string | null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The daemon's start time, captured ONCE when it acquires the lock. The
|
|
277
|
+
// heartbeat updates only `ts` — never `started_at` — so `stop`'s two-read
|
|
278
|
+
// identity check (pid + started_at unchanged) can't see it move and mistake the
|
|
279
|
+
// running daemon for "not running".
|
|
280
|
+
let daemonStartedAt = 0;
|
|
281
|
+
// Fencing token for THIS daemon's lock ownership (see the inbox lock). We only
|
|
282
|
+
// overwrite the owner file or delete the lock while it still carries our token,
|
|
283
|
+
// so a stale-stolen daemon can't clobber or delete the new daemon's lock.
|
|
284
|
+
let daemonToken = "";
|
|
285
|
+
|
|
286
|
+
/** The token currently written in the daemon lock owner file, or null. */
|
|
287
|
+
async function readDaemonOwnerToken(): Promise<string | null> {
|
|
288
|
+
try {
|
|
289
|
+
const o = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8")) as { token?: string };
|
|
290
|
+
return o.token ?? null;
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Stamp the lock owner file with our identity + a fresh heartbeat ts + token,
|
|
298
|
+
* ATOMICALLY (temp + rename) so a reader (status/stop/another daemon) never sees
|
|
299
|
+
* a torn owner mid-write — a null read then reliably means "no owner file yet".
|
|
300
|
+
*/
|
|
301
|
+
async function writeOwner(): Promise<boolean> {
|
|
302
|
+
const tmp = `${daemonLockOwnerPath()}.${daemonToken}.tmp`;
|
|
303
|
+
try {
|
|
304
|
+
await writeFile(
|
|
305
|
+
tmp,
|
|
306
|
+
JSON.stringify({ pid: process.pid, started_at: daemonStartedAt, ts: Date.now(), token: daemonToken }),
|
|
307
|
+
);
|
|
308
|
+
await rename(tmp, daemonLockOwnerPath());
|
|
309
|
+
return true;
|
|
310
|
+
} catch {
|
|
311
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Acquire the singleton lock. Returns true if WE now hold it, false if a LIVE
|
|
318
|
+
* daemon already does. Shares the inbox lock's invariant: ownership is proven by
|
|
319
|
+
* `mkdir(recursive:false)`, and a lock is stolen only when its owner is dead or
|
|
320
|
+
* its heartbeat is stale — never on a torn/empty owner WITHIN the grace (the
|
|
321
|
+
* mkdir→writeOwner window of another daemon coming up), so two concurrent starts
|
|
322
|
+
* can't both "win". The liveness predicate is injectable for tests.
|
|
323
|
+
*/
|
|
324
|
+
export async function acquireDaemonLock(isAlive: (pid: number) => boolean = isPidAlive): Promise<boolean> {
|
|
325
|
+
// The lock dir lives under notify/ — ensure that exists first, else mkdir of
|
|
326
|
+
// the lock throws ENOENT which must NOT be mistaken for "someone holds it".
|
|
327
|
+
await mkdir(notifyDir(), { recursive: true }).catch(() => {});
|
|
328
|
+
for (;;) {
|
|
329
|
+
try {
|
|
330
|
+
await mkdir(daemonLockDir(), { recursive: false });
|
|
331
|
+
daemonStartedAt = Date.now(); // fixed for this daemon's lifetime
|
|
332
|
+
daemonToken = randomUUID(); // fencing token for this ownership
|
|
333
|
+
// If we can't write our owner file, we don't own the lock — drop and retry.
|
|
334
|
+
if (!(await writeOwner())) {
|
|
335
|
+
await rm(daemonLockDir(), { recursive: true, force: true }).catch(() => {});
|
|
336
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; // real error — propagate
|
|
342
|
+
const raw = await readFile(daemonLockOwnerPath(), "utf8").catch(() => "");
|
|
343
|
+
const now = Date.now();
|
|
344
|
+
// A COMPLETE, live, fresh owner belonging to a DIFFERENT pid → another
|
|
345
|
+
// daemon is already running; we are not it.
|
|
346
|
+
let owner: { pid?: number; ts?: number } | null = null;
|
|
347
|
+
try {
|
|
348
|
+
owner = JSON.parse(raw) as { pid: number; ts: number };
|
|
349
|
+
} catch {
|
|
350
|
+
/* torn / not-yet-written */
|
|
351
|
+
}
|
|
352
|
+
if (
|
|
353
|
+
owner &&
|
|
354
|
+
typeof owner.pid === "number" &&
|
|
355
|
+
owner.pid > 0 &&
|
|
356
|
+
owner.pid !== process.pid &&
|
|
357
|
+
typeof owner.ts === "number" &&
|
|
358
|
+
now - owner.ts <= OWNER_TTL_MS &&
|
|
359
|
+
isAlive(owner.pid)
|
|
360
|
+
) {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
// Otherwise steal per the SHARED decision (dead / heartbeat-stale /
|
|
364
|
+
// torn-past-grace) — the same invariant the per-inbox lock uses, so a
|
|
365
|
+
// torn owner (the mkdir→writeOwner window of a concurrent daemon start) is
|
|
366
|
+
// respected within the grace and two starts can't both win. Else wait.
|
|
367
|
+
const lockAgeMs =
|
|
368
|
+
now -
|
|
369
|
+
(await stat(daemonLockDir())
|
|
370
|
+
.then((s) => s.mtimeMs)
|
|
371
|
+
.catch(() => now));
|
|
372
|
+
if (
|
|
373
|
+
shouldStealLock(raw, now, {
|
|
374
|
+
staleMs: OWNER_TTL_MS,
|
|
375
|
+
lockAgeMs,
|
|
376
|
+
selfPid: process.pid,
|
|
377
|
+
isAlive,
|
|
378
|
+
})
|
|
379
|
+
) {
|
|
380
|
+
await rm(daemonLockDir(), { recursive: true, force: true }).catch(() => {});
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Run the daemon loop in the FOREGROUND. `ay notifyd start` spawns this detached;
|
|
390
|
+
* `ay notifyd run` runs it inline. Holds the host singleton lock for its lifetime.
|
|
391
|
+
*/
|
|
392
|
+
export async function runDaemon(opts: DaemonOptions = {}): Promise<number> {
|
|
393
|
+
const host = hostId();
|
|
394
|
+
const intervalMs = opts.intervalMs ?? POLL_MS;
|
|
395
|
+
|
|
396
|
+
if (opts.once) {
|
|
397
|
+
// A single reconciled tick (test/debug). Skip entirely if a real daemon is
|
|
398
|
+
// already running — otherwise we'd re-emit baselines it has handled — and
|
|
399
|
+
// seed from the inbox so even standalone we don't duplicate prior edges.
|
|
400
|
+
if (await daemonStatus()) return 0;
|
|
401
|
+
const watched = new Set((await liveWatchers()).keys());
|
|
402
|
+
const prev = await reconcileFromInboxes(host, await liveChildrenSnapshot(), watched);
|
|
403
|
+
await tickState(host, prev);
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!(await acquireDaemonLock())) {
|
|
408
|
+
logger.debug("[notifyd] another live daemon holds the lock — exiting");
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let running = true;
|
|
413
|
+
// Heartbeat the owner ts on a BACKGROUND timer (not once per loop) so a slow
|
|
414
|
+
// tick — many watched children, slow log I/O — can't let the heartbeat cross
|
|
415
|
+
// OWNER_TTL and have another `watch` steal the lock as "stale" → double daemon.
|
|
416
|
+
// Refresh well inside the TTL; cleared on shutdown.
|
|
417
|
+
const ownerBeat = setInterval(() => {
|
|
418
|
+
void (async () => {
|
|
419
|
+
// Refresh ONLY while the owner still carries OUR token. Any other value — a
|
|
420
|
+
// different token (superseded) OR null/absent (a new daemon's mkdir→write
|
|
421
|
+
// window) — means we no longer own it, so stop rather than clobber an
|
|
422
|
+
// unknown/absent owner. Atomic writeOwner means a null read is never our own
|
|
423
|
+
// write in flight.
|
|
424
|
+
if ((await readDaemonOwnerToken()) !== daemonToken) {
|
|
425
|
+
clearInterval(ownerBeat);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
await writeOwner();
|
|
429
|
+
})();
|
|
430
|
+
}, Math.max(1000, Math.floor(OWNER_TTL_MS / 3)));
|
|
431
|
+
if (typeof ownerBeat.unref === "function") ownerBeat.unref();
|
|
432
|
+
const cleanup = async () => {
|
|
433
|
+
running = false;
|
|
434
|
+
clearInterval(ownerBeat);
|
|
435
|
+
// Delete the lock ONLY if it still carries our token — never remove a lock a
|
|
436
|
+
// newer daemon now owns.
|
|
437
|
+
if ((await readDaemonOwnerToken()) === daemonToken)
|
|
438
|
+
await rm(daemonLockDir(), { recursive: true, force: true }).catch(() => {});
|
|
439
|
+
};
|
|
440
|
+
process.on("SIGINT", () => void cleanup().then(() => process.exit(0)));
|
|
441
|
+
process.on("SIGTERM", () => void cleanup().then(() => process.exit(0)));
|
|
442
|
+
|
|
443
|
+
// Seed the router from prior inbox state — scoped to currently-watched parents
|
|
444
|
+
// and guarded against pid reuse by the current registry snapshot.
|
|
445
|
+
const watchedAtStart = new Set((await liveWatchers()).keys());
|
|
446
|
+
let prev = await reconcileFromInboxes(
|
|
447
|
+
host,
|
|
448
|
+
await liveChildrenSnapshot(),
|
|
449
|
+
watchedAtStart,
|
|
450
|
+
);
|
|
451
|
+
let ticks = 0;
|
|
452
|
+
let emptySince: number | null = null;
|
|
453
|
+
// Durable across ticks: edges whose append failed, retried until they land.
|
|
454
|
+
const pendingRetry = new Map<string, Omit<NotifyEvent, "seq">>();
|
|
455
|
+
|
|
456
|
+
while (running) {
|
|
457
|
+
// Cooperative stop: `notify notifyd stop` removes our lock rather than
|
|
458
|
+
// SIGTERM-ing a possibly-recycled pid. If our lock is gone (or taken over),
|
|
459
|
+
// exit gracefully.
|
|
460
|
+
if ((await readDaemonOwnerToken()) !== daemonToken) {
|
|
461
|
+
logger.debug("[notifyd] lock lost/removed — exiting");
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
const { next, watcherCount } = await tickState(host, prev, pendingRetry);
|
|
465
|
+
prev = next;
|
|
466
|
+
ticks++;
|
|
467
|
+
if (ticks % GC_EVERY_TICKS === 0) await gcTick(host).catch(() => {});
|
|
468
|
+
|
|
469
|
+
// Self-exit when no parent is watching for a grace window.
|
|
470
|
+
if (watcherCount > 0) emptySince = null;
|
|
471
|
+
else if (emptySince == null) emptySince = Date.now();
|
|
472
|
+
if (emptySince != null && Date.now() - emptySince > IDLE_EXIT_GRACE_MS) {
|
|
473
|
+
logger.debug("[notifyd] no watchers — exiting");
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
477
|
+
}
|
|
478
|
+
await cleanup();
|
|
479
|
+
return 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* A durable-in-memory retry queue for edges whose append failed. Keyed by the
|
|
484
|
+
* event's identity so a pid reused by a different child never collides (the key
|
|
485
|
+
* includes child_started_at). The router state is NOT rolled back on failure —
|
|
486
|
+
* re-delivery is owned entirely by this queue, so a failed OLD-child exited and
|
|
487
|
+
* the NEW same-pid child's edges are retried independently (at-least-once per
|
|
488
|
+
* event). Bounded so a permanently-unwritable inbox can't grow it without bound.
|
|
489
|
+
*/
|
|
490
|
+
const RETRY_CAP = 1000;
|
|
491
|
+
function retryKey(e: Omit<NotifyEvent, "seq">): string {
|
|
492
|
+
return `${e.parent_pid}:${e.child_pid}:${e.child_started_at ?? 0}:${e.edge}:${e.ts}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function tickState(
|
|
496
|
+
host: string,
|
|
497
|
+
prev: RouterState,
|
|
498
|
+
pendingRetry: Map<string, Omit<NotifyEvent, "seq">> = new Map(),
|
|
499
|
+
): Promise<{ next: RouterState; watcherCount: number }> {
|
|
500
|
+
// First, retry anything a previous tick couldn't append (transient FS/lock
|
|
501
|
+
// blip). Success removes it; a persistent failure stays queued for next tick.
|
|
502
|
+
for (const [k, stored] of pendingRetry) {
|
|
503
|
+
if (await appendWithRetry(stored)) pendingRetry.delete(k);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const { obs, parentStartedAt, childStartedAt, logFiles, aliveChildStartedAt, watcherCount } =
|
|
507
|
+
await observeChildren();
|
|
508
|
+
const { events, next } = stepRouter(prev, obs, Date.now(), { aliveChildStartedAt });
|
|
509
|
+
|
|
510
|
+
// Enrich all edges CONCURRENTLY (a burst of N edges must not serialize N git
|
|
511
|
+
// timeouts). Enrichment is best-effort and never throws; the append that
|
|
512
|
+
// follows stays serial so per-inbox seq allocation is unambiguous.
|
|
513
|
+
const enriched = await Promise.all(
|
|
514
|
+
events.map(async (ev) => {
|
|
515
|
+
let tail: string | null = null;
|
|
516
|
+
let git_head: string | null = null;
|
|
517
|
+
if (ev.edge !== "exited") {
|
|
518
|
+
[tail, git_head] = await Promise.all([
|
|
519
|
+
recentTail(logFiles.get(ev.child_pid)).catch(() => null),
|
|
520
|
+
gitHead(ev.cwd).catch(() => null),
|
|
521
|
+
]);
|
|
522
|
+
}
|
|
523
|
+
const stored: Omit<NotifyEvent, "seq"> = {
|
|
524
|
+
ts: Date.now(),
|
|
525
|
+
host,
|
|
526
|
+
parent_pid: ev.parent_pid,
|
|
527
|
+
// The parent's/child's start times — the read side and the startup
|
|
528
|
+
// reconcile reject an event whose start time disagrees, guarding pid reuse.
|
|
529
|
+
// Carried by the router so a synthetic exited (child gone from the live
|
|
530
|
+
// set) still stamps the real parent start, not 0 (which would bypass the
|
|
531
|
+
// reader's parent guard). Fall back to the live map, then 0.
|
|
532
|
+
parent_started_at: ev.parent_started_at ?? parentStartedAt.get(ev.child_pid) ?? 0,
|
|
533
|
+
child_pid: ev.child_pid,
|
|
534
|
+
child_wrapper_pid: ev.child_wrapper_pid,
|
|
535
|
+
// Carried by the router (survives a synthetic exited for a vanished child,
|
|
536
|
+
// which is no longer in childStartedAt); fall back to the live map.
|
|
537
|
+
child_started_at: ev.child_started_at ?? childStartedAt.get(ev.child_pid) ?? 0,
|
|
538
|
+
cli: ev.cli,
|
|
539
|
+
cwd: ev.cwd,
|
|
540
|
+
edge: ev.edge,
|
|
541
|
+
prev_state: ev.prev_state,
|
|
542
|
+
state: ev.state,
|
|
543
|
+
question: ev.question,
|
|
544
|
+
tail,
|
|
545
|
+
git_head,
|
|
546
|
+
};
|
|
547
|
+
return stored;
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
for (const stored of enriched) {
|
|
551
|
+
if (await appendWithRetry(stored)) continue;
|
|
552
|
+
// at-least-once: a transient FS/lock failure must NOT drop an edge. Queue it
|
|
553
|
+
// by IDENTITY for re-append on a later tick — never touch the router state
|
|
554
|
+
// (which is keyed by pid and can't hold both an old and a new same-pid child).
|
|
555
|
+
if (pendingRetry.size < RETRY_CAP) {
|
|
556
|
+
pendingRetry.set(retryKey(stored), stored);
|
|
557
|
+
logger.warn(`[notifyd] append failed for parent ${stored.parent_pid} — queued for retry`);
|
|
558
|
+
} else {
|
|
559
|
+
logger.warn(`[notifyd] retry queue full (${RETRY_CAP}) — dropping edge for parent ${stored.parent_pid}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return { next, watcherCount };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Append with a few quick retries — a transient lock/FS blip shouldn't lose an edge. */
|
|
566
|
+
async function appendWithRetry(stored: Omit<NotifyEvent, "seq">, attempts = 3): Promise<boolean> {
|
|
567
|
+
for (let a = 0; a < attempts; a++) {
|
|
568
|
+
try {
|
|
569
|
+
await appendEvent(stored.parent_pid, stored);
|
|
570
|
+
return true;
|
|
571
|
+
} catch {
|
|
572
|
+
await new Promise((r) => setTimeout(r, 25 * (a + 1)));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function gcTick(host: string): Promise<void> {
|
|
579
|
+
const records = await listRecords(undefined, { ...LS_OPTS, all: true }).catch(() => []);
|
|
580
|
+
const livePids = new Set<number>();
|
|
581
|
+
const childParentPids = new Set<number>();
|
|
582
|
+
for (const r of records) {
|
|
583
|
+
if (isPidAlive(r.pid)) livePids.add(r.wrapper_pid ?? r.pid);
|
|
584
|
+
if (typeof r.parent_pid === "number" && r.parent_pid > 0 && isPidAlive(r.pid))
|
|
585
|
+
childParentPids.add(r.parent_pid);
|
|
586
|
+
}
|
|
587
|
+
await gcInboxes(host, livePids, childParentPids);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The running daemon's full identity, or null. The lock owner file is the SINGLE
|
|
592
|
+
* source of truth: trusted only if it is COMPLETE (pid + started_at + ts), its
|
|
593
|
+
* pid is alive, AND its heartbeat is fresh. A recycled pid isn't refreshing this
|
|
594
|
+
* file, so its stale ts (or a torn/partial owner) yields null — we never trust or
|
|
595
|
+
* kill an unrelated process. `stop` re-reads and re-checks this identity right
|
|
596
|
+
* before signalling, so it can't SIGTERM a pid recycled between status and stop.
|
|
597
|
+
*
|
|
598
|
+
* Residual edge (documented, not yet closed): a pid recycled onto an UNRELATED
|
|
599
|
+
* live process WITHIN the tight OWNER_TTL window (a few ticks) could be briefly
|
|
600
|
+
* trusted, since we compare our recorded `started_at` against the owner file, not
|
|
601
|
+
* against the OS's actual process start time (which is non-portable to read:
|
|
602
|
+
* `/proc/<pid>/stat` on Linux vs `proc_pidinfo`/`ps -o lstart` on macOS). The
|
|
603
|
+
* heartbeat freshness + tight TTL keep the window to seconds; a real OS
|
|
604
|
+
* start-time cross-check is deferred to a future issue.
|
|
605
|
+
*/
|
|
606
|
+
export async function daemonIdentity(now = Date.now()): Promise<DaemonIdentity | null> {
|
|
607
|
+
const raw = await readFile(daemonLockOwnerPath(), "utf8").catch(() => "");
|
|
608
|
+
try {
|
|
609
|
+
const o = JSON.parse(raw) as Partial<DaemonIdentity>;
|
|
610
|
+
if (
|
|
611
|
+
typeof o?.pid === "number" &&
|
|
612
|
+
o.pid > 0 &&
|
|
613
|
+
typeof o.started_at === "number" &&
|
|
614
|
+
o.started_at > 0 &&
|
|
615
|
+
typeof o.ts === "number" &&
|
|
616
|
+
now - o.ts <= OWNER_TTL_MS &&
|
|
617
|
+
isPidAlive(o.pid)
|
|
618
|
+
) {
|
|
619
|
+
return {
|
|
620
|
+
pid: o.pid,
|
|
621
|
+
started_at: o.started_at,
|
|
622
|
+
ts: o.ts,
|
|
623
|
+
token: typeof o.token === "string" ? o.token : null,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
} catch {
|
|
627
|
+
/* missing / torn owner — no daemon */
|
|
628
|
+
}
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The running daemon's pid, or null (see daemonIdentity for the trust rules). */
|
|
633
|
+
export async function daemonStatus(now = Date.now()): Promise<number | null> {
|
|
634
|
+
return (await daemonIdentity(now))?.pid ?? null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Cooperatively request the running daemon to stop, NON-DESTRUCTIVELY: remove its
|
|
639
|
+
* lock rather than SIGTERM-ing the pid. The daemon checks its owner token every
|
|
640
|
+
* tick and exits when the lock is gone, so a pid recycled onto an unrelated
|
|
641
|
+
* process is never signalled. Returns the daemon's pid if one was running, else
|
|
642
|
+
* null. (A wedged daemon that isn't ticking relies on OS/self-exit — a residual
|
|
643
|
+
* tracked in the follow-up issue.)
|
|
644
|
+
*/
|
|
645
|
+
export async function requestDaemonStop(): Promise<number | null> {
|
|
646
|
+
const id = await daemonIdentity();
|
|
647
|
+
if (!id || id.token === null) return null;
|
|
648
|
+
// Re-read the owner token right before removing the lock and compare it to the
|
|
649
|
+
// token we VALIDATED above (this incarnation's). If daemon A validated, then
|
|
650
|
+
// exited, and B took over before the rm, the current token is B's — different
|
|
651
|
+
// from A's — so we do NOT remove B's lock (which would stop the new daemon).
|
|
652
|
+
// The residual re-read→rm window is the unavoidable FS-single-CAS gap (bounded).
|
|
653
|
+
const cur = await readDaemonOwnerToken();
|
|
654
|
+
if (cur !== id.token) return null;
|
|
655
|
+
await rm(daemonLockDir(), { recursive: true, force: true }).catch(() => {});
|
|
656
|
+
return id.pid;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Ensure a daemon is running; best-effort spawn detached. Returns its pid or null. */
|
|
660
|
+
export async function ensureDaemon(): Promise<number | null> {
|
|
661
|
+
const existing = await daemonStatus();
|
|
662
|
+
if (existing) return existing;
|
|
663
|
+
const { spawn } = await import("node:child_process");
|
|
664
|
+
// Resolve our own launcher the way serve/schedule/restart do: on POSIX `ay` is
|
|
665
|
+
// a `#!/usr/bin/env bun` script (run via process.execPath), on Windows it's a
|
|
666
|
+
// self-contained ay.exe. Fall back to THIS process's own script path
|
|
667
|
+
// (process.argv[1]) — which is always present — rather than a bare `ay` that a
|
|
668
|
+
// minimal PATH / absolute-path launch wouldn't resolve.
|
|
669
|
+
const ayBin = Bun.which("ay") ?? process.argv[1];
|
|
670
|
+
if (!ayBin) return null;
|
|
671
|
+
const launcher = process.platform === "win32" ? [ayBin] : [process.execPath, ayBin];
|
|
672
|
+
const [cmd, ...pre] = launcher;
|
|
673
|
+
const child = spawn(cmd!, [...pre, "notifyd", "run"], {
|
|
674
|
+
detached: true,
|
|
675
|
+
stdio: "ignore",
|
|
676
|
+
});
|
|
677
|
+
// spawn's ENOENT surfaces ASYNC as an 'error' event — without a handler it
|
|
678
|
+
// crashes the process. Swallow it; the daemonStatus() check below reports the
|
|
679
|
+
// real outcome (null if the spawn didn't come up).
|
|
680
|
+
child.on("error", () => {});
|
|
681
|
+
child.unref();
|
|
682
|
+
// Poll for the daemon to come up with a bounded φ-backoff (golden-ratio, the
|
|
683
|
+
// repo convention) instead of a single fixed wait — a cold `bun` start or slow
|
|
684
|
+
// FS can take longer than any one fixed delay, so a fixed wait falsely reports
|
|
685
|
+
// "failed". Early-return the moment it's up; give up after a bounded total.
|
|
686
|
+
const PHI = 1.618;
|
|
687
|
+
let delay = 50;
|
|
688
|
+
let waited = 0;
|
|
689
|
+
const BUDGET_MS = 3000;
|
|
690
|
+
for (;;) {
|
|
691
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
692
|
+
waited += delay;
|
|
693
|
+
const pid = await daemonStatus();
|
|
694
|
+
if (pid) return pid;
|
|
695
|
+
if (waited >= BUDGET_MS) return null;
|
|
696
|
+
delay = Math.min(Math.round(delay * PHI), 800);
|
|
697
|
+
}
|
|
698
|
+
}
|