agent-yes 1.169.0 → 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.
Files changed (62) hide show
  1. package/dist/{SUPPORTED_CLIS-D1WUoZA9.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
  3. package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
  4. package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
  5. package/dist/cli.js +10 -212
  6. package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
  7. package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
  8. package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
  9. package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
  10. package/dist/index.js +4 -4
  11. package/dist/notifyDaemon-D5jtpCAR.js +591 -0
  12. package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
  13. package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
  14. package/dist/pidStore-CLxwp0qI.js +5 -0
  15. package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
  16. package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
  17. package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
  18. package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
  19. package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
  20. package/dist/rustBinary-DsO0IlNq.js +228 -0
  21. package/dist/{schedule-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
  22. package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
  23. package/dist/{setup-K6VTmirE.js → setup-nlMqyEPB.js} +3 -3
  24. package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
  25. package/dist/spawnGate-BFhva-2F.js +5 -0
  26. package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
  27. package/dist/{subcommands-BZPW0UVn.js → subcommands-BFlpV23b.js} +1049 -44
  28. package/dist/subcommands-DLDpwrfG.js +9 -0
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
  31. package/dist/{versionChecker-C7KP-aoh.js → versionChecker-Czk3aHqy.js} +2 -2
  32. package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
  33. package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
  34. package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
  35. package/lab/ui/console-logic.js +79 -10
  36. package/lab/ui/index.html +174 -8
  37. package/lab/ui/landing.html +11 -1
  38. package/package.json +1 -1
  39. package/scripts/build-rs.ts +9 -5
  40. package/ts/badges.spec.ts +51 -1
  41. package/ts/badges.ts +35 -1
  42. package/ts/index.ts +13 -4
  43. package/ts/notifyDaemon.spec.ts +276 -0
  44. package/ts/notifyDaemon.ts +698 -0
  45. package/ts/notifyInbox.spec.ts +192 -0
  46. package/ts/notifyInbox.ts +294 -0
  47. package/ts/notifyRouter.spec.ts +355 -0
  48. package/ts/notifyRouter.ts +288 -0
  49. package/ts/notifyStore.spec.ts +308 -0
  50. package/ts/notifyStore.ts +439 -0
  51. package/ts/reaper.spec.ts +18 -1
  52. package/ts/reaper.ts +33 -1
  53. package/ts/rustBinary.ts +30 -0
  54. package/ts/serve.ts +180 -14
  55. package/ts/subcommands.spec.ts +341 -0
  56. package/ts/subcommands.ts +764 -29
  57. package/ts/workspaceConfig.spec.ts +48 -0
  58. package/ts/workspaceConfig.ts +46 -3
  59. package/dist/SUPPORTED_CLIS-BFV10E3f.js +0 -8
  60. package/dist/pidStore-5qyLX3Fh.js +0 -5
  61. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  62. package/dist/subcommands-DuGwtmz5.js +0 -9
@@ -0,0 +1,308 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtemp, readFile, rm, stat, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import path from "path";
5
+ import {
6
+ acquireLock,
7
+ appendEvent,
8
+ clearWatcher,
9
+ gcInboxes,
10
+ heartbeatWatcher,
11
+ hostId,
12
+ listInboxParents,
13
+ liveWatchers,
14
+ minConsumerCursor,
15
+ readInbox,
16
+ setCursor,
17
+ shouldStealLock,
18
+ } from "./notifyStore.ts";
19
+ import { inboxPath, type NotifyEvent } from "./notifyInbox.ts";
20
+
21
+ const host = hostId();
22
+ const baseEvent = (edge: NotifyEvent["edge"], over: Partial<NotifyEvent> = {}) => ({
23
+ ts: 1_000,
24
+ host,
25
+ parent_pid: 999,
26
+ child_pid: 555,
27
+ cli: "claude",
28
+ cwd: "/repo",
29
+ edge,
30
+ prev_state: "active",
31
+ state: edge === "exited" ? "stopped" : edge,
32
+ question: null,
33
+ ...over,
34
+ });
35
+
36
+ describe("notifyStore — lock steal decision (C1: holder liveness, not wait time)", () => {
37
+ const now = 1_000_000;
38
+ const opts = (over = {}) => ({
39
+ staleMs: 30_000,
40
+ lockAgeMs: 0,
41
+ selfPid: 1,
42
+ isAlive: (p: number) => p === 42, // only pid 42 is "alive"
43
+ ...over,
44
+ });
45
+
46
+ it("does NOT steal a LIVE holder with a fresh heartbeat, regardless of lock age", () => {
47
+ const owner = JSON.stringify({ pid: 42, ts: now - 1_000 });
48
+ // A live holder with a fresh heartbeat is inviolable — lockAgeMs only gates
49
+ // the torn-owner grace, never a holder we have positive live evidence for.
50
+ expect(shouldStealLock(owner, now, opts({ lockAgeMs: 50_000 }))).toBe(false);
51
+ expect(shouldStealLock(owner, now, opts({ lockAgeMs: 10 * 60_000 }))).toBe(false);
52
+ });
53
+
54
+ it("steals a DEAD holder", () => {
55
+ const owner = JSON.stringify({ pid: 99, ts: now }); // 99 not alive
56
+ expect(shouldStealLock(owner, now, opts())).toBe(true);
57
+ });
58
+
59
+ it("steals a live holder whose heartbeat is STALE (wedged)", () => {
60
+ const owner = JSON.stringify({ pid: 42, ts: now - 40_000 }); // alive but stale
61
+ expect(shouldStealLock(owner, now, opts())).toBe(true);
62
+ });
63
+
64
+ it("does NOT steal a torn/empty owner within the grace (mkdir→writeFile window)", () => {
65
+ // A just-created lock whose owner file isn't written yet must be respected,
66
+ // else two writers race into the critical section and duplicate a seq.
67
+ expect(shouldStealLock("", now, opts({ lockAgeMs: 0, graceMs: 1000 }))).toBe(false);
68
+ expect(shouldStealLock("{ torn", now, opts({ lockAgeMs: 100, graceMs: 1000 }))).toBe(false);
69
+ });
70
+
71
+ it("steals a torn/empty owner once the grace elapses (holder crashed mid-acquire)", () => {
72
+ expect(shouldStealLock("", now, opts({ lockAgeMs: 1500, graceMs: 1000 }))).toBe(true);
73
+ });
74
+
75
+ it("treats an owner with no heartbeat ts as torn (grace), not immediately steal", () => {
76
+ const owner = JSON.stringify({ pid: 42, started_at: 1 }); // no ts
77
+ expect(shouldStealLock(owner, now, opts({ lockAgeMs: 0, graceMs: 1000 }))).toBe(false);
78
+ expect(shouldStealLock(owner, now, opts({ lockAgeMs: 1500, graceMs: 1000 }))).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("notifyStore (fs)", () => {
83
+ let home: string;
84
+ const prev = process.env.AGENT_YES_HOME;
85
+ beforeEach(async () => {
86
+ home = await mkdtemp(path.join(tmpdir(), "ay-notify-store-"));
87
+ process.env.AGENT_YES_HOME = home;
88
+ });
89
+ afterEach(async () => {
90
+ if (prev === undefined) delete process.env.AGENT_YES_HOME;
91
+ else process.env.AGENT_YES_HOME = prev;
92
+ await rm(home, { recursive: true, force: true }).catch(() => {});
93
+ });
94
+
95
+ it("appends events with a strictly increasing per-inbox seq (locked)", async () => {
96
+ // Concurrent appends must still get unique, increasing seqs.
97
+ const seqs = await Promise.all([
98
+ appendEvent(999, baseEvent("idle")),
99
+ appendEvent(999, baseEvent("needs_input")),
100
+ appendEvent(999, baseEvent("exited")),
101
+ ]);
102
+ const sorted = [...seqs].sort((a, b) => a - b);
103
+ expect(sorted).toEqual([1, 2, 3]);
104
+ const inbox = await readInbox(host, 999);
105
+ expect(inbox.map((e) => e.seq).sort((a, b) => a - b)).toEqual([1, 2, 3]);
106
+ });
107
+
108
+ it("many concurrent appends get unique, contiguous seqs (lock serializes)", async () => {
109
+ // 30 writers race on the same inbox. Ownership is proven only by mkdir, so
110
+ // even under the steal path no two writers share the critical section →
111
+ // seqs are 1..30 with no gap or duplicate.
112
+ const seqs = await Promise.all(
113
+ Array.from({ length: 30 }, () => appendEvent(999, baseEvent("idle"))),
114
+ );
115
+ expect([...seqs].sort((a, b) => a - b)).toEqual(Array.from({ length: 30 }, (_, i) => i + 1));
116
+ const inbox = await readInbox(host, 999);
117
+ expect(inbox.length).toBe(30);
118
+ expect(new Set(inbox.map((e) => e.seq)).size).toBe(30); // no duplicates
119
+ });
120
+
121
+ it("trusts the sidecar seq counter across a GC that shrank the inbox (I2)", async () => {
122
+ // Fill past rotateKeep's minKeep(100), ack most, then GC to a smaller inbox;
123
+ // the counter must keep seq monotonic so the next append is maxSeq+1, NOT
124
+ // max(remaining events)+1.
125
+ for (let i = 0; i < 130; i++) await appendEvent(999, baseEvent("idle"));
126
+ await setCursor(host, 999, 120, "parent");
127
+ await gcInboxes(host, new Set([999]), new Set([999]), 1); // trims acked (<=120)
128
+ const after = await readInbox(host, 999);
129
+ expect(after.length).toBeLessThan(130); // did shrink
130
+ const nextSeq = await appendEvent(999, baseEvent("needs_input"));
131
+ expect(nextSeq).toBe(131); // continues from the counter, no seq reuse
132
+ });
133
+
134
+ it("release() does NOT delete a lock that was STOLEN out from under it (fencing)", async () => {
135
+ const { mkdtemp, writeFile } = await import("fs/promises");
136
+ const { tmpdir } = await import("os");
137
+ const lockDir = path.join(await mkdtemp(path.join(tmpdir(), "ay-fence-")), "L");
138
+ const release = await acquireLock(lockDir, 30_000);
139
+ // Simulate another writer stealing + re-creating the lock: its owner file now
140
+ // carries a DIFFERENT fencing token.
141
+ await writeFile(
142
+ path.join(lockDir, "owner"),
143
+ JSON.stringify({ pid: 999999, ts: Date.now(), token: "someone-else" }),
144
+ );
145
+ await release();
146
+ // We must NOT have removed the new owner's lock.
147
+ await expect(stat(lockDir)).resolves.toBeTruthy();
148
+ });
149
+
150
+ it("release() deletes the lock when we still hold it", async () => {
151
+ const { mkdtemp } = await import("fs/promises");
152
+ const { tmpdir } = await import("os");
153
+ const lockDir = path.join(await mkdtemp(path.join(tmpdir(), "ay-fence2-")), "L");
154
+ const release = await acquireLock(lockDir, 30_000);
155
+ await release();
156
+ await expect(stat(lockDir)).rejects.toThrow(); // gone
157
+ });
158
+
159
+ it("refreshes the lock owner heartbeat while held (I2: a long GC can't be stolen)", async () => {
160
+ const { mkdtemp } = await import("fs/promises");
161
+ const { tmpdir } = await import("os");
162
+ const lockDir = path.join(await mkdtemp(path.join(tmpdir(), "ay-lock-")), "L");
163
+ // staleMs 1500 → beat every max(500, 500) = 500ms.
164
+ const release = await acquireLock(lockDir, 1500);
165
+ const ownerFile = path.join(lockDir, "owner");
166
+ const ts1 = JSON.parse(await readFile(ownerFile, "utf8")).ts as number;
167
+ await new Promise((r) => setTimeout(r, 700)); // past one beat interval
168
+ const ts2 = JSON.parse(await readFile(ownerFile, "utf8")).ts as number;
169
+ expect(ts2).toBeGreaterThan(ts1); // heartbeat advanced → holder stays "fresh"
170
+ await release();
171
+ });
172
+
173
+ it("never reuses a RESERVED-but-unused seq (crash-safety: gap OK, dup NG)", async () => {
174
+ const { mkdir, writeFile } = await import("fs/promises");
175
+ const { inboxDir, inboxPath, seqPath } = await import("./notifyInbox.ts");
176
+ await mkdir(inboxDir(host), { recursive: true });
177
+ // Simulate a crash between "reserve seq 5" and "append line": the counter
178
+ // says 5, but the inbox only has 1..3 (4 and 5 are reserved gaps).
179
+ await writeFile(inboxPath(host, 700), [1, 2, 3].map((s) => JSON.stringify(baseEvent("idle", { seq: s }))).join("\n") + "\n");
180
+ await writeFile(seqPath(host, 700), "5");
181
+ const next = await appendEvent(700, baseEvent("idle"));
182
+ expect(next).toBe(6); // continues past the reserved gap, never reuses 4 or 5
183
+ });
184
+
185
+ it("registers and expires watcher heartbeats (fresh AND process alive)", async () => {
186
+ const alive = process.pid; // a definitely-alive pid
187
+ await heartbeatWatcher(alive, 111);
188
+ const live = await liveWatchers();
189
+ expect(live.has(alive)).toBe(true);
190
+ expect(live.get(alive)).toBe(111); // parent's self-reported started_at
191
+ await clearWatcher(alive);
192
+ expect((await liveWatchers()).has(alive)).toBe(false);
193
+ // A far-future `now` makes the heartbeat stale even for a live pid.
194
+ await heartbeatWatcher(alive, 111);
195
+ expect((await liveWatchers(Date.now() + 10 * 60_000)).size).toBe(0);
196
+ });
197
+
198
+ it("drops a watcher whose heartbeat is fresh but whose process is DEAD (I3)", async () => {
199
+ await heartbeatWatcher(424242, 999); // 424242 is not a live process
200
+ expect((await liveWatchers()).has(424242)).toBe(false);
201
+ });
202
+
203
+ it("drops a watcher with a missing/non-positive started_at (fail-closed)", async () => {
204
+ // A 0 started_at would defeat the daemon's cross-session scope guard, so such
205
+ // a heartbeat is treated as NOT live.
206
+ await heartbeatWatcher(process.pid, 0);
207
+ expect((await liveWatchers()).has(process.pid)).toBe(false);
208
+ });
209
+
210
+ it("minConsumerCursor returns the smallest cursor across consumers (0 if none)", async () => {
211
+ expect(await minConsumerCursor(host, 999)).toBe(0);
212
+ await setCursor(host, 999, 8, "parent");
213
+ await setCursor(host, 999, 3, "auditor");
214
+ expect(await minConsumerCursor(host, 999)).toBe(3);
215
+ });
216
+
217
+ it("GC deletes a dead-parent inbox but keeps a referenced one", async () => {
218
+ await appendEvent(999, baseEvent("idle"));
219
+ await appendEvent(888, baseEvent("idle", { parent_pid: 888 }));
220
+ expect((await listInboxParents(host)).sort((a, b) => a - b)).toEqual([888, 999]);
221
+ // 999 dead + unreferenced → GC; 888 referenced by a live child → keep.
222
+ await gcInboxes(host, new Set<number>(), new Set([888]));
223
+ expect(await listInboxParents(host)).toEqual([888]);
224
+ });
225
+
226
+ it("concurrent GC and appends never lose an event (read+write both under lock)", async () => {
227
+ // Seed enough to trigger rotation, ack a chunk so GC has something to trim.
228
+ for (let i = 0; i < 40; i++) await appendEvent(999, baseEvent("idle"));
229
+ await setCursor(host, 999, 15, "parent");
230
+ // Fire a GC pass concurrently with a burst of appends. Because GC reads AND
231
+ // writes inside the inbox lock, no append can be clobbered by a stale-snapshot
232
+ // rewrite — every appended seq must survive.
233
+ const appends = Array.from({ length: 20 }, () => appendEvent(999, baseEvent("needs_input")));
234
+ const [seqs] = await Promise.all([
235
+ Promise.all(appends),
236
+ gcInboxes(host, new Set([999]), new Set([999]), 1),
237
+ ]);
238
+ const inbox = await readInbox(host, 999);
239
+ const present = new Set(inbox.map((e) => e.seq));
240
+ for (const s of seqs) expect(present.has(s)).toBe(true); // no append lost
241
+ // Seqs are still unique (no duplication from a racing rewrite).
242
+ expect(inbox.length).toBe(new Set(inbox.map((e) => e.seq)).size);
243
+ });
244
+
245
+ it("GC rotation never drops unacked events even past the byte cap", async () => {
246
+ for (let i = 0; i < 50; i++) await appendEvent(999, baseEvent("idle"));
247
+ // Ack up to seq 20 for the sole consumer; cap of 1 byte forces rotation.
248
+ await setCursor(host, 999, 20, "parent");
249
+ await gcInboxes(host, new Set([999]), new Set([999]), 1);
250
+ const inbox = await readInbox(host, 999);
251
+ // Every unacked event (seq > 20) survives.
252
+ for (let seq = 21; seq <= 50; seq++) expect(inbox.some((e) => e.seq === seq)).toBe(true);
253
+ // The file did shrink (some acked events evicted) OR stayed — either way, no
254
+ // unacked loss. Confirm the inbox still exists.
255
+ await expect(stat(inboxPath(host, 999))).resolves.toBeTruthy();
256
+ });
257
+ });
258
+
259
+ // These exercise the O(file) self-heal fallback (counter lost/corrupt) and the
260
+ // "no reader / never acked" cursor defaults — all pure-fs, so they run and count
261
+ // on every platform (Windows included), unlike the FIFO/e2e paths.
262
+ describe("notifyStore — seq self-heal + cursor defaults", () => {
263
+ let home: string;
264
+ const prev = process.env.AGENT_YES_HOME;
265
+ beforeEach(async () => {
266
+ home = await mkdtemp(path.join(tmpdir(), "ay-notify-heal-"));
267
+ process.env.AGENT_YES_HOME = home;
268
+ });
269
+ afterEach(async () => {
270
+ if (prev === undefined) delete process.env.AGENT_YES_HOME;
271
+ else process.env.AGENT_YES_HOME = prev;
272
+ await rm(home, { recursive: true, force: true }).catch(() => {});
273
+ });
274
+
275
+ it("self-heals the seq from the inbox when the .seq counter is MISSING (no reuse)", async () => {
276
+ // The sidecar counter is the O(1) hot path. If it's lost (e.g. a partial
277
+ // cleanup deleted it but left the inbox), appendEvent must fall back to an
278
+ // O(file) scan of the inbox and continue from maxSeq — never reset to 1 and
279
+ // duplicate a seq that a cursor has already acked past.
280
+ const { seqPath } = await import("./notifyInbox.ts");
281
+ await appendEvent(999, baseEvent("idle")); // seq 1
282
+ await appendEvent(999, baseEvent("idle")); // seq 2
283
+ await rm(seqPath(host, 999), { force: true }); // lose the counter
284
+ const seq = await appendEvent(999, baseEvent("needs_input"));
285
+ expect(seq).toBe(3); // scanned the inbox (maxSeq 2) → 3, not a reused 1
286
+ });
287
+
288
+ it("self-heals from the inbox when the .seq counter is CORRUPT (non-numeric)", async () => {
289
+ const { seqPath } = await import("./notifyInbox.ts");
290
+ await appendEvent(999, baseEvent("idle")); // seq 1
291
+ await writeFile(seqPath(host, 999), "not-a-number\n"); // torn/garbage counter
292
+ const seq = await appendEvent(999, baseEvent("idle"));
293
+ expect(seq).toBe(2); // NaN counter → fall back to the inbox scan → 2
294
+ });
295
+
296
+ it("getCursor returns 0 when the consumer has never acked (no cursor file)", async () => {
297
+ const { getCursor } = await import("./notifyStore.ts");
298
+ expect(await getCursor(host, 999)).toBe(0);
299
+ expect(await getCursor(host, 999, "auditor")).toBe(0);
300
+ });
301
+
302
+ it("minConsumerCursor returns 0 (protect-everything) when a parent has no consumers", async () => {
303
+ // A 0 watermark makes rotation treat every event as unacked → evicts nothing,
304
+ // so an inbox nobody reads is never trimmed.
305
+ await appendEvent(999, baseEvent("idle"));
306
+ expect(await minConsumerCursor(host, 999)).toBe(0);
307
+ });
308
+ });