agent-yes 1.169.1 → 1.171.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/dist/SUPPORTED_CLIS-BMExykB1.js +8 -0
- package/dist/{SUPPORTED_CLIS-Bwg9VqLI.js → SUPPORTED_CLIS-D6MArV7A.js} +2 -2
- 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-BlGV0y4j.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-mYbyV0SD.js +228 -0
- package/dist/{schedule-CtVvSyaN.js → schedule-C5nUV0GO.js} +7 -7
- package/dist/{serve-CLmk5ujF.js → serve-ChcayCC3.js} +122 -26
- package/dist/{setup-CYbXOcpH.js → setup-CCQZ9tPB.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-CIk1SSdW.js +9 -0
- package/dist/{subcommands--EUush_X.js → subcommands-CtWxwydR.js} +1049 -44
- package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
- package/dist/{ts-Bbhf5JbN.js → ts-WHYKp13w.js} +8 -8
- package/dist/{versionChecker-BmWkWhcJ.js → versionChecker-BoPKPL9B.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 +125 -10
- package/lab/ui/index.html +340 -11
- 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 +194 -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,591 @@
|
|
|
1
|
+
import { n as logger } from "./logger-CDIsZ-Pp.js";
|
|
2
|
+
import "./globalPidIndex-CoNr7tS8.js";
|
|
3
|
+
import "./configShared-0MnIQ652.js";
|
|
4
|
+
import { D as renderLogTailLines, G as hostId, J as readInbox, K as listInboxParents, Q as notifyDir, S as listRecords, U as appendEvent, W as gcInboxes, X as daemonLockDir, Y as shouldStealLock, Z as daemonLockOwnerPath, _ as isPidAlive, c as deriveLiveState, q as liveWatchers } from "./subcommands-CtWxwydR.js";
|
|
5
|
+
import "./e2e-jb0Hp43q.js";
|
|
6
|
+
import "./webrtcLink-B7REGtK2.js";
|
|
7
|
+
import "./remotes-CC-4GuJb.js";
|
|
8
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
//#region ts/notifyRouter.ts
|
|
13
|
+
const DEFAULT_IDLE_CONFIRM_MS = 3e4;
|
|
14
|
+
/**
|
|
15
|
+
* Advance the router by one tick. Returns the notifications to push plus the
|
|
16
|
+
* next RouterState. Only children with a numeric `parent_pid` are considered —
|
|
17
|
+
* the caller is responsible for including only opted-in children.
|
|
18
|
+
*
|
|
19
|
+
* First-observation (baseline / startup-reconcile) semantics: a child we've
|
|
20
|
+
* never seen that is ALREADY terminal emits immediately for `needs_input` /
|
|
21
|
+
* `stopped`, and starts its idle timer at `now` for `idle` (so a child parked
|
|
22
|
+
* idle when the daemon starts still notifies after the confirm window). To avoid
|
|
23
|
+
* re-emitting the same baseline on every daemon restart, the caller SEEDS the
|
|
24
|
+
* prior state from each inbox's already-written edges (see cmdNotifyd).
|
|
25
|
+
*/
|
|
26
|
+
function stepRouter(prev, observations, now, config = {}) {
|
|
27
|
+
const idleConfirmMs = config.idleConfirmMs ?? DEFAULT_IDLE_CONFIRM_MS;
|
|
28
|
+
const events = [];
|
|
29
|
+
const next = /* @__PURE__ */ new Map();
|
|
30
|
+
const seen = /* @__PURE__ */ new Set();
|
|
31
|
+
for (const obs of observations) {
|
|
32
|
+
if (typeof obs.parent_pid !== "number" || obs.parent_pid <= 0) continue;
|
|
33
|
+
seen.add(obs.pid);
|
|
34
|
+
let p = prev.get(obs.pid);
|
|
35
|
+
if (p && obs.started_at != null && p.started_at != null && p.started_at !== obs.started_at) {
|
|
36
|
+
if (!p.exitedEmitted) events.push(syntheticExited(p, obs.pid));
|
|
37
|
+
p = void 0;
|
|
38
|
+
} else if (p && obs.started_at != null && p.started_at == null) p = void 0;
|
|
39
|
+
const cs = {
|
|
40
|
+
parent_pid: obs.parent_pid,
|
|
41
|
+
wrapper_pid: obs.wrapper_pid,
|
|
42
|
+
started_at: obs.started_at,
|
|
43
|
+
parent_started_at: obs.parent_started_at,
|
|
44
|
+
cli: obs.cli,
|
|
45
|
+
cwd: obs.cwd,
|
|
46
|
+
state: obs.state,
|
|
47
|
+
idleSince: p?.idleSince ?? null,
|
|
48
|
+
idleEmitted: p?.idleEmitted ?? false,
|
|
49
|
+
inNeedsInput: p?.inNeedsInput ?? false,
|
|
50
|
+
needsInputQuestion: p?.needsInputQuestion ?? null,
|
|
51
|
+
exitedEmitted: p?.exitedEmitted ?? false
|
|
52
|
+
};
|
|
53
|
+
const prevState = p?.state ?? null;
|
|
54
|
+
const emit = (edge) => events.push({
|
|
55
|
+
parent_pid: obs.parent_pid,
|
|
56
|
+
child_pid: obs.pid,
|
|
57
|
+
child_wrapper_pid: obs.wrapper_pid,
|
|
58
|
+
child_started_at: obs.started_at,
|
|
59
|
+
parent_started_at: obs.parent_started_at,
|
|
60
|
+
cli: obs.cli,
|
|
61
|
+
cwd: obs.cwd,
|
|
62
|
+
edge,
|
|
63
|
+
prev_state: prevState,
|
|
64
|
+
state: obs.state,
|
|
65
|
+
question: edge === "needs_input" ? obs.question : null
|
|
66
|
+
});
|
|
67
|
+
switch (obs.state) {
|
|
68
|
+
case "needs_input":
|
|
69
|
+
cs.idleSince = null;
|
|
70
|
+
cs.idleEmitted = false;
|
|
71
|
+
if (!cs.inNeedsInput || cs.needsInputQuestion !== obs.question) {
|
|
72
|
+
emit("needs_input");
|
|
73
|
+
cs.inNeedsInput = true;
|
|
74
|
+
cs.needsInputQuestion = obs.question;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
case "idle":
|
|
78
|
+
cs.inNeedsInput = false;
|
|
79
|
+
cs.needsInputQuestion = null;
|
|
80
|
+
if (p?.state === "idle" && p.idleSince != null) {
|
|
81
|
+
cs.idleSince = p.idleSince;
|
|
82
|
+
cs.idleEmitted = p.idleEmitted;
|
|
83
|
+
} else {
|
|
84
|
+
cs.idleSince = now;
|
|
85
|
+
cs.idleEmitted = false;
|
|
86
|
+
}
|
|
87
|
+
if (!cs.idleEmitted && now - (cs.idleSince ?? now) >= idleConfirmMs) {
|
|
88
|
+
emit("idle");
|
|
89
|
+
cs.idleEmitted = true;
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
case "stopped":
|
|
93
|
+
cs.idleSince = null;
|
|
94
|
+
cs.idleEmitted = false;
|
|
95
|
+
cs.inNeedsInput = false;
|
|
96
|
+
cs.needsInputQuestion = null;
|
|
97
|
+
if (!cs.exitedEmitted) {
|
|
98
|
+
emit("exited");
|
|
99
|
+
cs.exitedEmitted = true;
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
default:
|
|
103
|
+
cs.idleSince = null;
|
|
104
|
+
cs.idleEmitted = false;
|
|
105
|
+
cs.inNeedsInput = false;
|
|
106
|
+
cs.needsInputQuestion = null;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
next.set(obs.pid, cs);
|
|
110
|
+
}
|
|
111
|
+
for (const [pid, cs] of prev) {
|
|
112
|
+
if (seen.has(pid)) continue;
|
|
113
|
+
if (config.aliveChildStartedAt) {
|
|
114
|
+
const liveStart = config.aliveChildStartedAt.get(pid);
|
|
115
|
+
if (liveStart !== void 0 && (cs.started_at == null || liveStart === cs.started_at)) {
|
|
116
|
+
next.set(pid, cs);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!cs.exitedEmitted) events.push(syntheticExited(cs, pid));
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
events,
|
|
124
|
+
next
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A synthetic `exited` for a child that left the live set without our observing
|
|
129
|
+
* its stop — either reaped between ticks, or displaced by a pid-reuse. Built from
|
|
130
|
+
* the last-known router state so it still carries the child's identity.
|
|
131
|
+
*/
|
|
132
|
+
function syntheticExited(cs, pid) {
|
|
133
|
+
return {
|
|
134
|
+
parent_pid: cs.parent_pid,
|
|
135
|
+
child_pid: pid,
|
|
136
|
+
child_wrapper_pid: cs.wrapper_pid,
|
|
137
|
+
child_started_at: cs.started_at,
|
|
138
|
+
parent_started_at: cs.parent_started_at,
|
|
139
|
+
cli: cs.cli,
|
|
140
|
+
cwd: cs.cwd,
|
|
141
|
+
edge: "exited",
|
|
142
|
+
prev_state: cs.state,
|
|
143
|
+
state: "stopped",
|
|
144
|
+
question: null
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region ts/notifyDaemon.ts
|
|
150
|
+
/**
|
|
151
|
+
* `ay notifyd` — the always-on-while-watched detection engine. Polls every
|
|
152
|
+
* WATCHED agent's live state (the runtime-agnostic query layer, so BOTH Rust and
|
|
153
|
+
* TS children are covered), runs the pure debounce router (`notifyRouter.ts`),
|
|
154
|
+
* and appends the decided edges — enriched with a payload the parent can act on
|
|
155
|
+
* without tailing — into each parent's inbox (`notifyStore.ts`).
|
|
156
|
+
*
|
|
157
|
+
* Lifecycle: a host singleton, guarded by an mkdir lock that records its owner
|
|
158
|
+
* pid so a crashed daemon's stale lock is detected and STOLEN (never a permanent
|
|
159
|
+
* deadlock). Scope + liveness are driven by the WATCHER REGISTRY: the daemon only
|
|
160
|
+
* processes children whose parent is currently running `ay notify watch` (so an
|
|
161
|
+
* unrelated agent never gets an inbox), and it stays alive as long as any watcher
|
|
162
|
+
* heartbeat is live, self-exiting only after a grace window with no watchers.
|
|
163
|
+
* `ay notify watch` re-ensures the daemon every poll, so a parent that watches
|
|
164
|
+
* BEFORE spawning children (or across a fan-out gap) always has a live daemon.
|
|
165
|
+
*/
|
|
166
|
+
const POLL_MS = 2e3;
|
|
167
|
+
const GC_EVERY_TICKS = 30;
|
|
168
|
+
const IDLE_EXIT_GRACE_MS = 6e4;
|
|
169
|
+
const LS_OPTS = {
|
|
170
|
+
all: true,
|
|
171
|
+
active: false,
|
|
172
|
+
json: false,
|
|
173
|
+
latest: false,
|
|
174
|
+
cwdScope: null
|
|
175
|
+
};
|
|
176
|
+
/** Short git HEAD of a cwd, best-effort, never blocks emission. */
|
|
177
|
+
function gitHead(cwd) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
execFile("git", [
|
|
180
|
+
"-C",
|
|
181
|
+
cwd,
|
|
182
|
+
"rev-parse",
|
|
183
|
+
"--short",
|
|
184
|
+
"HEAD"
|
|
185
|
+
], {
|
|
186
|
+
timeout: 1500,
|
|
187
|
+
windowsHide: true
|
|
188
|
+
}, (err, stdout) => resolve(err ? null : stdout.trim() || null));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** Last few rendered log lines, compacted, so the parent needn't tail. */
|
|
192
|
+
async function recentTail(logFile) {
|
|
193
|
+
if (!logFile) return null;
|
|
194
|
+
const lines = await renderLogTailLines(logFile, 12).catch(() => null);
|
|
195
|
+
if (!lines) return null;
|
|
196
|
+
const compact = lines.map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim()).slice(-8);
|
|
197
|
+
return compact.length ? compact.join("\n").slice(0, 1200) : null;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Seed the router's prior state from what each inbox has ALREADY recorded, so a
|
|
201
|
+
* daemon restart does not re-emit a baseline the parent already saw.
|
|
202
|
+
*
|
|
203
|
+
* pid-reuse guard: seed a child ONLY if it is still in the registry with the
|
|
204
|
+
* SAME start time as the inbox recorded (`liveChildren`: pid → started_at). A pid
|
|
205
|
+
* whose live start time differs is a NEW child recycling the pid — seeding it
|
|
206
|
+
* would suppress its first notification, so we skip it (it starts fresh). A pid
|
|
207
|
+
* absent from the registry (reaped) is skipped too: it won't be observed again,
|
|
208
|
+
* and if the pid is later reused, that new child was never seeded.
|
|
209
|
+
*/
|
|
210
|
+
async function reconcileFromInboxes(host, liveChildren, watchedParents) {
|
|
211
|
+
const state = /* @__PURE__ */ new Map();
|
|
212
|
+
for (const parent of await listInboxParents(host)) {
|
|
213
|
+
if (!watchedParents.has(parent)) continue;
|
|
214
|
+
const events = await readInbox(host, parent);
|
|
215
|
+
const lastByChild = /* @__PURE__ */ new Map();
|
|
216
|
+
for (const e of events) lastByChild.set(e.child_pid, e);
|
|
217
|
+
for (const [childPid, last] of lastByChild) {
|
|
218
|
+
const liveStarted = liveChildren.get(childPid);
|
|
219
|
+
if (liveStarted === void 0) continue;
|
|
220
|
+
if (!last.child_started_at || liveStarted !== last.child_started_at) continue;
|
|
221
|
+
const seededState = last.edge === "exited" ? "stopped" : last.edge === "needs_input" ? "needs_input" : last.edge === "idle" ? "idle" : "active";
|
|
222
|
+
state.set(childPid, {
|
|
223
|
+
parent_pid: last.parent_pid,
|
|
224
|
+
wrapper_pid: last.child_wrapper_pid,
|
|
225
|
+
started_at: last.child_started_at,
|
|
226
|
+
parent_started_at: last.parent_started_at,
|
|
227
|
+
cli: last.cli,
|
|
228
|
+
cwd: last.cwd,
|
|
229
|
+
state: seededState,
|
|
230
|
+
idleSince: null,
|
|
231
|
+
idleEmitted: false,
|
|
232
|
+
inNeedsInput: false,
|
|
233
|
+
needsInputQuestion: null,
|
|
234
|
+
exitedEmitted: last.edge === "exited"
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return state;
|
|
239
|
+
}
|
|
240
|
+
/** Observe every child WHOSE PARENT IS WATCHING. Nothing else gets an inbox. */
|
|
241
|
+
async function observeChildren() {
|
|
242
|
+
const watching = await liveWatchers();
|
|
243
|
+
const records = await listRecords(void 0, LS_OPTS);
|
|
244
|
+
const startedAtByWrapper = /* @__PURE__ */ new Map();
|
|
245
|
+
for (const r of records) if (typeof r.wrapper_pid === "number" && r.wrapper_pid > 0) startedAtByWrapper.set(r.wrapper_pid, r.started_at);
|
|
246
|
+
const obs = [];
|
|
247
|
+
const parentStartedAt = /* @__PURE__ */ new Map();
|
|
248
|
+
const childStartedAt = /* @__PURE__ */ new Map();
|
|
249
|
+
const logFiles = /* @__PURE__ */ new Map();
|
|
250
|
+
const aliveChildStartedAt = /* @__PURE__ */ new Map();
|
|
251
|
+
for (const r of records) if (typeof r.parent_pid === "number" && r.parent_pid > 0 && r.status !== "exited" && isPidAlive(r.pid)) aliveChildStartedAt.set(r.pid, r.started_at);
|
|
252
|
+
for (const r of records) {
|
|
253
|
+
const parent = r.parent_pid;
|
|
254
|
+
if (typeof parent !== "number" || parent <= 0) continue;
|
|
255
|
+
if (!watching.has(parent)) continue;
|
|
256
|
+
const watcherStart = watching.get(parent) ?? 0;
|
|
257
|
+
if (watcherStart > 0 && r.started_at < watcherStart) continue;
|
|
258
|
+
const { state, question } = await deriveLiveState(r);
|
|
259
|
+
const parentStart = watching.get(parent) || startedAtByWrapper.get(parent) || 0;
|
|
260
|
+
obs.push({
|
|
261
|
+
pid: r.pid,
|
|
262
|
+
wrapper_pid: r.wrapper_pid ?? void 0,
|
|
263
|
+
started_at: r.started_at,
|
|
264
|
+
parent_pid: parent,
|
|
265
|
+
parent_started_at: parentStart,
|
|
266
|
+
cli: r.cli,
|
|
267
|
+
cwd: r.cwd,
|
|
268
|
+
state,
|
|
269
|
+
question
|
|
270
|
+
});
|
|
271
|
+
parentStartedAt.set(r.pid, parentStart);
|
|
272
|
+
childStartedAt.set(r.pid, r.started_at);
|
|
273
|
+
logFiles.set(r.pid, r.log_file ?? null);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
obs,
|
|
277
|
+
parentStartedAt,
|
|
278
|
+
childStartedAt,
|
|
279
|
+
logFiles,
|
|
280
|
+
aliveChildStartedAt,
|
|
281
|
+
watcherCount: watching.size
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Registry snapshot: every record's pid → its started_at (for reconcile guard). */
|
|
285
|
+
async function liveChildrenSnapshot() {
|
|
286
|
+
const records = await listRecords(void 0, LS_OPTS).catch(() => []);
|
|
287
|
+
const m = /* @__PURE__ */ new Map();
|
|
288
|
+
for (const r of records) m.set(r.pid, r.started_at);
|
|
289
|
+
return m;
|
|
290
|
+
}
|
|
291
|
+
const OWNER_TTL_MS = 3e4;
|
|
292
|
+
let daemonStartedAt = 0;
|
|
293
|
+
let daemonToken = "";
|
|
294
|
+
/** The token currently written in the daemon lock owner file, or null. */
|
|
295
|
+
async function readDaemonOwnerToken() {
|
|
296
|
+
try {
|
|
297
|
+
return JSON.parse(await readFile(daemonLockOwnerPath(), "utf8")).token ?? null;
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Stamp the lock owner file with our identity + a fresh heartbeat ts + token,
|
|
304
|
+
* ATOMICALLY (temp + rename) so a reader (status/stop/another daemon) never sees
|
|
305
|
+
* a torn owner mid-write — a null read then reliably means "no owner file yet".
|
|
306
|
+
*/
|
|
307
|
+
async function writeOwner() {
|
|
308
|
+
const tmp = `${daemonLockOwnerPath()}.${daemonToken}.tmp`;
|
|
309
|
+
try {
|
|
310
|
+
await writeFile(tmp, JSON.stringify({
|
|
311
|
+
pid: process.pid,
|
|
312
|
+
started_at: daemonStartedAt,
|
|
313
|
+
ts: Date.now(),
|
|
314
|
+
token: daemonToken
|
|
315
|
+
}));
|
|
316
|
+
await rename(tmp, daemonLockOwnerPath());
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Acquire the singleton lock. Returns true if WE now hold it, false if a LIVE
|
|
325
|
+
* daemon already does. Shares the inbox lock's invariant: ownership is proven by
|
|
326
|
+
* `mkdir(recursive:false)`, and a lock is stolen only when its owner is dead or
|
|
327
|
+
* its heartbeat is stale — never on a torn/empty owner WITHIN the grace (the
|
|
328
|
+
* mkdir→writeOwner window of another daemon coming up), so two concurrent starts
|
|
329
|
+
* can't both "win". The liveness predicate is injectable for tests.
|
|
330
|
+
*/
|
|
331
|
+
async function acquireDaemonLock(isAlive = isPidAlive) {
|
|
332
|
+
await mkdir(notifyDir(), { recursive: true }).catch(() => {});
|
|
333
|
+
for (;;) try {
|
|
334
|
+
await mkdir(daemonLockDir(), { recursive: false });
|
|
335
|
+
daemonStartedAt = Date.now();
|
|
336
|
+
daemonToken = randomUUID();
|
|
337
|
+
if (!await writeOwner()) {
|
|
338
|
+
await rm(daemonLockDir(), {
|
|
339
|
+
recursive: true,
|
|
340
|
+
force: true
|
|
341
|
+
}).catch(() => {});
|
|
342
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
} catch (e) {
|
|
347
|
+
if (e.code !== "EEXIST") throw e;
|
|
348
|
+
const raw = await readFile(daemonLockOwnerPath(), "utf8").catch(() => "");
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
let owner = null;
|
|
351
|
+
try {
|
|
352
|
+
owner = JSON.parse(raw);
|
|
353
|
+
} catch {}
|
|
354
|
+
if (owner && typeof owner.pid === "number" && owner.pid > 0 && owner.pid !== process.pid && typeof owner.ts === "number" && now - owner.ts <= OWNER_TTL_MS && isAlive(owner.pid)) return false;
|
|
355
|
+
if (shouldStealLock(raw, now, {
|
|
356
|
+
staleMs: OWNER_TTL_MS,
|
|
357
|
+
lockAgeMs: now - await stat(daemonLockDir()).then((s) => s.mtimeMs).catch(() => now),
|
|
358
|
+
selfPid: process.pid,
|
|
359
|
+
isAlive
|
|
360
|
+
})) {
|
|
361
|
+
await rm(daemonLockDir(), {
|
|
362
|
+
recursive: true,
|
|
363
|
+
force: true
|
|
364
|
+
}).catch(() => {});
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Run the daemon loop in the FOREGROUND. `ay notifyd start` spawns this detached;
|
|
372
|
+
* `ay notifyd run` runs it inline. Holds the host singleton lock for its lifetime.
|
|
373
|
+
*/
|
|
374
|
+
async function runDaemon(opts = {}) {
|
|
375
|
+
const host = hostId();
|
|
376
|
+
const intervalMs = opts.intervalMs ?? POLL_MS;
|
|
377
|
+
if (opts.once) {
|
|
378
|
+
if (await daemonStatus()) return 0;
|
|
379
|
+
const watched = new Set((await liveWatchers()).keys());
|
|
380
|
+
await tickState(host, await reconcileFromInboxes(host, await liveChildrenSnapshot(), watched));
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
if (!await acquireDaemonLock()) {
|
|
384
|
+
logger.debug("[notifyd] another live daemon holds the lock — exiting");
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
let running = true;
|
|
388
|
+
const ownerBeat = setInterval(() => {
|
|
389
|
+
(async () => {
|
|
390
|
+
if (await readDaemonOwnerToken() !== daemonToken) {
|
|
391
|
+
clearInterval(ownerBeat);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
await writeOwner();
|
|
395
|
+
})();
|
|
396
|
+
}, Math.max(1e3, Math.floor(OWNER_TTL_MS / 3)));
|
|
397
|
+
if (typeof ownerBeat.unref === "function") ownerBeat.unref();
|
|
398
|
+
const cleanup = async () => {
|
|
399
|
+
running = false;
|
|
400
|
+
clearInterval(ownerBeat);
|
|
401
|
+
if (await readDaemonOwnerToken() === daemonToken) await rm(daemonLockDir(), {
|
|
402
|
+
recursive: true,
|
|
403
|
+
force: true
|
|
404
|
+
}).catch(() => {});
|
|
405
|
+
};
|
|
406
|
+
process.on("SIGINT", () => void cleanup().then(() => process.exit(0)));
|
|
407
|
+
process.on("SIGTERM", () => void cleanup().then(() => process.exit(0)));
|
|
408
|
+
const watchedAtStart = new Set((await liveWatchers()).keys());
|
|
409
|
+
let prev = await reconcileFromInboxes(host, await liveChildrenSnapshot(), watchedAtStart);
|
|
410
|
+
let ticks = 0;
|
|
411
|
+
let emptySince = null;
|
|
412
|
+
const pendingRetry = /* @__PURE__ */ new Map();
|
|
413
|
+
while (running) {
|
|
414
|
+
if (await readDaemonOwnerToken() !== daemonToken) {
|
|
415
|
+
logger.debug("[notifyd] lock lost/removed — exiting");
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
const { next, watcherCount } = await tickState(host, prev, pendingRetry);
|
|
419
|
+
prev = next;
|
|
420
|
+
ticks++;
|
|
421
|
+
if (ticks % GC_EVERY_TICKS === 0) await gcTick(host).catch(() => {});
|
|
422
|
+
if (watcherCount > 0) emptySince = null;
|
|
423
|
+
else if (emptySince == null) emptySince = Date.now();
|
|
424
|
+
if (emptySince != null && Date.now() - emptySince > IDLE_EXIT_GRACE_MS) {
|
|
425
|
+
logger.debug("[notifyd] no watchers — exiting");
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
429
|
+
}
|
|
430
|
+
await cleanup();
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* A durable-in-memory retry queue for edges whose append failed. Keyed by the
|
|
435
|
+
* event's identity so a pid reused by a different child never collides (the key
|
|
436
|
+
* includes child_started_at). The router state is NOT rolled back on failure —
|
|
437
|
+
* re-delivery is owned entirely by this queue, so a failed OLD-child exited and
|
|
438
|
+
* the NEW same-pid child's edges are retried independently (at-least-once per
|
|
439
|
+
* event). Bounded so a permanently-unwritable inbox can't grow it without bound.
|
|
440
|
+
*/
|
|
441
|
+
const RETRY_CAP = 1e3;
|
|
442
|
+
function retryKey(e) {
|
|
443
|
+
return `${e.parent_pid}:${e.child_pid}:${e.child_started_at ?? 0}:${e.edge}:${e.ts}`;
|
|
444
|
+
}
|
|
445
|
+
async function tickState(host, prev, pendingRetry = /* @__PURE__ */ new Map()) {
|
|
446
|
+
for (const [k, stored] of pendingRetry) if (await appendWithRetry(stored)) pendingRetry.delete(k);
|
|
447
|
+
const { obs, parentStartedAt, childStartedAt, logFiles, aliveChildStartedAt, watcherCount } = await observeChildren();
|
|
448
|
+
const { events, next } = stepRouter(prev, obs, Date.now(), { aliveChildStartedAt });
|
|
449
|
+
const enriched = await Promise.all(events.map(async (ev) => {
|
|
450
|
+
let tail = null;
|
|
451
|
+
let git_head = null;
|
|
452
|
+
if (ev.edge !== "exited") [tail, git_head] = await Promise.all([recentTail(logFiles.get(ev.child_pid)).catch(() => null), gitHead(ev.cwd).catch(() => null)]);
|
|
453
|
+
return {
|
|
454
|
+
ts: Date.now(),
|
|
455
|
+
host,
|
|
456
|
+
parent_pid: ev.parent_pid,
|
|
457
|
+
parent_started_at: ev.parent_started_at ?? parentStartedAt.get(ev.child_pid) ?? 0,
|
|
458
|
+
child_pid: ev.child_pid,
|
|
459
|
+
child_wrapper_pid: ev.child_wrapper_pid,
|
|
460
|
+
child_started_at: ev.child_started_at ?? childStartedAt.get(ev.child_pid) ?? 0,
|
|
461
|
+
cli: ev.cli,
|
|
462
|
+
cwd: ev.cwd,
|
|
463
|
+
edge: ev.edge,
|
|
464
|
+
prev_state: ev.prev_state,
|
|
465
|
+
state: ev.state,
|
|
466
|
+
question: ev.question,
|
|
467
|
+
tail,
|
|
468
|
+
git_head
|
|
469
|
+
};
|
|
470
|
+
}));
|
|
471
|
+
for (const stored of enriched) {
|
|
472
|
+
if (await appendWithRetry(stored)) continue;
|
|
473
|
+
if (pendingRetry.size < RETRY_CAP) {
|
|
474
|
+
pendingRetry.set(retryKey(stored), stored);
|
|
475
|
+
logger.warn(`[notifyd] append failed for parent ${stored.parent_pid} — queued for retry`);
|
|
476
|
+
} else logger.warn(`[notifyd] retry queue full (${RETRY_CAP}) — dropping edge for parent ${stored.parent_pid}`);
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
next,
|
|
480
|
+
watcherCount
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/** Append with a few quick retries — a transient lock/FS blip shouldn't lose an edge. */
|
|
484
|
+
async function appendWithRetry(stored, attempts = 3) {
|
|
485
|
+
for (let a = 0; a < attempts; a++) try {
|
|
486
|
+
await appendEvent(stored.parent_pid, stored);
|
|
487
|
+
return true;
|
|
488
|
+
} catch {
|
|
489
|
+
await new Promise((r) => setTimeout(r, 25 * (a + 1)));
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
async function gcTick(host) {
|
|
494
|
+
const records = await listRecords(void 0, {
|
|
495
|
+
...LS_OPTS,
|
|
496
|
+
all: true
|
|
497
|
+
}).catch(() => []);
|
|
498
|
+
const livePids = /* @__PURE__ */ new Set();
|
|
499
|
+
const childParentPids = /* @__PURE__ */ new Set();
|
|
500
|
+
for (const r of records) {
|
|
501
|
+
if (isPidAlive(r.pid)) livePids.add(r.wrapper_pid ?? r.pid);
|
|
502
|
+
if (typeof r.parent_pid === "number" && r.parent_pid > 0 && isPidAlive(r.pid)) childParentPids.add(r.parent_pid);
|
|
503
|
+
}
|
|
504
|
+
await gcInboxes(host, livePids, childParentPids);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The running daemon's full identity, or null. The lock owner file is the SINGLE
|
|
508
|
+
* source of truth: trusted only if it is COMPLETE (pid + started_at + ts), its
|
|
509
|
+
* pid is alive, AND its heartbeat is fresh. A recycled pid isn't refreshing this
|
|
510
|
+
* file, so its stale ts (or a torn/partial owner) yields null — we never trust or
|
|
511
|
+
* kill an unrelated process. `stop` re-reads and re-checks this identity right
|
|
512
|
+
* before signalling, so it can't SIGTERM a pid recycled between status and stop.
|
|
513
|
+
*
|
|
514
|
+
* Residual edge (documented, not yet closed): a pid recycled onto an UNRELATED
|
|
515
|
+
* live process WITHIN the tight OWNER_TTL window (a few ticks) could be briefly
|
|
516
|
+
* trusted, since we compare our recorded `started_at` against the owner file, not
|
|
517
|
+
* against the OS's actual process start time (which is non-portable to read:
|
|
518
|
+
* `/proc/<pid>/stat` on Linux vs `proc_pidinfo`/`ps -o lstart` on macOS). The
|
|
519
|
+
* heartbeat freshness + tight TTL keep the window to seconds; a real OS
|
|
520
|
+
* start-time cross-check is deferred to a future issue.
|
|
521
|
+
*/
|
|
522
|
+
async function daemonIdentity(now = Date.now()) {
|
|
523
|
+
const raw = await readFile(daemonLockOwnerPath(), "utf8").catch(() => "");
|
|
524
|
+
try {
|
|
525
|
+
const o = JSON.parse(raw);
|
|
526
|
+
if (typeof o?.pid === "number" && o.pid > 0 && typeof o.started_at === "number" && o.started_at > 0 && typeof o.ts === "number" && now - o.ts <= OWNER_TTL_MS && isPidAlive(o.pid)) return {
|
|
527
|
+
pid: o.pid,
|
|
528
|
+
started_at: o.started_at,
|
|
529
|
+
ts: o.ts,
|
|
530
|
+
token: typeof o.token === "string" ? o.token : null
|
|
531
|
+
};
|
|
532
|
+
} catch {}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
/** The running daemon's pid, or null (see daemonIdentity for the trust rules). */
|
|
536
|
+
async function daemonStatus(now = Date.now()) {
|
|
537
|
+
return (await daemonIdentity(now))?.pid ?? null;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Cooperatively request the running daemon to stop, NON-DESTRUCTIVELY: remove its
|
|
541
|
+
* lock rather than SIGTERM-ing the pid. The daemon checks its owner token every
|
|
542
|
+
* tick and exits when the lock is gone, so a pid recycled onto an unrelated
|
|
543
|
+
* process is never signalled. Returns the daemon's pid if one was running, else
|
|
544
|
+
* null. (A wedged daemon that isn't ticking relies on OS/self-exit — a residual
|
|
545
|
+
* tracked in the follow-up issue.)
|
|
546
|
+
*/
|
|
547
|
+
async function requestDaemonStop() {
|
|
548
|
+
const id = await daemonIdentity();
|
|
549
|
+
if (!id || id.token === null) return null;
|
|
550
|
+
if (await readDaemonOwnerToken() !== id.token) return null;
|
|
551
|
+
await rm(daemonLockDir(), {
|
|
552
|
+
recursive: true,
|
|
553
|
+
force: true
|
|
554
|
+
}).catch(() => {});
|
|
555
|
+
return id.pid;
|
|
556
|
+
}
|
|
557
|
+
/** Ensure a daemon is running; best-effort spawn detached. Returns its pid or null. */
|
|
558
|
+
async function ensureDaemon() {
|
|
559
|
+
const existing = await daemonStatus();
|
|
560
|
+
if (existing) return existing;
|
|
561
|
+
const { spawn } = await import("node:child_process");
|
|
562
|
+
const ayBin = Bun.which("ay") ?? process.argv[1];
|
|
563
|
+
if (!ayBin) return null;
|
|
564
|
+
const [cmd, ...pre] = process.platform === "win32" ? [ayBin] : [process.execPath, ayBin];
|
|
565
|
+
const child = spawn(cmd, [
|
|
566
|
+
...pre,
|
|
567
|
+
"notifyd",
|
|
568
|
+
"run"
|
|
569
|
+
], {
|
|
570
|
+
detached: true,
|
|
571
|
+
stdio: "ignore"
|
|
572
|
+
});
|
|
573
|
+
child.on("error", () => {});
|
|
574
|
+
child.unref();
|
|
575
|
+
const PHI = 1.618;
|
|
576
|
+
let delay = 50;
|
|
577
|
+
let waited = 0;
|
|
578
|
+
const BUDGET_MS = 3e3;
|
|
579
|
+
for (;;) {
|
|
580
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
581
|
+
waited += delay;
|
|
582
|
+
const pid = await daemonStatus();
|
|
583
|
+
if (pid) return pid;
|
|
584
|
+
if (waited >= BUDGET_MS) return null;
|
|
585
|
+
delay = Math.min(Math.round(delay * PHI), 800);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
//#endregion
|
|
590
|
+
export { daemonStatus, ensureDaemon, requestDaemonStop, runDaemon };
|
|
591
|
+
//# sourceMappingURL=notifyDaemon-BlGV0y4j.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as logger } from "./logger-CDIsZ-Pp.js";
|
|
2
|
-
import { t as agentYesHome } from "./agentYesHome-
|
|
3
|
-
import { a as updateGlobalPidStatus, n as maybeCompactGlobalPids, r as pruneOldLogs, t as appendGlobalPid } from "./globalPidIndex-
|
|
2
|
+
import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
|
|
3
|
+
import { a as updateGlobalPidStatus, n as maybeCompactGlobalPids, r as pruneOldLogs, t as appendGlobalPid } from "./globalPidIndex-CoNr7tS8.js";
|
|
4
4
|
import { closeSync, existsSync, fsyncSync, openSync } from "fs";
|
|
5
5
|
import { appendFile, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
6
6
|
import path from "path";
|
|
@@ -368,4 +368,4 @@ pid-db/
|
|
|
368
368
|
|
|
369
369
|
//#endregion
|
|
370
370
|
export { PidStore as t };
|
|
371
|
-
//# sourceMappingURL=pidStore-
|
|
371
|
+
//# sourceMappingURL=pidStore-BIvsBQ8X.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as agentYesHome } from "./agentYesHome-
|
|
2
|
-
import { appendFile, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
1
|
+
import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
|
|
2
|
+
import { appendFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
4
|
|
|
5
5
|
//#region ts/reaper.ts
|
|
@@ -49,6 +49,7 @@ async function register(wrapperPid, pgid) {
|
|
|
49
49
|
/** SIGKILL the recorded group of every agent whose wrapper has exited, and
|
|
50
50
|
* rewrite the registry keeping only still-running agents. Best-effort. */
|
|
51
51
|
async function sweep() {
|
|
52
|
+
await pruneStaleActivityMarkers();
|
|
52
53
|
let content;
|
|
53
54
|
try {
|
|
54
55
|
content = await readFile(registryPath(), "utf8");
|
|
@@ -80,7 +81,30 @@ async function sweep() {
|
|
|
80
81
|
await rename(tmp, registryPath());
|
|
81
82
|
} catch {}
|
|
82
83
|
}
|
|
84
|
+
/** Prune typing-activity markers (`activity/<pid>.stdin`) left by hard-killed
|
|
85
|
+
* agents whose own exit cleanup never ran. Independent of the reaper registry
|
|
86
|
+
* above, so it catches every dead pid. A leaked marker is harmless (it ages out
|
|
87
|
+
* of the typing window and stopped agents never render the chip); this just
|
|
88
|
+
* keeps the dir from accumulating dead entries. Mirrors rs/src/fifo.rs
|
|
89
|
+
* `prune_stale_activity_markers`. Best-effort. */
|
|
90
|
+
async function pruneStaleActivityMarkers() {
|
|
91
|
+
const dir = path.join(agentYesHome(), "activity");
|
|
92
|
+
let names;
|
|
93
|
+
try {
|
|
94
|
+
names = await readdir(dir);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(names.map(async (name) => {
|
|
99
|
+
if (!name.endsWith(".stdin")) return;
|
|
100
|
+
const pid = Number(name.slice(0, -6));
|
|
101
|
+
if (!Number.isInteger(pid) || isAlive(pid)) return;
|
|
102
|
+
try {
|
|
103
|
+
await unlink(path.join(dir, name));
|
|
104
|
+
} catch {}
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
83
107
|
|
|
84
108
|
//#endregion
|
|
85
109
|
export { register as n, sweep as r, pgidForWrapper as t };
|
|
86
|
-
//# sourceMappingURL=reaper-
|
|
110
|
+
//# sourceMappingURL=reaper-CWF2_ATd.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import "./e2e-
|
|
2
|
-
import "./webrtcLink-
|
|
3
|
-
import { a as resolveRemoteSpec, i as readRemotes, n as deleteRemoteAlias, o as writeRemoteAlias, r as parseDirectRemoteSpec, t as cmdRemote } from "./remotes-
|
|
1
|
+
import "./e2e-jb0Hp43q.js";
|
|
2
|
+
import "./webrtcLink-B7REGtK2.js";
|
|
3
|
+
import { a as resolveRemoteSpec, i as readRemotes, n as deleteRemoteAlias, o as writeRemoteAlias, r as parseDirectRemoteSpec, t as cmdRemote } from "./remotes-CC-4GuJb.js";
|
|
4
4
|
|
|
5
5
|
export { cmdRemote };
|