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.
- package/dist/{SUPPORTED_CLIS-D1WUoZA9.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-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
- package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
- package/dist/{setup-K6VTmirE.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-BZPW0UVn.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-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
- package/dist/{versionChecker-C7KP-aoh.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/index.ts +13 -4
- 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-BFV10E3f.js +0 -8
- package/dist/pidStore-5qyLX3Fh.js +0 -5
- package/dist/spawnGate-CQ1Il3Xk.js +0 -5
- 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
|
+
});
|