agent-yes 1.265.4 → 1.266.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-C3tcRetU.js → SUPPORTED_CLIS-AFV5pjSx.js} +2 -2
- package/dist/{SUPPORTED_CLIS-CY3SaV0D.js → SUPPORTED_CLIS-DRVuSo48.js} +3 -3
- package/dist/{agentShare-g6N1A6A_.js → agentShare-qdFSp9Uq.js} +6 -5
- package/dist/{callback-L4k-v2GG.js → callback-Da7MRLh6.js} +6 -5
- package/dist/{callback-z-w0ND5z.js → callback-DfNFdZin.js} +5 -5
- package/dist/{callbackCore-Cv4EOGjE.js → callbackCore-DcptaZwH.js} +1 -1
- package/dist/{callbackCore-Cg3Ye0ZS.js → callbackCore-K99x3C75.js} +1 -1
- package/dist/{channels-BwpN_8mu.js → channels-DQaZbare.js} +2 -2
- package/dist/cli.js +11 -11
- package/dist/{cwdConflictWarn-DZTxTGXq.js → cwdConflictWarn-CtQhl-vF.js} +1 -1
- package/dist/{cwdPassthroughHint-DDVkhBiD.js → cwdPassthroughHint-apaX_O7p.js} +1 -1
- package/dist/{expose-BG17GV7D.js → expose-BXn6My6A.js} +1 -1
- package/dist/{forkNested-VroZS-nk.js → forkNested-CDfWPiHw.js} +12 -2
- package/dist/{hist-CoXBBjJt.js → hist-BpocAe1N.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/initMsg-BjRseolL.js +3 -0
- package/dist/initMsg-C-w4A_eF.js +85 -0
- package/dist/needsInput-103zhZ90.js +79 -0
- package/dist/{nodeRuntime-CT0w6zsB.js → nodeRuntime-CRoEMWkp.js} +1 -1
- package/dist/{notifyDaemon-BL1tmxUK.js → notifyDaemon-BFzqcdN1.js} +32 -5
- package/dist/notifyStore-CEDiVyis.js +4 -0
- package/dist/notifyStore-Cbw2ZJ0X.js +559 -0
- package/dist/{openBrowser-Cw8hg7ok.js → openBrowser-DhQ9pJRi.js} +1 -1
- package/dist/parentLink-B798ASCW.js +5 -0
- package/dist/parentLink-C0cIRjfL.js +53 -0
- package/dist/parentPingLoop-BIKxzbPH.js +469 -0
- package/dist/{remotes-DWVw-Q7V.js → remotes-Dg_JscKk.js} +2 -2
- package/dist/{remotes-iIvqiFWK.js → remotes-iMhdZytp.js} +3 -3
- package/dist/{rustBinary-DSVRhDzO.js → rustBinary-JGNROWfY.js} +2 -2
- package/dist/{schedule-i4o5SJ_h.js → schedule-DbLpjgl5.js} +6 -6
- package/dist/{serve-CpGJD2Kt.js → serve-ucVXOci1.js} +31 -30
- package/dist/{setup-B-iQKsQW.js → setup-De7uYRZb.js} +4 -4
- package/dist/{share-DOnynkf4.js → share-Db_zb6gH.js} +1 -1
- package/dist/{share-B-e_9WBx.js → share-NUhi-Wdd.js} +1 -1
- package/dist/{spawnGate-S6EoFdzX.js → spawnGate-BvaZeHE7.js} +1 -1
- package/dist/{spawnGate-BmsTv340.js → spawnGate-CEXVmTtv.js} +2 -2
- package/dist/subcommands-C04exkIM.js +11 -0
- package/dist/{subcommands-CQPnT5qu.js → subcommands-DtF31RoW.js} +41 -656
- package/dist/{systemPathLink-CjLmiPbB.js → systemPathLink-7TFPwJna.js} +1 -1
- package/dist/{terminal-CybRgMZu.js → terminal-Dm32SXU-.js} +2 -2
- package/dist/{todoCli-DZmOmcvd.js → todoCli-c_R7D7Y-.js} +1 -1
- package/dist/{tray-Cjm3a8JH.js → tray-DZnGjJBK.js} +1 -1
- package/dist/trayApp-CZbwLK9t.js +5 -0
- package/dist/{trayApp-UglHbk_m.js → trayApp-CfkPG0wy.js} +2 -2
- package/dist/{ts-nuUnrVcf.js → ts-CGtoPER5.js} +43 -10
- package/dist/{versionChecker-CMOLbEEi.js → versionChecker-CJgW9DHY.js} +2 -2
- package/dist/{webrtcLink-CS4D-3G1.js → webrtcLink-DyCj380i.js} +1 -1
- package/dist/{webrtcRemote-zQybN9IR.js → webrtcRemote-BDg0jjTs.js} +2 -2
- package/dist/{widget-y5Bpvwsa.js → widget-DXtaX3QR.js} +3 -3
- package/dist/{workspaceConfig-BDqp7fYl.js → workspaceConfig-HiWQzq6A.js} +1 -1
- package/dist/{ws-CH8GzcQn.js → ws-CqlS5JhN.js} +5 -4
- package/dist/{ws-BrLV-AlH.js → ws-aKRZpI9b.js} +9 -11
- package/package.json +1 -1
- package/ts/forkNested.spec.ts +22 -0
- package/ts/forkNested.ts +19 -0
- package/ts/index.ts +77 -12
- package/ts/initMsg.spec.ts +114 -0
- package/ts/initMsg.ts +107 -0
- package/ts/ownerHeartbeat.ts +40 -1
- package/ts/parentLink.spec.ts +64 -0
- package/ts/parentLink.ts +58 -0
- package/ts/parentPing.spec.ts +176 -0
- package/ts/parentPing.ts +195 -0
- package/ts/parentPingLoop.delivery.spec.ts +236 -0
- package/ts/parentPingLoop.spec.ts +79 -0
- package/ts/parentPingLoop.ts +196 -0
- package/ts/parentPingSend.spec.ts +195 -0
- package/ts/parentPingSend.ts +148 -0
- package/ts/parentWatching.spec.ts +61 -0
- package/ts/parentWatching.ts +89 -0
- package/ts/subcommands.ts +11 -2
- package/ts/trayApp.spec.ts +31 -4
- package/ts/ws.ts +17 -12
- package/dist/subcommands-Cgt2-kjg.js +0 -10
- package/dist/trayApp-CmwwBg8v.js +0 -5
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { n as logger } from "./logger-CDIsZ-Pp.js";
|
|
2
|
+
import { t as agentYesHome } from "./agentYesHome-CtmsY6I-.js";
|
|
3
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
//#region ts/notifyInbox.ts
|
|
9
|
+
/**
|
|
10
|
+
* Pure, fs-free core for the subagent→parent notification inbox — the storage
|
|
11
|
+
* and watermark layer under `ay notify` / `ay notifyd`.
|
|
12
|
+
*
|
|
13
|
+
* Motivation (real incident): a parent agent fanned out sub-agents that finished
|
|
14
|
+
* their work but sat at an idle `❯` prompt WITHOUT exiting (claude-yes does not
|
|
15
|
+
* exit on idle). Claude Code's background-task notification only fires on process
|
|
16
|
+
* EXIT, so the parent never learned the children went idle and left them parked
|
|
17
|
+
* 16 minutes. `ay ls --watch` already streams transitions, but it is PULL: the
|
|
18
|
+
* parent must run the watch loop. The whole point of this pain is the parent is
|
|
19
|
+
* NOT watching. So we accumulate qualifying edges into a per-parent append-only
|
|
20
|
+
* inbox that the parent drains on its own schedule (its Monitor loop), with a
|
|
21
|
+
* persisted cursor so a parent that restarts reads only the unread edges.
|
|
22
|
+
*
|
|
23
|
+
* This module is the pure part (path math, NDJSON (de)serialization, seq/cursor
|
|
24
|
+
* filtering, retention decisions) so it is trivially unit-testable, mirroring
|
|
25
|
+
* `lsWatch.ts` / `needsInput.ts` / `resultEnvelope.ts`. The fs read/write + lock
|
|
26
|
+
* + CLI live in `subcommands.ts`; the detection/debounce lives in
|
|
27
|
+
* `notifyRouter.ts`.
|
|
28
|
+
*/
|
|
29
|
+
/** Root dir for all notification state. */
|
|
30
|
+
function notifyDir() {
|
|
31
|
+
return path.join(agentYesHome(), "notify");
|
|
32
|
+
}
|
|
33
|
+
/** Per-host inbox directory (pids are only unique within a host). */
|
|
34
|
+
function inboxDir(host) {
|
|
35
|
+
return path.join(notifyDir(), "inbox", sanitizeHost(host));
|
|
36
|
+
}
|
|
37
|
+
/** Absolute path of one parent's append-only NDJSON inbox. */
|
|
38
|
+
function inboxPath(host, parentPid) {
|
|
39
|
+
return path.join(inboxDir(host), `${parentPid}.ndjson`);
|
|
40
|
+
}
|
|
41
|
+
/** Sidecar seq counter for a parent inbox (last allocated seq). */
|
|
42
|
+
function seqPath(host, parentPid) {
|
|
43
|
+
return path.join(inboxDir(host), `${parentPid}.seq`);
|
|
44
|
+
}
|
|
45
|
+
/** Per-consumer cursor directory for a parent inbox. */
|
|
46
|
+
function cursorDir(host, parentPid) {
|
|
47
|
+
return path.join(notifyDir(), "cursors", sanitizeHost(host), String(parentPid));
|
|
48
|
+
}
|
|
49
|
+
/** Absolute path of one consumer's cursor file for a parent inbox. */
|
|
50
|
+
function cursorPath(host, parentPid, consumer = "parent") {
|
|
51
|
+
return path.join(cursorDir(host, parentPid), `${sanitizeConsumer(consumer)}.json`);
|
|
52
|
+
}
|
|
53
|
+
/** The daemon singleton lock dir (mkdir-based, like pidStore). */
|
|
54
|
+
function daemonLockDir() {
|
|
55
|
+
return path.join(notifyDir(), "notifyd.lock");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Owner metadata inside the lock dir ({pid, started_at, ts}) — the SINGLE source
|
|
59
|
+
* of truth for the running daemon's identity, used for stale-lock steal and for
|
|
60
|
+
* `notifyd status`/`stop` (a running daemon refreshes `ts` each tick).
|
|
61
|
+
*/
|
|
62
|
+
function daemonLockOwnerPath() {
|
|
63
|
+
return path.join(daemonLockDir(), "owner.json");
|
|
64
|
+
}
|
|
65
|
+
/** Registry dir of parents currently running `ay notify watch` (heartbeats). */
|
|
66
|
+
function watchersDir() {
|
|
67
|
+
return path.join(notifyDir(), "watchers");
|
|
68
|
+
}
|
|
69
|
+
/** Heartbeat file for one watching parent. */
|
|
70
|
+
function watcherPath(parentPid) {
|
|
71
|
+
return path.join(watchersDir(), `${parentPid}.json`);
|
|
72
|
+
}
|
|
73
|
+
/** A watcher heartbeat is "live" if refreshed within this window. */
|
|
74
|
+
const WATCHER_TTL_MS = 15e3;
|
|
75
|
+
function sanitizeSegment(s, fallback) {
|
|
76
|
+
const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
77
|
+
return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
|
|
78
|
+
}
|
|
79
|
+
function sanitizeHost(host) {
|
|
80
|
+
return sanitizeSegment(host, "localhost");
|
|
81
|
+
}
|
|
82
|
+
function sanitizeConsumer(consumer) {
|
|
83
|
+
return sanitizeSegment(consumer, "parent");
|
|
84
|
+
}
|
|
85
|
+
/** Serialize one event to its NDJSON line (no trailing newline). */
|
|
86
|
+
function serializeEvent(ev) {
|
|
87
|
+
return JSON.stringify(ev);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parse an inbox's raw text into events. Tolerant by design: a torn final line
|
|
91
|
+
* (a writer mid-append) or any unparseable line is skipped, never throws — a
|
|
92
|
+
* reader must survive a concurrent writer. Order is file order (= seq order,
|
|
93
|
+
* since a single locked writer appends in increasing seq).
|
|
94
|
+
*/
|
|
95
|
+
function parseInboxText(text) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const line of text.split("\n")) {
|
|
98
|
+
const t = line.trim();
|
|
99
|
+
if (!t) continue;
|
|
100
|
+
try {
|
|
101
|
+
const ev = JSON.parse(t);
|
|
102
|
+
if (isValidEvent(ev)) out.push(ev);
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
const EDGES = new Set([
|
|
108
|
+
"needs_input",
|
|
109
|
+
"idle",
|
|
110
|
+
"exited"
|
|
111
|
+
]);
|
|
112
|
+
/**
|
|
113
|
+
* Full shape validation at the storage boundary — a torn/partial line missing
|
|
114
|
+
* the correlation fields must never flow through to a consumer's output. Every
|
|
115
|
+
* event we surface has the fields a reader relies on.
|
|
116
|
+
*/
|
|
117
|
+
function isValidEvent(ev) {
|
|
118
|
+
if (!ev || typeof ev !== "object") return false;
|
|
119
|
+
const e = ev;
|
|
120
|
+
return typeof e.seq === "number" && typeof e.ts === "number" && typeof e.host === "string" && typeof e.parent_pid === "number" && typeof e.child_pid === "number" && typeof e.cli === "string" && typeof e.cwd === "string" && typeof e.edge === "string" && EDGES.has(e.edge) && typeof e.state === "string" && (e.question === null || typeof e.question === "string");
|
|
121
|
+
}
|
|
122
|
+
/** Next seq to allocate given the last stored seq counter (or the inbox max). */
|
|
123
|
+
function nextSeq(lastSeq) {
|
|
124
|
+
return (Number.isFinite(lastSeq) && lastSeq > 0 ? lastSeq : 0) + 1;
|
|
125
|
+
}
|
|
126
|
+
/** Highest seq present in a parsed inbox (0 when empty). */
|
|
127
|
+
function maxSeq(events) {
|
|
128
|
+
let m = 0;
|
|
129
|
+
for (const e of events) if (e.seq > m) m = e.seq;
|
|
130
|
+
return m;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Filter events for `--since <seq>`: strictly-greater than the given seq. A
|
|
134
|
+
* `since` of 0/undefined returns everything.
|
|
135
|
+
*/
|
|
136
|
+
function filterSinceSeq(events, sinceSeq) {
|
|
137
|
+
if (!sinceSeq || sinceSeq <= 0) return events.slice();
|
|
138
|
+
return events.filter((e) => e.seq > sinceSeq);
|
|
139
|
+
}
|
|
140
|
+
/** Filter events after a wall-clock `--since <ts>` (inclusive lower bound). */
|
|
141
|
+
function filterSinceTs(events, sinceTs) {
|
|
142
|
+
if (!sinceTs || sinceTs <= 0) return events.slice();
|
|
143
|
+
return events.filter((e) => e.ts >= sinceTs);
|
|
144
|
+
}
|
|
145
|
+
/** Unread = seq strictly greater than the consumer's cursor. */
|
|
146
|
+
function filterUnread(events, cursorSeq) {
|
|
147
|
+
return events.filter((e) => e.seq > (cursorSeq > 0 ? cursorSeq : 0));
|
|
148
|
+
}
|
|
149
|
+
/** Parse a cursor file's text; a missing/garbage cursor reads as seq 0. */
|
|
150
|
+
function parseCursor(text) {
|
|
151
|
+
if (!text) return { seq: 0 };
|
|
152
|
+
try {
|
|
153
|
+
const c = JSON.parse(text);
|
|
154
|
+
return { seq: Number.isFinite(c?.seq) && c.seq > 0 ? c.seq : 0 };
|
|
155
|
+
} catch {
|
|
156
|
+
return { seq: 0 };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function serializeCursor(seq) {
|
|
160
|
+
return JSON.stringify({ seq: Math.max(0, Math.floor(seq)) });
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Which incarnation of a parent pid a POSTMORTEM read should show (pure).
|
|
164
|
+
*
|
|
165
|
+
* The normal read path resolves the parent's `started_at` from the registry and
|
|
166
|
+
* fails closed when there is no live record — deliberately, so a recycled pid
|
|
167
|
+
* can't read a prior session's inbox. That also makes a FINISHED parent's inbox
|
|
168
|
+
* permanently uninspectable, which is exactly when an operator wants to see what
|
|
169
|
+
* the children reported. Postmortem keeps the identity guard but takes the
|
|
170
|
+
* identity from the inbox's own `parent_started_at` stamps instead.
|
|
171
|
+
*
|
|
172
|
+
* - An explicit choice always wins.
|
|
173
|
+
* - One distinct stamp is unambiguous — use it.
|
|
174
|
+
* - Several mean the pid was reused across sessions and the file holds more than
|
|
175
|
+
* one agent's edges: refuse and list them rather than guess, because guessing
|
|
176
|
+
* would show one agent's children under another's name.
|
|
177
|
+
* - None (a legacy, identity-less inbox) yields 0 = "no filter"; there is nothing
|
|
178
|
+
* to disambiguate and nothing to mis-attribute.
|
|
179
|
+
*
|
|
180
|
+
* Issue #169 item 6.
|
|
181
|
+
*/
|
|
182
|
+
function postmortemStartedAt(events, explicit) {
|
|
183
|
+
if (Number.isFinite(explicit) && explicit > 0) return explicit;
|
|
184
|
+
const distinct = [...new Set(events.map((e) => e.parent_started_at ?? 0).filter((v) => v > 0))].sort((a, b) => a - b);
|
|
185
|
+
if (distinct.length === 0) return 0;
|
|
186
|
+
if (distinct.length === 1) return distinct[0];
|
|
187
|
+
throw new Error(`inbox holds edges from ${distinct.length} incarnations of this pid (started_at ${distinct.join(", ")}) — pass --started-at <ms> to pick one.`);
|
|
188
|
+
}
|
|
189
|
+
/** Byte size one event occupies in the inbox file (its line + newline). */
|
|
190
|
+
function eventBytes(events) {
|
|
191
|
+
let n = 0;
|
|
192
|
+
for (const e of events) n += serializeEvent(e).length + 1;
|
|
193
|
+
return n;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* EMERGENCY rotation (pure): keep the newest events that fit `capBytes`,
|
|
197
|
+
* IGNORING the un-acked watermark that {@link rotateKeep} protects.
|
|
198
|
+
*
|
|
199
|
+
* This is the escape hatch for the one way an inbox can grow without bound: a
|
|
200
|
+
* parent that watches WITHOUT `--ack` (the default, at-least-once) never
|
|
201
|
+
* advances its cursor, so every event stays protected and normal rotation can
|
|
202
|
+
* trim nothing. That is the right default — an unacked edge must not be dropped
|
|
203
|
+
* — but "right" stops being right once the file threatens the disk. Past a hard
|
|
204
|
+
* cap well above the soft one, dropping the OLDEST unacked events (loudly) beats
|
|
205
|
+
* unbounded growth.
|
|
206
|
+
*
|
|
207
|
+
* `minKeep` still floors the result, so the newest edges — the ones a parent is
|
|
208
|
+
* most likely to still act on — are never the ones sacrificed.
|
|
209
|
+
*
|
|
210
|
+
* Issue #169 item 3.
|
|
211
|
+
*/
|
|
212
|
+
function emergencyKeep(events, capBytes, minKeep = 100) {
|
|
213
|
+
if (events.length <= minKeep) return events.slice();
|
|
214
|
+
const kept = [];
|
|
215
|
+
let bytes = 0;
|
|
216
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
217
|
+
const e = events[i];
|
|
218
|
+
bytes += serializeEvent(e).length + 1;
|
|
219
|
+
if (kept.length < minKeep || bytes <= capBytes) kept.push(e);
|
|
220
|
+
else break;
|
|
221
|
+
}
|
|
222
|
+
kept.reverse();
|
|
223
|
+
return kept;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Rotation decision (pure): given an inbox's events, a byte cap, and the minimum
|
|
227
|
+
* un-acked cursor across ALL consumers, return the events to KEEP after
|
|
228
|
+
* rotating. Critically, an event with `seq > protectAboveSeq` is NEVER dropped —
|
|
229
|
+
* that is the at-least-once guarantee: we must not evict an edge no consumer has
|
|
230
|
+
* acknowledged yet, even under the byte cap. Below that watermark we keep the
|
|
231
|
+
* newest events that fit the cap (and at least `minKeep`).
|
|
232
|
+
*/
|
|
233
|
+
function rotateKeep(events, capBytes, protectAboveSeq = 0, minKeep = 100) {
|
|
234
|
+
if (events.length <= minKeep) return events.slice();
|
|
235
|
+
const kept = [];
|
|
236
|
+
let bytes = 0;
|
|
237
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
238
|
+
const e = events[i];
|
|
239
|
+
const line = serializeEvent(e) + "\n";
|
|
240
|
+
bytes += line.length;
|
|
241
|
+
if (e.seq > protectAboveSeq || kept.length < minKeep || bytes <= capBytes) kept.push(e);
|
|
242
|
+
else break;
|
|
243
|
+
}
|
|
244
|
+
kept.reverse();
|
|
245
|
+
return kept;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region ts/notifyStore.ts
|
|
250
|
+
/**
|
|
251
|
+
* Filesystem side of the notification inbox — the impure half of the pure
|
|
252
|
+
* `notifyInbox.ts` path/serialization core. Handles the locked append (so
|
|
253
|
+
* concurrent daemon ticks / future writers never interleave a seq), inbox and
|
|
254
|
+
* cursor reads/writes, and retention GC.
|
|
255
|
+
*
|
|
256
|
+
* The lock is a mkdir-based advisory lock (same primitive `pidStore` uses), held
|
|
257
|
+
* only for the microseconds of a read-counter → append-line → write-counter, so
|
|
258
|
+
* contention is negligible even with many parents.
|
|
259
|
+
*/
|
|
260
|
+
/** Stable local host id — the pid namespace is per-host. */
|
|
261
|
+
function hostId() {
|
|
262
|
+
return os.hostname() || "localhost";
|
|
263
|
+
}
|
|
264
|
+
function pidAlive(pid) {
|
|
265
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
266
|
+
try {
|
|
267
|
+
process.kill(pid, 0);
|
|
268
|
+
return true;
|
|
269
|
+
} catch (e) {
|
|
270
|
+
return e.code === "EPERM";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Pure steal decision for a per-inbox lock (extracted so it's unit-testable):
|
|
275
|
+
* steal the lock ONLY when its holder is provably gone (torn/missing owner on a
|
|
276
|
+
* lock that has EXISTED longer than the grace, or a dead pid) or its heartbeat is
|
|
277
|
+
* stale — NEVER merely because the contender has waited a while.
|
|
278
|
+
*
|
|
279
|
+
* Crucially, the torn-owner grace is measured from the LOCK INSTANCE's age
|
|
280
|
+
* (`lockAgeMs` = now − lockDir mtime), NOT from how long the contender has been
|
|
281
|
+
* waiting. Otherwise a contender that waited out one holder could instantly steal
|
|
282
|
+
* the NEXT holder's freshly-created lock during its mkdir→write-owner window
|
|
283
|
+
* (torn but brand-new) — letting two writers into the critical section.
|
|
284
|
+
*/
|
|
285
|
+
function shouldStealLock(ownerRaw, now, opts) {
|
|
286
|
+
const graceMs = opts.graceMs ?? 1e3;
|
|
287
|
+
let ownerPid = 0;
|
|
288
|
+
let ownerTs = 0;
|
|
289
|
+
let parsed = false;
|
|
290
|
+
try {
|
|
291
|
+
const o = JSON.parse(ownerRaw);
|
|
292
|
+
ownerPid = o?.pid ?? 0;
|
|
293
|
+
ownerTs = o?.ts ?? 0;
|
|
294
|
+
parsed = true;
|
|
295
|
+
} catch {}
|
|
296
|
+
if (!parsed || ownerPid <= 0 || ownerTs <= 0) return opts.lockAgeMs > graceMs;
|
|
297
|
+
const holderDead = ownerPid !== opts.selfPid && !opts.isAlive(ownerPid);
|
|
298
|
+
const holderStale = now - ownerTs > opts.staleMs;
|
|
299
|
+
return holderDead || holderStale;
|
|
300
|
+
}
|
|
301
|
+
async function ensureDir(dir) {
|
|
302
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Atomically overwrite a single file (temp + rename) so a concurrent reader never
|
|
306
|
+
* observes a torn/partial write — the sweep invariant for every single-file
|
|
307
|
+
* mutation (cursor, watcher heartbeat, lock owner). Returns whether it landed.
|
|
308
|
+
*/
|
|
309
|
+
async function atomicWrite(file, data) {
|
|
310
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
311
|
+
try {
|
|
312
|
+
await writeFile(tmp, data);
|
|
313
|
+
await rename(tmp, file);
|
|
314
|
+
return true;
|
|
315
|
+
} catch {
|
|
316
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Acquire a per-inbox lock; returns a release fn. Ownership is proven ONLY by a
|
|
322
|
+
* successful `mkdir(recursive:false)` (atomic exclusive create), and stealing is
|
|
323
|
+
* driven by HOLDER LIVENESS, not elapsed wait time: the holder writes an
|
|
324
|
+
* `owner` file ({pid, ts}) on acquire AND heartbeats its ts for the whole time
|
|
325
|
+
* it holds the lock, so a contender steals ONLY when that holder is dead or its
|
|
326
|
+
* heartbeat is stale. A live holder whose critical section legitimately runs long
|
|
327
|
+
* (a big GC rewrite, a slow disk) is never robbed — its heartbeat stays fresh;
|
|
328
|
+
* and a wedged holder stops heartbeating, so staleMs still reclaims it. There is
|
|
329
|
+
* deliberately NO wall-clock backstop: elapsed wait time alone never steals, so a
|
|
330
|
+
* live holder is inviolable.
|
|
331
|
+
*/
|
|
332
|
+
function isTransientLockMkdirError(code, platform = process.platform) {
|
|
333
|
+
if (code === "EEXIST") return true;
|
|
334
|
+
return platform === "win32" && (code === "EPERM" || code === "EBUSY" || code === "ENOTEMPTY" || code === "EACCES");
|
|
335
|
+
}
|
|
336
|
+
async function acquireLock(lockDir, staleMs = 3e4) {
|
|
337
|
+
const ownerFile = path.join(lockDir, "owner");
|
|
338
|
+
const token = randomUUID();
|
|
339
|
+
const readOwnerToken = async () => {
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(await readFile(ownerFile, "utf8")).token ?? null;
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
const stampOwner = async () => {
|
|
347
|
+
const tmp = `${ownerFile}.${token}.tmp`;
|
|
348
|
+
try {
|
|
349
|
+
await writeFile(tmp, JSON.stringify({
|
|
350
|
+
pid: process.pid,
|
|
351
|
+
ts: Date.now(),
|
|
352
|
+
token
|
|
353
|
+
}));
|
|
354
|
+
await rename(tmp, ownerFile);
|
|
355
|
+
return true;
|
|
356
|
+
} catch {
|
|
357
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
const lockAgeMs = async () => {
|
|
362
|
+
const m = await stat(lockDir).then((s) => s.mtimeMs).catch(() => Date.now());
|
|
363
|
+
return Date.now() - m;
|
|
364
|
+
};
|
|
365
|
+
let transientErrors = 0;
|
|
366
|
+
for (;;) try {
|
|
367
|
+
await mkdir(lockDir, { recursive: false });
|
|
368
|
+
if (!await stampOwner()) {
|
|
369
|
+
await rm(lockDir, {
|
|
370
|
+
recursive: true,
|
|
371
|
+
force: true
|
|
372
|
+
}).catch(() => {});
|
|
373
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const beat = setInterval(() => {
|
|
377
|
+
(async () => {
|
|
378
|
+
if (await readOwnerToken() !== token) {
|
|
379
|
+
clearInterval(beat);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await stampOwner();
|
|
383
|
+
})();
|
|
384
|
+
}, Math.max(500, Math.floor(staleMs / 3)));
|
|
385
|
+
if (typeof beat.unref === "function") beat.unref();
|
|
386
|
+
return async () => {
|
|
387
|
+
clearInterval(beat);
|
|
388
|
+
if (await readOwnerToken() === token) await rm(lockDir, {
|
|
389
|
+
recursive: true,
|
|
390
|
+
force: true
|
|
391
|
+
}).catch(() => {});
|
|
392
|
+
};
|
|
393
|
+
} catch (e) {
|
|
394
|
+
const code = e.code;
|
|
395
|
+
if (!isTransientLockMkdirError(code)) throw e;
|
|
396
|
+
if (code !== "EEXIST") {
|
|
397
|
+
if (++transientErrors > 200) throw e;
|
|
398
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (shouldStealLock(await readFile(ownerFile, "utf8").catch(() => ""), Date.now(), {
|
|
402
|
+
staleMs,
|
|
403
|
+
lockAgeMs: await lockAgeMs(),
|
|
404
|
+
selfPid: process.pid,
|
|
405
|
+
isAlive: pidAlive
|
|
406
|
+
})) {
|
|
407
|
+
await rm(lockDir, {
|
|
408
|
+
recursive: true,
|
|
409
|
+
force: true
|
|
410
|
+
}).catch(() => {});
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Append one event to a parent's inbox under lock, allocating the next seq. The
|
|
418
|
+
* caller passes the event WITHOUT seq (we stamp it). Returns the stamped seq.
|
|
419
|
+
*/
|
|
420
|
+
async function appendEvent(parentPid, ev) {
|
|
421
|
+
const host = ev.host;
|
|
422
|
+
const dir = inboxDir(host);
|
|
423
|
+
await ensureDir(dir);
|
|
424
|
+
const release = await acquireLock(path.join(dir, `${parentPid}.lock`));
|
|
425
|
+
try {
|
|
426
|
+
let last = 0;
|
|
427
|
+
const counterRaw = await readFile(seqPath(host, parentPid), "utf8").catch(() => "");
|
|
428
|
+
const parsed = parseInt(counterRaw.trim(), 10);
|
|
429
|
+
if (Number.isFinite(parsed) && parsed > 0) last = parsed;
|
|
430
|
+
else last = maxSeq(parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => "")));
|
|
431
|
+
const seq = nextSeq(last);
|
|
432
|
+
const full = {
|
|
433
|
+
...ev,
|
|
434
|
+
seq
|
|
435
|
+
};
|
|
436
|
+
await writeFile(seqPath(host, parentPid), String(seq));
|
|
437
|
+
await appendFile(inboxPath(host, parentPid), serializeEvent(full) + "\n");
|
|
438
|
+
return seq;
|
|
439
|
+
} finally {
|
|
440
|
+
await release();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** Read + parse a parent's inbox (empty array when none). */
|
|
444
|
+
async function readInbox(host, parentPid) {
|
|
445
|
+
return parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => ""));
|
|
446
|
+
}
|
|
447
|
+
/** All parent pids that currently have an inbox on this host. */
|
|
448
|
+
async function listInboxParents(host) {
|
|
449
|
+
const names = await readdir(inboxDir(host)).catch(() => []);
|
|
450
|
+
const pids = [];
|
|
451
|
+
for (const n of names) {
|
|
452
|
+
const m = /^(\d+)\.ndjson$/.exec(n);
|
|
453
|
+
if (m) pids.push(parseInt(m[1], 10));
|
|
454
|
+
}
|
|
455
|
+
return pids;
|
|
456
|
+
}
|
|
457
|
+
async function getCursor(host, parentPid, consumer = "parent") {
|
|
458
|
+
return parseCursor(await readFile(cursorPath(host, parentPid, consumer), "utf8").catch(() => null)).seq;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Minimum cursor across ALL consumers of a parent inbox — the watermark below
|
|
462
|
+
* which rotation may evict events (nothing above it has been acked by every
|
|
463
|
+
* reader). Returns 0 when there are no consumers — which PROTECTS EVERYTHING:
|
|
464
|
+
* with a 0 watermark, `rotateKeep` treats every event as unacked and evicts
|
|
465
|
+
* nothing (an inbox nobody reads is never trimmed).
|
|
466
|
+
*/
|
|
467
|
+
async function minConsumerCursor(host, parentPid) {
|
|
468
|
+
const cursors = (await readdir(cursorDir(host, parentPid)).catch(() => [])).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5));
|
|
469
|
+
if (cursors.length === 0) return 0;
|
|
470
|
+
let min = Infinity;
|
|
471
|
+
for (const c of cursors) min = Math.min(min, await getCursor(host, parentPid, c));
|
|
472
|
+
return Number.isFinite(min) ? min : 0;
|
|
473
|
+
}
|
|
474
|
+
async function setCursor(host, parentPid, seq, consumer = "parent") {
|
|
475
|
+
const p = cursorPath(host, parentPid, consumer);
|
|
476
|
+
await ensureDir(path.dirname(p));
|
|
477
|
+
await atomicWrite(p, serializeCursor(seq));
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Retention: delete inboxes (+ their seq counter + cursors) for parents that are
|
|
481
|
+
* both dead and unreferenced by any live child. `capBytes` also rotates a live
|
|
482
|
+
* inbox that has grown past the cap, keeping the newest events.
|
|
483
|
+
*/
|
|
484
|
+
async function gcInboxes(host, livePids, liveChildParentPids, capBytes = 10 * 1024 * 1024, hardCapBytes = capBytes * 4) {
|
|
485
|
+
const parents = await listInboxParents(host);
|
|
486
|
+
for (const p of parents) {
|
|
487
|
+
const release = await acquireLock(path.join(inboxDir(host), `${p}.lock`));
|
|
488
|
+
try {
|
|
489
|
+
if (!livePids.has(p) && !liveChildParentPids.has(p)) {
|
|
490
|
+
await rm(inboxPath(host, p), { force: true }).catch(() => {});
|
|
491
|
+
await rm(seqPath(host, p), { force: true }).catch(() => {});
|
|
492
|
+
await rm(cursorDir(host, p), {
|
|
493
|
+
recursive: true,
|
|
494
|
+
force: true
|
|
495
|
+
}).catch(() => {});
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const size = await stat(inboxPath(host, p)).then((s) => s.size).catch(() => 0);
|
|
499
|
+
if (size <= capBytes) continue;
|
|
500
|
+
const events = await readInbox(host, p);
|
|
501
|
+
let kept = rotateKeep(events, capBytes, await minConsumerCursor(host, p));
|
|
502
|
+
let dropped = [];
|
|
503
|
+
if (eventBytes(kept) > hardCapBytes) {
|
|
504
|
+
const before = kept;
|
|
505
|
+
kept = emergencyKeep(kept, capBytes);
|
|
506
|
+
dropped = before.slice(0, before.length - kept.length);
|
|
507
|
+
}
|
|
508
|
+
if (kept.length < events.length) {
|
|
509
|
+
await writeFile(inboxPath(host, p), kept.map(serializeEvent).join("\n") + "\n");
|
|
510
|
+
if (dropped.length > 0) logger.error(`[notify] EMERGENCY TRIM: inbox for parent ${p} exceeded the hard cap ${hardCapBytes} bytes — dropped ${dropped.length} UNACKED events (seq ${dropped[0].seq}..${dropped[dropped.length - 1].seq}). The consumer's cursor is not advancing: run \`ay notify watch --ack\` (or \`ay notify cursor set <seq>\`) for parent ${p}.`);
|
|
511
|
+
} else logger.warn(`[notify] inbox for parent ${p} is ${size} bytes (> soft cap ${capBytes}) but all events are unacked — cursor not advancing?`);
|
|
512
|
+
} finally {
|
|
513
|
+
await release();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** Register / refresh this parent's watch heartbeat (atomically, so a concurrent
|
|
518
|
+
* liveWatchers never reads a torn file and transiently drops the watcher). */
|
|
519
|
+
async function heartbeatWatcher(parentPid, startedAt) {
|
|
520
|
+
await ensureDir(watchersDir());
|
|
521
|
+
await atomicWrite(watcherPath(parentPid), JSON.stringify({
|
|
522
|
+
pid: parentPid,
|
|
523
|
+
started_at: startedAt,
|
|
524
|
+
ts: Date.now(),
|
|
525
|
+
watch_pid: process.pid
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
/** Remove this parent's watch heartbeat (on watch exit). */
|
|
529
|
+
async function clearWatcher(parentPid) {
|
|
530
|
+
await rm(watcherPath(parentPid), { force: true }).catch(() => {});
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Live watchers as a map of parent pid → the parent's own `started_at` (its
|
|
534
|
+
* authoritative self-reported start time, so the daemon never has to guess it
|
|
535
|
+
* from the registry and stamp a 0). A watcher counts as live ONLY if its
|
|
536
|
+
* heartbeat is fresh AND its process is actually alive — so a crashed `watch`
|
|
537
|
+
* whose heartbeat lingers for the TTL doesn't keep the daemon writing to a dead
|
|
538
|
+
* parent's inbox (which would violate "nothing happens unless you watch"). A
|
|
539
|
+
* heartbeat with a missing/non-positive `started_at` is treated as NOT live: the
|
|
540
|
+
* daemon's cross-session scope guard keys off a positive parent start time, so a
|
|
541
|
+
* 0 would defeat it — fail closed.
|
|
542
|
+
*/
|
|
543
|
+
async function liveWatchers(now = Date.now()) {
|
|
544
|
+
const names = await readdir(watchersDir()).catch(() => []);
|
|
545
|
+
const live = /* @__PURE__ */ new Map();
|
|
546
|
+
for (const n of names) {
|
|
547
|
+
if (!n.endsWith(".json")) continue;
|
|
548
|
+
const raw = await readFile(path.join(watchersDir(), n), "utf8").catch(() => "");
|
|
549
|
+
try {
|
|
550
|
+
const o = JSON.parse(raw);
|
|
551
|
+
if (typeof o?.pid === "number" && typeof o?.started_at === "number" && o.started_at > 0 && typeof o?.ts === "number" && now - o.ts <= WATCHER_TTL_MS && pidAlive(o.pid) && (typeof o.watch_pid !== "number" || o.watch_pid <= 0 || pidAlive(o.watch_pid))) live.set(o.pid, o.started_at);
|
|
552
|
+
} catch {}
|
|
553
|
+
}
|
|
554
|
+
return live;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
//#endregion
|
|
558
|
+
export { postmortemStartedAt as S, filterSinceSeq as _, getCursor as a, maxSeq as b, isTransientLockMkdirError as c, minConsumerCursor as d, readInbox as f, daemonLockOwnerPath as g, daemonLockDir as h, gcInboxes as i, listInboxParents as l, shouldStealLock as m, appendEvent as n, heartbeatWatcher as o, setCursor as p, clearWatcher as r, hostId as s, acquireLock as t, liveWatchers as u, filterSinceTs as v, notifyDir as x, filterUnread as y };
|
|
559
|
+
//# sourceMappingURL=notifyStore-Cbw2ZJ0X.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { i as readGlobalPids } from "./globalPidIndex-Dq4KY2IV.js";
|
|
2
|
+
|
|
3
|
+
//#region ts/parentLink.ts
|
|
4
|
+
/**
|
|
5
|
+
* Resolve "who spawned me" from the wrapper-pid link the registry already
|
|
6
|
+
* carries, into an addressable identity.
|
|
7
|
+
*
|
|
8
|
+
* The link itself is just a number: a nested `ay` inherits its parent wrapper's
|
|
9
|
+
* `AGENT_YES_PID` and records it as `parent_pid` (see globalPidIndex's
|
|
10
|
+
* GlobalPidRecord). To actually TALK to that parent — which is what
|
|
11
|
+
* `<ay-init-msg>` and the finished/stuck ping both need — we have to map that
|
|
12
|
+
* wrapper pid back to the parent's canonical record, because the reply route is
|
|
13
|
+
* its `agent_id` (stable across restart), not its pid.
|
|
14
|
+
*
|
|
15
|
+
* Shared by ts/initMsg.ts's caller and ts/parentPing's delivery so both address
|
|
16
|
+
* the SAME identity — a child that reports to a different route than the one
|
|
17
|
+
* printed in its own init block would be maddening to debug.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The parent agent for `parentPid` (a parent WRAPPER pid), or null.
|
|
21
|
+
*
|
|
22
|
+
* Null covers three different situations that all mean the same thing here —
|
|
23
|
+
* "nobody to report to": no parent at all (top-level, human-launched); a parent
|
|
24
|
+
* whose record aged out of the registry; or a parent on another host. Callers
|
|
25
|
+
* treat all three as "skip the wrapper / skip the ping" rather than guessing a
|
|
26
|
+
* route that would silently go nowhere.
|
|
27
|
+
*/
|
|
28
|
+
async function resolveSpawner(parentPid) {
|
|
29
|
+
if (typeof parentPid !== "number" || !Number.isInteger(parentPid) || parentPid <= 0) return null;
|
|
30
|
+
let records;
|
|
31
|
+
try {
|
|
32
|
+
records = await readGlobalPids();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return spawnerFromRecords(records, parentPid);
|
|
37
|
+
}
|
|
38
|
+
/** Pure half of resolveSpawner, so the match rule is testable without the fs. */
|
|
39
|
+
function spawnerFromRecords(records, parentPid) {
|
|
40
|
+
const matches = records.filter((r) => r.wrapper_pid === parentPid);
|
|
41
|
+
const parent = matches.find((r) => r.status !== "exited") ?? matches[0];
|
|
42
|
+
if (!parent) return null;
|
|
43
|
+
return {
|
|
44
|
+
cli: parent.cli,
|
|
45
|
+
pid: parent.pid,
|
|
46
|
+
agentId: parent.agent_id ?? null,
|
|
47
|
+
cwd: parent.cwd
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
export { spawnerFromRecords as n, resolveSpawner as t };
|
|
53
|
+
//# sourceMappingURL=parentLink-C0cIRjfL.js.map
|