agent-yes 1.169.1 → 1.170.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{SUPPORTED_CLIS-Bwg9VqLI.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
- package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
- package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
- package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
- package/dist/cli.js +10 -212
- package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
- package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
- package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
- package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/notifyDaemon-D5jtpCAR.js +591 -0
- package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
- package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
- package/dist/pidStore-CLxwp0qI.js +5 -0
- package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
- package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
- package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
- package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
- package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
- package/dist/rustBinary-DsO0IlNq.js +228 -0
- package/dist/{schedule-CtVvSyaN.js → schedule-ChTPIcXf.js} +7 -7
- package/dist/{serve-CLmk5ujF.js → serve-De1gAWNI.js} +120 -26
- package/dist/{setup-CYbXOcpH.js → setup-nlMqyEPB.js} +3 -3
- package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
- package/dist/spawnGate-BFhva-2F.js +5 -0
- package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
- package/dist/{subcommands--EUush_X.js → subcommands-BFlpV23b.js} +1049 -44
- package/dist/subcommands-DLDpwrfG.js +9 -0
- package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
- package/dist/{ts-Bbhf5JbN.js → ts-DsxejviJ.js} +8 -8
- package/dist/{versionChecker-BmWkWhcJ.js → versionChecker-Czk3aHqy.js} +2 -2
- package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
- package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
- package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
- package/lab/ui/console-logic.js +79 -10
- package/lab/ui/index.html +174 -8
- package/lab/ui/landing.html +11 -1
- package/package.json +1 -1
- package/scripts/build-rs.ts +9 -5
- package/ts/badges.spec.ts +51 -1
- package/ts/badges.ts +35 -1
- package/ts/notifyDaemon.spec.ts +276 -0
- package/ts/notifyDaemon.ts +698 -0
- package/ts/notifyInbox.spec.ts +192 -0
- package/ts/notifyInbox.ts +294 -0
- package/ts/notifyRouter.spec.ts +355 -0
- package/ts/notifyRouter.ts +288 -0
- package/ts/notifyStore.spec.ts +308 -0
- package/ts/notifyStore.ts +439 -0
- package/ts/reaper.spec.ts +18 -1
- package/ts/reaper.ts +33 -1
- package/ts/rustBinary.ts +30 -0
- package/ts/serve.ts +180 -14
- package/ts/subcommands.spec.ts +341 -0
- package/ts/subcommands.ts +764 -29
- package/ts/workspaceConfig.spec.ts +48 -0
- package/ts/workspaceConfig.ts +46 -3
- package/dist/SUPPORTED_CLIS-DiL99G3j.js +0 -8
- package/dist/pidStore-5qyLX3Fh.js +0 -5
- package/dist/spawnGate-CQ1Il3Xk.js +0 -5
- package/dist/subcommands-1ZcaHYkb.js +0 -9
package/scripts/build-rs.ts
CHANGED
|
@@ -18,11 +18,15 @@ import path from "path";
|
|
|
18
18
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
19
19
|
const cargoHome = process.env.CARGO_HOME || path.join(os.homedir(), ".cargo");
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
];
|
|
21
|
+
// Every binary cargo touches: the build output and the installed copy, for
|
|
22
|
+
// each [[bin]] in the package. Any of these can be locked by a live process,
|
|
23
|
+
// and cargo install refuses to overwrite an existing installed binary (even
|
|
24
|
+
// with --force it can't delete a locked one), so each must be moved aside.
|
|
25
|
+
const exeNames = ["agent-yes.exe", "ay-spawn-hidden.exe"];
|
|
26
|
+
const targets = exeNames.flatMap((exe) => [
|
|
27
|
+
path.join(repoRoot, "rs", "target", "release", exe),
|
|
28
|
+
path.join(cargoHome, "bin", exe),
|
|
29
|
+
]);
|
|
26
30
|
|
|
27
31
|
function moveLockedBinariesAside(): Array<{ original: string; aside: string }> {
|
|
28
32
|
const moved: Array<{ original: string; aside: string }> = [];
|
package/ts/badges.spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { badgeDef, BADGE_DEFS, matchBadges, type BadgeDef } from "./badges.ts";
|
|
2
|
+
import { badgeDef, BADGE_DEFS, matchBadges, TYPING_BADGE, type BadgeDef } from "./badges.ts";
|
|
3
3
|
|
|
4
4
|
describe("matchBadges", () => {
|
|
5
5
|
it("matches goal-active when the /goal status line is on screen", () => {
|
|
@@ -41,6 +41,41 @@ describe("matchBadges", () => {
|
|
|
41
41
|
];
|
|
42
42
|
expect(matchBadges(["alpha and beta both here"], defs)).toEqual(["a", "b"]);
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
it("matches session-limit when claude's usage-limit banner is on screen", () => {
|
|
46
|
+
expect(matchBadges(["You've hit your session limit · resets 9:50pm (Asia/Tokyo)"])).toEqual([
|
|
47
|
+
"session-limit",
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("matches with or without the apostrophe (straight ', curly ’, or none)", () => {
|
|
52
|
+
expect(matchBadges(["You've hit your session limit"])).toEqual(["session-limit"]);
|
|
53
|
+
expect(matchBadges(["Youve hit your session limit"])).toEqual(["session-limit"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not match an unrelated 'limit' mention", () => {
|
|
57
|
+
expect(matchBadges(["rate limit exceeded, please slow down"])).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("matches retrying on claude's self-retry backoff banner", () => {
|
|
61
|
+
expect(
|
|
62
|
+
matchBadges(["✻ Waiting for API response · will retry in 2m 17s · check your network"]),
|
|
63
|
+
).toEqual(["retrying"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("matches retrying across a line-wrapped banner (joined text)", () => {
|
|
67
|
+
expect(
|
|
68
|
+
matchBadges(["✻ Waiting for API response ·", "will retry in 45s · check your network"]),
|
|
69
|
+
).toEqual(["retrying"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("does not light retrying for a normal in-flight wait (no 'will retry')", () => {
|
|
73
|
+
expect(matchBadges(["✻ Waiting for API response… (esc to interrupt)"])).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not light retrying for an agent merely discussing retries (anchored to the banner)", () => {
|
|
77
|
+
expect(matchBadges(["the client will retry in 5 seconds with backoff"])).toEqual([]);
|
|
78
|
+
});
|
|
44
79
|
});
|
|
45
80
|
|
|
46
81
|
describe("badgeDef", () => {
|
|
@@ -51,4 +86,19 @@ describe("badgeDef", () => {
|
|
|
51
86
|
it("returns undefined for an unknown id", () => {
|
|
52
87
|
expect(badgeDef("does-not-exist")).toBeUndefined();
|
|
53
88
|
});
|
|
89
|
+
|
|
90
|
+
it("resolves the time-derived typing badge even though it isn't in BADGE_DEFS", () => {
|
|
91
|
+
expect(badgeDef("typing")).toBe(TYPING_BADGE);
|
|
92
|
+
expect(BADGE_DEFS).not.toContain(TYPING_BADGE);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("TYPING_BADGE", () => {
|
|
97
|
+
it("is never produced by screen matching (its pattern can't match)", () => {
|
|
98
|
+
// Presence is derived from the stdin-activity marker, not the rendered
|
|
99
|
+
// screen — matchBadges must never surface it, even against text mentioning
|
|
100
|
+
// typing. A screen can't cause a false 'user is typing' chip.
|
|
101
|
+
expect(matchBadges(["the user is typing a lot right now", "typing typing typing"])).toEqual([]);
|
|
102
|
+
expect(TYPING_BADGE.pattern.test("typing")).toBe(false);
|
|
103
|
+
});
|
|
54
104
|
});
|
package/ts/badges.ts
CHANGED
|
@@ -26,6 +26,26 @@ export const BADGE_DEFS: BadgeDef[] = [
|
|
|
26
26
|
title: "A /goal Stop-hook loop is active on this agent",
|
|
27
27
|
pattern: /\/goal active/i,
|
|
28
28
|
},
|
|
29
|
+
{
|
|
30
|
+
id: "session-limit",
|
|
31
|
+
label: "limit",
|
|
32
|
+
title: "Usage session limit hit — waiting for the reset time shown on screen",
|
|
33
|
+
pattern: /you['’]?ve hit your session limit/i,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
// The CLI is auto-retrying an API call on its OWN backoff — claude prints
|
|
37
|
+
// "✻ Waiting for API response · will retry in 2m 17s · check your network"
|
|
38
|
+
// and recovers by itself. agent-yes injects NOTHING here (unlike the runtime's
|
|
39
|
+
// autoRetry, which types "retry"); this badge just annotates "waiting on the
|
|
40
|
+
// API, no action needed" so the agent doesn't read as plainly busy. Anchored
|
|
41
|
+
// on the FULL banner (not a bare "will retry in") so an agent merely
|
|
42
|
+
// *discussing* retries can't light it up; `[\s\S]{0,40}` spans the "· "
|
|
43
|
+
// separator and any line-wrap between the two phrases.
|
|
44
|
+
id: "retrying",
|
|
45
|
+
label: "retry",
|
|
46
|
+
title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
|
|
47
|
+
pattern: /Waiting for API response[\s\S]{0,40}will retry in \d/i,
|
|
48
|
+
},
|
|
29
49
|
];
|
|
30
50
|
|
|
31
51
|
/**
|
|
@@ -37,6 +57,20 @@ export function matchBadges(lines: string[], defs: BadgeDef[] = BADGE_DEFS): str
|
|
|
37
57
|
return defs.filter((d) => d.pattern.test(text)).map((d) => d.id);
|
|
38
58
|
}
|
|
39
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A time-derived flag (NOT screen-matched): lit when the user typed at this
|
|
62
|
+
* agent's terminal within the last few seconds. The ls code appends its id from
|
|
63
|
+
* the Rust runner's stdin-activity marker, so it resolves through `badgeDef`
|
|
64
|
+
* like any other chip — but `matchBadges` never produces it (it isn't in
|
|
65
|
+
* BADGE_DEFS, and its pattern can't match), keeping screen matching pure.
|
|
66
|
+
*/
|
|
67
|
+
export const TYPING_BADGE: BadgeDef = {
|
|
68
|
+
id: "typing",
|
|
69
|
+
label: "typing",
|
|
70
|
+
title: "The user is typing at this agent's terminal — ay send backs off until they pause",
|
|
71
|
+
pattern: /(?!)/, // never matches; presence comes from stdin activity, not the screen
|
|
72
|
+
};
|
|
73
|
+
|
|
40
74
|
export function badgeDef(id: string, defs: BadgeDef[] = BADGE_DEFS): BadgeDef | undefined {
|
|
41
|
-
return defs.find((d) => d.id === id);
|
|
75
|
+
return defs.find((d) => d.id === id) ?? (id === TYPING_BADGE.id ? TYPING_BADGE : undefined);
|
|
42
76
|
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import {
|
|
6
|
+
acquireDaemonLock,
|
|
7
|
+
daemonStatus,
|
|
8
|
+
reconcileFromInboxes,
|
|
9
|
+
requestDaemonStop,
|
|
10
|
+
} from "./notifyDaemon.ts";
|
|
11
|
+
import { stat } from "fs/promises";
|
|
12
|
+
import { daemonLockDir, daemonLockOwnerPath } from "./notifyInbox.ts";
|
|
13
|
+
import { appendEvent, hostId } from "./notifyStore.ts";
|
|
14
|
+
import type { NotifyEvent } from "./notifyInbox.ts";
|
|
15
|
+
|
|
16
|
+
// The lock steal/keep race codex flagged: a crashed daemon's stale lock must be
|
|
17
|
+
// stealable (else runDaemon deadlocks forever), while a LIVE owner's lock must
|
|
18
|
+
// be respected (else two daemons run). Liveness is injected so both branches are
|
|
19
|
+
// deterministic.
|
|
20
|
+
describe("notifyd singleton lock", () => {
|
|
21
|
+
let home: string;
|
|
22
|
+
const prev = process.env.AGENT_YES_HOME;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
home = await mkdtemp(path.join(tmpdir(), "ay-notify-lock-"));
|
|
26
|
+
process.env.AGENT_YES_HOME = home;
|
|
27
|
+
});
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
if (prev === undefined) delete process.env.AGENT_YES_HOME;
|
|
30
|
+
else process.env.AGENT_YES_HOME = prev;
|
|
31
|
+
await rm(home, { recursive: true, force: true }).catch(() => {});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("acquires the lock on a clean install (parent dir absent, no ENOENT trap)", async () => {
|
|
35
|
+
// notify/ does not exist yet — the classic clean-install bug was mkdir(lock)
|
|
36
|
+
// throwing ENOENT and being misread as "held". Must acquire cleanly.
|
|
37
|
+
expect(await acquireDaemonLock(() => false)).toBe(true);
|
|
38
|
+
const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
|
|
39
|
+
expect(owner.pid).toBe(process.pid);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("steals a stale lock whose owner is dead (no permanent deadlock)", async () => {
|
|
43
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
44
|
+
await writeFile(
|
|
45
|
+
daemonLockOwnerPath(),
|
|
46
|
+
JSON.stringify({ pid: 424242, started_at: 1, ts: Date.now() }),
|
|
47
|
+
);
|
|
48
|
+
// 424242 reported dead → steal (dead pid, no wait needed).
|
|
49
|
+
expect(await acquireDaemonLock(() => false)).toBe(true);
|
|
50
|
+
const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
|
|
51
|
+
expect(owner.pid).toBe(process.pid);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("refuses the lock when a LIVE owner holds it (no double daemon)", async () => {
|
|
55
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
56
|
+
// A complete, live, fresh owner (a real daemon always heartbeats its ts).
|
|
57
|
+
await writeFile(
|
|
58
|
+
daemonLockOwnerPath(),
|
|
59
|
+
JSON.stringify({ pid: 424242, started_at: 1, ts: Date.now() }),
|
|
60
|
+
);
|
|
61
|
+
expect(await acquireDaemonLock((pid) => pid === 424242)).toBe(false);
|
|
62
|
+
const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
|
|
63
|
+
expect(owner.pid).toBe(424242); // untouched
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("steals a live-but-STALE owner (heartbeat not refreshed → wedged/gone)", async () => {
|
|
67
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
68
|
+
await writeFile(
|
|
69
|
+
daemonLockOwnerPath(),
|
|
70
|
+
JSON.stringify({ pid: 424242, started_at: 1, ts: 1 }), // ancient ts
|
|
71
|
+
);
|
|
72
|
+
expect(await acquireDaemonLock((pid) => pid === 424242)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("eventually steals a torn owner, but only AFTER the grace (no double daemon)", async () => {
|
|
76
|
+
// A torn owner is also the mkdir→writeOwner window of a concurrent daemon
|
|
77
|
+
// start, so it is respected within the grace (~1s) and stolen only past it —
|
|
78
|
+
// the same invariant as the per-inbox lock (the steal decision itself is
|
|
79
|
+
// unit-tested via shouldStealLock in notifyStore.spec).
|
|
80
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
81
|
+
await writeFile(daemonLockOwnerPath(), "{ not json");
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
expect(await acquireDaemonLock(() => true)).toBe(true);
|
|
84
|
+
expect(Date.now() - t0).toBeGreaterThanOrEqual(900); // waited out the grace
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a second live-owner acquire from a DIFFERENT pid is refused", async () => {
|
|
88
|
+
// First acquire writes owner = our pid. A second caller that sees a different
|
|
89
|
+
// live owner must back off (mkdir(recursive:false) is the exclusive proof;
|
|
90
|
+
// the owner is alive so it isn't stolen).
|
|
91
|
+
expect(await acquireDaemonLock(() => true)).toBe(true);
|
|
92
|
+
const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
|
|
93
|
+
// Rewrite the owner to a DIFFERENT, "alive" pid to simulate another daemon.
|
|
94
|
+
await writeFile(
|
|
95
|
+
daemonLockOwnerPath(),
|
|
96
|
+
JSON.stringify({ ...owner, pid: owner.pid + 1 }),
|
|
97
|
+
);
|
|
98
|
+
expect(await acquireDaemonLock((pid) => pid === owner.pid + 1)).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("notifyd identity (status/stop safety)", () => {
|
|
103
|
+
let home: string;
|
|
104
|
+
const prev = process.env.AGENT_YES_HOME;
|
|
105
|
+
beforeEach(async () => {
|
|
106
|
+
home = await mkdtemp(path.join(tmpdir(), "ay-notify-id-"));
|
|
107
|
+
process.env.AGENT_YES_HOME = home;
|
|
108
|
+
});
|
|
109
|
+
afterEach(async () => {
|
|
110
|
+
if (prev === undefined) delete process.env.AGENT_YES_HOME;
|
|
111
|
+
else process.env.AGENT_YES_HOME = prev;
|
|
112
|
+
await rm(home, { recursive: true, force: true }).catch(() => {});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("trusts a live owner with a fresh heartbeat", async () => {
|
|
116
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
117
|
+
await writeFile(
|
|
118
|
+
daemonLockOwnerPath(),
|
|
119
|
+
JSON.stringify({ pid: process.pid, started_at: 1, ts: Date.now() }),
|
|
120
|
+
);
|
|
121
|
+
expect(await daemonStatus()).toBe(process.pid);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns null for a STALE heartbeat even if the pid is alive (pid-reuse guard)", async () => {
|
|
125
|
+
// process.pid is alive, but its owner ts is ancient — a recycled pid that
|
|
126
|
+
// isn't refreshing THIS file must not be trusted or killed.
|
|
127
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
128
|
+
await writeFile(
|
|
129
|
+
daemonLockOwnerPath(),
|
|
130
|
+
JSON.stringify({ pid: process.pid, started_at: 1, ts: 1 }),
|
|
131
|
+
);
|
|
132
|
+
expect(await daemonStatus()).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns null when no owner file exists", async () => {
|
|
136
|
+
expect(await daemonStatus()).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns null for an INCOMPLETE owner missing started_at (I1)", async () => {
|
|
140
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
141
|
+
// pid alive + fresh ts, but no started_at → identity incomplete, not trusted.
|
|
142
|
+
await writeFile(
|
|
143
|
+
daemonLockOwnerPath(),
|
|
144
|
+
JSON.stringify({ pid: process.pid, ts: Date.now() }),
|
|
145
|
+
);
|
|
146
|
+
expect(await daemonStatus()).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("requestDaemonStop removes the lock (cooperative) for a valid owner", async () => {
|
|
150
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
151
|
+
await writeFile(
|
|
152
|
+
daemonLockOwnerPath(),
|
|
153
|
+
JSON.stringify({ pid: process.pid, started_at: 1, ts: Date.now(), token: "T" }),
|
|
154
|
+
);
|
|
155
|
+
expect(await requestDaemonStop()).toBe(process.pid);
|
|
156
|
+
await expect(stat(daemonLockDir())).rejects.toThrow(); // lock removed
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("requestDaemonStop refuses an owner with no fencing token (can't confirm identity)", async () => {
|
|
160
|
+
await mkdir(daemonLockDir(), { recursive: true });
|
|
161
|
+
await writeFile(
|
|
162
|
+
daemonLockOwnerPath(),
|
|
163
|
+
JSON.stringify({ pid: process.pid, started_at: 1, ts: Date.now() }), // no token
|
|
164
|
+
);
|
|
165
|
+
expect(await requestDaemonStop()).toBeNull();
|
|
166
|
+
await expect(stat(daemonLockDir())).resolves.toBeTruthy(); // lock untouched
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("startup reconcile pid-reuse guard", () => {
|
|
171
|
+
let home: string;
|
|
172
|
+
const prev = process.env.AGENT_YES_HOME;
|
|
173
|
+
const host = hostId();
|
|
174
|
+
beforeEach(async () => {
|
|
175
|
+
home = await mkdtemp(path.join(tmpdir(), "ay-notify-rec-"));
|
|
176
|
+
process.env.AGENT_YES_HOME = home;
|
|
177
|
+
});
|
|
178
|
+
afterEach(async () => {
|
|
179
|
+
if (prev === undefined) delete process.env.AGENT_YES_HOME;
|
|
180
|
+
else process.env.AGENT_YES_HOME = prev;
|
|
181
|
+
await rm(home, { recursive: true, force: true }).catch(() => {});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const exitedEv = (childPid: number, childStartedAt: number): Omit<NotifyEvent, "seq"> => ({
|
|
185
|
+
ts: 1,
|
|
186
|
+
host,
|
|
187
|
+
parent_pid: 1,
|
|
188
|
+
child_pid: childPid,
|
|
189
|
+
child_started_at: childStartedAt,
|
|
190
|
+
cli: "claude",
|
|
191
|
+
cwd: "/repo",
|
|
192
|
+
edge: "exited",
|
|
193
|
+
prev_state: "active",
|
|
194
|
+
state: "stopped",
|
|
195
|
+
question: null,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("seeds a child still live with the SAME start time (no re-emit) + copies identity", async () => {
|
|
199
|
+
await appendEvent(1, { ...exitedEv(555, 1000), parent_started_at: 42 });
|
|
200
|
+
const seeded = await reconcileFromInboxes(host, new Map([[555, 1000]]), new Set([1]));
|
|
201
|
+
expect(seeded.get(555)?.exitedEmitted).toBe(true);
|
|
202
|
+
// I2: identity copied into the seeded state so the hot-path guard can fire.
|
|
203
|
+
expect(seeded.get(555)?.started_at).toBe(1000);
|
|
204
|
+
expect(seeded.get(555)?.parent_started_at).toBe(42);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("does NOT inherit exitedEmitted when the pid was reused after an exit (identity-aware)", async () => {
|
|
208
|
+
// Old child (pid 555, started 1000) exited; then pid 555 is reused by a NEW
|
|
209
|
+
// child (started 2000) whose latest edge is idle. On reconcile the new child
|
|
210
|
+
// matches liveStarted=2000, and its exitedEmitted must be FALSE (its own exit
|
|
211
|
+
// still fires later), not inherited from the old incarnation's exit.
|
|
212
|
+
await appendEvent(1, exitedEv(555, 1000)); // old child exited
|
|
213
|
+
await appendEvent(1, {
|
|
214
|
+
...exitedEv(555, 2000),
|
|
215
|
+
edge: "idle",
|
|
216
|
+
state: "idle",
|
|
217
|
+
}); // new child's later idle
|
|
218
|
+
const cs = (await reconcileFromInboxes(host, new Map([[555, 2000]]), new Set([1]))).get(555)!;
|
|
219
|
+
expect(cs.started_at).toBe(2000);
|
|
220
|
+
expect(cs.exitedEmitted).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does NOT seed the idle emitted flag — a restart RE-CONFIRMS idle (never suppress)", async () => {
|
|
224
|
+
const idleEv: Omit<NotifyEvent, "seq"> = {
|
|
225
|
+
ts: 1,
|
|
226
|
+
host,
|
|
227
|
+
parent_pid: 1,
|
|
228
|
+
child_pid: 555,
|
|
229
|
+
child_started_at: 1000,
|
|
230
|
+
cli: "claude",
|
|
231
|
+
cwd: "/repo",
|
|
232
|
+
edge: "idle",
|
|
233
|
+
prev_state: "active",
|
|
234
|
+
state: "idle",
|
|
235
|
+
question: null,
|
|
236
|
+
};
|
|
237
|
+
await appendEvent(1, idleEv);
|
|
238
|
+
const cs = (await reconcileFromInboxes(host, new Map([[555, 1000]]), new Set([1]))).get(555)!;
|
|
239
|
+
// Identity is seeded (hot-path guard), but the idle episode is NOT marked
|
|
240
|
+
// emitted → the post-restart idle observation re-confirms and re-emits.
|
|
241
|
+
expect(cs.started_at).toBe(1000);
|
|
242
|
+
expect(cs.idleEmitted).toBe(false);
|
|
243
|
+
expect(cs.idleSince).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("does NOT seed a pid whose live start time differs (reused pid emits fresh)", async () => {
|
|
247
|
+
await appendEvent(1, exitedEv(555, 1000));
|
|
248
|
+
// pid 555 now belongs to a NEW child (started_at 2000) → must not inherit
|
|
249
|
+
// the old child's exitedEmitted, else its own exit would be suppressed.
|
|
250
|
+
const seeded = await reconcileFromInboxes(host, new Map([[555, 2000]]), new Set([1]));
|
|
251
|
+
expect(seeded.has(555)).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("does NOT seed a reaped child absent from the registry", async () => {
|
|
255
|
+
await appendEvent(1, exitedEv(555, 1000));
|
|
256
|
+
const seeded = await reconcileFromInboxes(host, new Map(), new Set([1]));
|
|
257
|
+
expect(seeded.has(555)).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("does NOT seed an event with no child_started_at (C2: can't verify → re-emit)", async () => {
|
|
261
|
+
// A pre-C2 / synthetic event without a start time can't be identity-checked,
|
|
262
|
+
// so it must not seed (better a duplicate edge than a suppressed one).
|
|
263
|
+
await appendEvent(1, { ...exitedEv(555, 1000), child_started_at: 0 });
|
|
264
|
+
const seeded = await reconcileFromInboxes(host, new Map([[555, 1000]]), new Set([1]));
|
|
265
|
+
expect(seeded.has(555)).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("does NOT seed an inbox whose parent is NOT currently watched (I1)", async () => {
|
|
269
|
+
// Parent 1's inbox exists, but no one is watching parent 1 now → the daemon
|
|
270
|
+
// must not hold its state (else it could later write a synthetic exited into
|
|
271
|
+
// an unwatched inbox, violating "nothing happens unless you watch").
|
|
272
|
+
await appendEvent(1, exitedEv(555, 1000));
|
|
273
|
+
const seeded = await reconcileFromInboxes(host, new Map([[555, 1000]]), new Set()); // none watched
|
|
274
|
+
expect(seeded.size).toBe(0);
|
|
275
|
+
});
|
|
276
|
+
});
|