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
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { classifyNeedsInput } from "./needsInput.ts";
|
|
3
|
+
import {
|
|
4
|
+
type ChildObservation,
|
|
5
|
+
type RouterState,
|
|
6
|
+
stepRouter,
|
|
7
|
+
} from "./notifyRouter.ts";
|
|
8
|
+
|
|
9
|
+
// Mirror the claude `needsInput`/`working` config (rs/default.config.yaml): the
|
|
10
|
+
// menu cursor sits on a NUMBERED option, and a spinner marker means "working".
|
|
11
|
+
const CFG = {
|
|
12
|
+
needsInput: [/❯ ?\d+\./m],
|
|
13
|
+
working: [/esc to interrupt/, /to run in background/],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const child = (over: Partial<ChildObservation> = {}): ChildObservation => ({
|
|
17
|
+
pid: 100,
|
|
18
|
+
wrapper_pid: 100,
|
|
19
|
+
started_at: 7000,
|
|
20
|
+
parent_pid: 1,
|
|
21
|
+
parent_started_at: 3000,
|
|
22
|
+
cli: "claude",
|
|
23
|
+
cwd: "/repo",
|
|
24
|
+
state: "active",
|
|
25
|
+
question: null,
|
|
26
|
+
...over,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const step = (prev: RouterState, obs: ChildObservation[], now: number, idleConfirmMs = 30_000) =>
|
|
30
|
+
stepRouter(prev, obs, now, { idleConfirmMs });
|
|
31
|
+
|
|
32
|
+
describe("notifyRouter — idle hysteresis (P1)", () => {
|
|
33
|
+
it("does NOT emit idle until the child has been idle for idleConfirmMs", () => {
|
|
34
|
+
let s: RouterState = new Map();
|
|
35
|
+
let r = step(s, [child({ state: "idle" })], 0);
|
|
36
|
+
expect(r.events).toEqual([]); // timer just started
|
|
37
|
+
s = r.next;
|
|
38
|
+
|
|
39
|
+
r = step(s, [child({ state: "idle" })], 10_000);
|
|
40
|
+
expect(r.events).toEqual([]); // still within the window
|
|
41
|
+
s = r.next;
|
|
42
|
+
|
|
43
|
+
r = step(s, [child({ state: "idle" })], 30_000);
|
|
44
|
+
expect(r.events.map((e) => e.edge)).toEqual(["idle"]); // confirmed
|
|
45
|
+
s = r.next;
|
|
46
|
+
|
|
47
|
+
r = step(s, [child({ state: "idle" })], 40_000);
|
|
48
|
+
expect(r.events).toEqual([]); // edge, not level — one per episode
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("treats active→idle→active→idle as two separate idle episodes", () => {
|
|
52
|
+
let s: RouterState = new Map();
|
|
53
|
+
s = step(s, [child({ state: "idle" })], 0).next;
|
|
54
|
+
s = step(s, [child({ state: "idle" })], 30_000).next; // episode 1 emits
|
|
55
|
+
// work resumes — resets the episode
|
|
56
|
+
s = step(s, [child({ state: "active" })], 35_000).next;
|
|
57
|
+
let r = step(s, [child({ state: "idle" })], 40_000);
|
|
58
|
+
expect(r.events).toEqual([]); // new episode, timer restarts
|
|
59
|
+
s = r.next;
|
|
60
|
+
r = step(s, [child({ state: "idle" })], 70_000);
|
|
61
|
+
expect(r.events.map((e) => e.edge)).toEqual(["idle"]); // episode 2 emits
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("a long silent working spell (state stays active) never emits idle", () => {
|
|
65
|
+
// The load-bearing guard: a 2-minute test run is `active`, not `idle`, so no
|
|
66
|
+
// amount of elapsed wall-clock produces a false idle edge.
|
|
67
|
+
let s: RouterState = new Map();
|
|
68
|
+
for (const t of [0, 30_000, 60_000, 120_000]) {
|
|
69
|
+
const r = step(s, [child({ state: "active" })], t);
|
|
70
|
+
expect(r.events).toEqual([]);
|
|
71
|
+
s = r.next;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("notifyRouter — needs_input", () => {
|
|
77
|
+
it("emits immediately on entering needs_input, with the question", () => {
|
|
78
|
+
const r = step(new Map(), [child({ state: "needs_input", question: "Approve? 1.Yes 2.No" })], 0);
|
|
79
|
+
expect(r.events).toHaveLength(1);
|
|
80
|
+
expect(r.events[0]!.edge).toBe("needs_input");
|
|
81
|
+
expect(r.events[0]!.question).toBe("Approve? 1.Yes 2.No");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not re-fire while the same question stays on screen", () => {
|
|
85
|
+
let s: RouterState = new Map();
|
|
86
|
+
s = step(s, [child({ state: "needs_input", question: "Q1" })], 0).next;
|
|
87
|
+
const r = step(s, [child({ state: "needs_input", question: "Q1" })], 1_000);
|
|
88
|
+
expect(r.events).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("re-fires when the compact question changes (a genuinely new question)", () => {
|
|
92
|
+
let s: RouterState = new Map();
|
|
93
|
+
s = step(s, [child({ state: "needs_input", question: "Q1" })], 0).next;
|
|
94
|
+
const r = step(s, [child({ state: "needs_input", question: "Q2" })], 1_000);
|
|
95
|
+
expect(r.events.map((e) => e.edge)).toEqual(["needs_input"]);
|
|
96
|
+
expect(r.events[0]!.question).toBe("Q2");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("notifyRouter — exited", () => {
|
|
101
|
+
it("emits exactly once on stop", () => {
|
|
102
|
+
let s: RouterState = new Map();
|
|
103
|
+
let r = step(s, [child({ state: "stopped" })], 0);
|
|
104
|
+
expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
|
|
105
|
+
s = r.next;
|
|
106
|
+
r = step(s, [child({ state: "stopped" })], 1_000);
|
|
107
|
+
expect(r.events).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("synthesizes an exited edge when a tracked child vanishes (reaped)", () => {
|
|
111
|
+
let s: RouterState = new Map();
|
|
112
|
+
s = step(s, [child({ state: "active" })], 0).next; // now tracked
|
|
113
|
+
const r = step(s, [], 1_000); // child gone from the live set
|
|
114
|
+
expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
|
|
115
|
+
expect(r.events[0]!.child_pid).toBe(100);
|
|
116
|
+
// C2/I3: the synthetic exited must carry BOTH start times even though the
|
|
117
|
+
// child is gone from the current observation — else the pid-reuse guards
|
|
118
|
+
// (child on reconcile, parent on the read side) can't verify identity and
|
|
119
|
+
// a recycled pid's edge could be suppressed or mis-delivered.
|
|
120
|
+
expect(r.events[0]!.child_started_at).toBe(7000);
|
|
121
|
+
expect(r.events[0]!.parent_started_at).toBe(3000);
|
|
122
|
+
expect(r.next.has(100)).toBe(false); // forgotten
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("does not synthesize a second exited if we already emitted stopped", () => {
|
|
126
|
+
let s: RouterState = new Map();
|
|
127
|
+
s = step(s, [child({ state: "stopped" })], 0).next; // emitted exited
|
|
128
|
+
const r = step(s, [], 1_000); // then reaped
|
|
129
|
+
expect(r.events).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("notifyRouter — routing scope", () => {
|
|
134
|
+
it("ignores children with no parent_pid (top-level agents)", () => {
|
|
135
|
+
const r = step(new Map(), [child({ state: "needs_input", parent_pid: 0, question: "Q" })], 0);
|
|
136
|
+
expect(r.events).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("addresses each edge to the child's parent_pid", () => {
|
|
140
|
+
const r = step(new Map(), [child({ state: "stopped", parent_pid: 42 })], 0);
|
|
141
|
+
expect(r.events[0]!.parent_pid).toBe(42);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// P1 regression: the real render the parent hit — a child parked at an idle
|
|
146
|
+
// prompt with a RESOLVED prior menu lingering in scrollback must NOT be
|
|
147
|
+
// misclassified as needs_input (which would mask the idle edge).
|
|
148
|
+
describe("notifyRouter — idle-prompt fixture (P1 regression)", () => {
|
|
149
|
+
const idlePromptWithResolvedMenu = [
|
|
150
|
+
" Do you want to proceed?",
|
|
151
|
+
" 1. Yes",
|
|
152
|
+
" 2. No, keep planning",
|
|
153
|
+
" ⎿ Selected 1. Yes",
|
|
154
|
+
"",
|
|
155
|
+
"● Committed as abc1234 and pushed to origin/feat-x.",
|
|
156
|
+
"",
|
|
157
|
+
"╭──────────────────────────────────────────╮",
|
|
158
|
+
"│ > push it │",
|
|
159
|
+
"╰──────────────────────────────────────────╯",
|
|
160
|
+
" ? for shortcuts",
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
it("a resolved menu in scrollback is NOT needs_input (cursor not on a number)", () => {
|
|
164
|
+
// The numbered options linger but no `❯ N.` cursor remains — so the screen
|
|
165
|
+
// is quiet, classified idle, not needs_input.
|
|
166
|
+
expect(classifyNeedsInput(idlePromptWithResolvedMenu, CFG)).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("an ACTIVE menu (cursor on a number) still classifies as needs_input", () => {
|
|
170
|
+
const activeMenu = [
|
|
171
|
+
" Do you want to proceed?",
|
|
172
|
+
"❯ 1. Yes",
|
|
173
|
+
" 2. No, keep planning",
|
|
174
|
+
];
|
|
175
|
+
expect(classifyNeedsInput(activeMenu, CFG)).not.toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("a working spinner wins over a lingering menu (no false needs_input)", () => {
|
|
179
|
+
const workingWithOldMenu = ["❯ 2. No, keep planning", "● Running tests… (esc to interrupt)"];
|
|
180
|
+
expect(classifyNeedsInput(workingWithOldMenu, CFG)).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("end-to-end: such an idle child emits ONE idle edge after the confirm window", () => {
|
|
184
|
+
// Feeding the router the `idle` state (what deriveLiveState yields for the
|
|
185
|
+
// fixture above) produces exactly one idle edge per episode.
|
|
186
|
+
let s: RouterState = new Map();
|
|
187
|
+
s = step(s, [child({ state: "idle" })], 0).next;
|
|
188
|
+
const r = step(s, [child({ state: "idle" })], 30_000);
|
|
189
|
+
expect(r.events.map((e) => e.edge)).toEqual(["idle"]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("notifyRouter — carry-forward vs exited (C2: watcher-lapse ≠ child-death)", () => {
|
|
194
|
+
it("carries forward an unobserved SAME child (alive, matching start time)", () => {
|
|
195
|
+
// Track a child, then a tick with NO observation of it but the SAME child
|
|
196
|
+
// still alive (matching started_at → parent's watcher merely lapsed).
|
|
197
|
+
let s: RouterState = new Map();
|
|
198
|
+
s = stepRouter(s, [child({ pid: 100, started_at: 7000, state: "active" })], 0).next;
|
|
199
|
+
const r = stepRouter(s, [], 1_000, { aliveChildStartedAt: new Map([[100, 7000]]) });
|
|
200
|
+
expect(r.events).toEqual([]); // no false exited
|
|
201
|
+
expect(r.next.has(100)).toBe(true); // carried forward
|
|
202
|
+
expect(r.next.get(100)!.started_at).toBe(7000); // identity preserved
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("synthesizes exited when the child's pid is not alive at all", () => {
|
|
206
|
+
let s: RouterState = new Map();
|
|
207
|
+
s = stepRouter(s, [child({ pid: 100, state: "active" })], 0).next;
|
|
208
|
+
const r = stepRouter(s, [], 1_000, { aliveChildStartedAt: new Map() }); // pid dead
|
|
209
|
+
expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
|
|
210
|
+
expect(r.events[0]!.child_started_at).toBe(7000);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("synthesizes exited IMMEDIATELY when the pid is reused by a different child during a lapse", () => {
|
|
214
|
+
// The identity-aware fix: pid 100 is alive but its start time now differs
|
|
215
|
+
// (7000 → 9000) — the old child died and its pid was reused. We must NOT
|
|
216
|
+
// carry it forward as if still running; emit the old child's exited now, no
|
|
217
|
+
// waiting for the watcher to return.
|
|
218
|
+
let s: RouterState = new Map();
|
|
219
|
+
s = stepRouter(s, [child({ pid: 100, started_at: 7000, state: "active" })], 0).next;
|
|
220
|
+
const r = stepRouter(s, [], 1_000, { aliveChildStartedAt: new Map([[100, 9000]]) });
|
|
221
|
+
expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
|
|
222
|
+
expect(r.events[0]!.child_started_at).toBe(7000); // the OLD child's identity
|
|
223
|
+
expect(r.next.has(100)).toBe(false); // dropped
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("recovers the old child's exited on watcher RETURN (same-child lapse then reuse)", () => {
|
|
227
|
+
// Child tracked; watcher lapses with the SAME child still alive (carried);
|
|
228
|
+
// when the watcher returns, the pid is observed with a NEW start time → the
|
|
229
|
+
// old child's exited is recovered by the hot-path guard, new child fresh.
|
|
230
|
+
let s: RouterState = new Map();
|
|
231
|
+
s = stepRouter(s, [child({ pid: 100, started_at: 1000, state: "idle" })], 0).next;
|
|
232
|
+
s = stepRouter(s, [], 1_000, { aliveChildStartedAt: new Map([[100, 1000]]) }).next; // carried
|
|
233
|
+
const r = stepRouter(
|
|
234
|
+
s,
|
|
235
|
+
[child({ pid: 100, started_at: 2000, state: "needs_input", question: "New?" })],
|
|
236
|
+
2_000,
|
|
237
|
+
);
|
|
238
|
+
const edges = r.events.map((e) => e.edge);
|
|
239
|
+
expect(edges).toContain("exited"); // old child (1000) recovered
|
|
240
|
+
expect(r.events.find((e) => e.edge === "exited")!.child_started_at).toBe(1000);
|
|
241
|
+
expect(edges).toContain("needs_input"); // new child (2000) fresh
|
|
242
|
+
expect(r.next.get(100)!.started_at).toBe(2000);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("notifyRouter — hot-path pid reuse (Important)", () => {
|
|
247
|
+
it("treats a same-pid child with a NEW start time as a fresh child", () => {
|
|
248
|
+
// Old child exits at pid 100; then pid 100 is recycled by a NEW child that
|
|
249
|
+
// goes needs_input. The new child's needs_input must fire (not be suppressed
|
|
250
|
+
// by the old child's state), and the old child gets a synthetic exited.
|
|
251
|
+
let s: RouterState = new Map();
|
|
252
|
+
s = step(s, [child({ pid: 100, started_at: 1000, state: "idle" })], 0).next;
|
|
253
|
+
s = step(s, [child({ pid: 100, started_at: 1000, state: "idle" })], 30_000).next; // old idle-emitted
|
|
254
|
+
const r = step(
|
|
255
|
+
s,
|
|
256
|
+
[child({ pid: 100, started_at: 2000, state: "needs_input", question: "New?" })],
|
|
257
|
+
31_000,
|
|
258
|
+
);
|
|
259
|
+
const edges = r.events.map((e) => e.edge);
|
|
260
|
+
expect(edges).toContain("exited"); // old child (started_at 1000) closed out
|
|
261
|
+
expect(edges).toContain("needs_input"); // new child (started_at 2000) fires
|
|
262
|
+
expect(r.events.find((e) => e.edge === "exited")!.child_started_at).toBe(1000);
|
|
263
|
+
expect(r.events.find((e) => e.edge === "needs_input")!.child_started_at).toBe(2000);
|
|
264
|
+
// The next state reflects the NEW child only.
|
|
265
|
+
expect(r.next.get(100)!.started_at).toBe(2000);
|
|
266
|
+
expect(r.next.get(100)!.exitedEmitted).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("does NOT inherit emitted-memory from a tracked state with no start time (fail-safe)", () => {
|
|
270
|
+
// Old state seeded WITHOUT a start time (unverifiable). A same-pid child now
|
|
271
|
+
// observed with a start time must NOT inherit idleEmitted/exitedEmitted — else
|
|
272
|
+
// its first edge is suppressed. It rebuilds fresh (a duplicate is acceptable),
|
|
273
|
+
// and NO synthetic exited is emitted (it may be the same child).
|
|
274
|
+
const seeded: RouterState = new Map([
|
|
275
|
+
[
|
|
276
|
+
100,
|
|
277
|
+
{
|
|
278
|
+
parent_pid: 1,
|
|
279
|
+
started_at: undefined, // unverifiable
|
|
280
|
+
cli: "claude",
|
|
281
|
+
cwd: "/repo",
|
|
282
|
+
state: "idle",
|
|
283
|
+
idleSince: 0,
|
|
284
|
+
idleEmitted: true, // already emitted for the OLD episode
|
|
285
|
+
inNeedsInput: false,
|
|
286
|
+
needsInputQuestion: null,
|
|
287
|
+
exitedEmitted: false,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
]);
|
|
291
|
+
const r = stepRouter(seeded, [child({ pid: 100, started_at: 5000, state: "idle" })], 0, {
|
|
292
|
+
idleConfirmMs: 30_000,
|
|
293
|
+
});
|
|
294
|
+
expect(r.events.some((e) => e.edge === "exited")).toBe(false); // no false exited
|
|
295
|
+
expect(r.next.get(100)!.idleEmitted).toBe(false); // fresh — not inherited
|
|
296
|
+
expect(r.next.get(100)!.started_at).toBe(5000);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("does not double-close an old child that had already exited", () => {
|
|
300
|
+
let s: RouterState = new Map();
|
|
301
|
+
s = step(s, [child({ pid: 100, started_at: 1000, state: "stopped" })], 0).next; // exited emitted
|
|
302
|
+
const r = step(s, [child({ pid: 100, started_at: 2000, state: "idle" })], 1_000);
|
|
303
|
+
// Old child already exited → no second exited; new child just starts its idle timer.
|
|
304
|
+
expect(r.events).toEqual([]);
|
|
305
|
+
expect(r.next.get(100)!.started_at).toBe(2000);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("notifyRouter — startup reconcile (baseline)", () => {
|
|
310
|
+
it("emits needs_input immediately for a child already blocked at daemon start", () => {
|
|
311
|
+
const r = step(new Map(), [child({ state: "needs_input", question: "Q" })], 5_000);
|
|
312
|
+
expect(r.events.map((e) => e.edge)).toEqual(["needs_input"]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("emits exited immediately for a child already stopped at daemon start", () => {
|
|
316
|
+
const r = step(new Map(), [child({ state: "stopped" })], 5_000);
|
|
317
|
+
expect(r.events.map((e) => e.edge)).toEqual(["exited"]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("a child already idle at start emits only after the confirm window (from now)", () => {
|
|
321
|
+
let s: RouterState = new Map();
|
|
322
|
+
let r = step(s, [child({ state: "idle" })], 5_000);
|
|
323
|
+
expect(r.events).toEqual([]); // timer anchored at first observation
|
|
324
|
+
s = r.next;
|
|
325
|
+
r = step(s, [child({ state: "idle" })], 35_000);
|
|
326
|
+
expect(r.events.map((e) => e.edge)).toEqual(["idle"]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("respects a SEEDED prior state (no duplicate baseline across daemon restart)", () => {
|
|
330
|
+
// The daemon seeds prior emitted state from the inbox so a restart doesn't
|
|
331
|
+
// re-emit. A seeded exitedEmitted child that is still `stopped` stays quiet —
|
|
332
|
+
// the seed carries the SAME start time as the observation, so the identity is
|
|
333
|
+
// verifiable and the emitted-memory is (correctly) inherited.
|
|
334
|
+
const seeded: RouterState = new Map([
|
|
335
|
+
[
|
|
336
|
+
100,
|
|
337
|
+
{
|
|
338
|
+
parent_pid: 1,
|
|
339
|
+
wrapper_pid: 100,
|
|
340
|
+
started_at: 7000, // matches the child() factory → verifiable identity
|
|
341
|
+
cli: "claude",
|
|
342
|
+
cwd: "/repo",
|
|
343
|
+
state: "stopped",
|
|
344
|
+
idleSince: null,
|
|
345
|
+
idleEmitted: false,
|
|
346
|
+
inNeedsInput: false,
|
|
347
|
+
needsInputQuestion: null,
|
|
348
|
+
exitedEmitted: true,
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
]);
|
|
352
|
+
const r = step(seeded, [child({ state: "stopped" })], 0);
|
|
353
|
+
expect(r.events).toEqual([]);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure edge-detection + debounce state machine for subagent→parent
|
|
3
|
+
* notifications. The heart of `ay notifyd`: given each tick's observed child
|
|
4
|
+
* states, decide which EDGES to push to which parent, exactly once per episode,
|
|
5
|
+
* without spamming on transient flicker.
|
|
6
|
+
*
|
|
7
|
+
* Design (frozen with codex + the two agents who hit the pain):
|
|
8
|
+
* - Signal is the query-layer `deriveLiveState` state — NOT a bare no-output
|
|
9
|
+
* timer. `idle` already means "idle prompt visible AND no working spinner",
|
|
10
|
+
* so a long, silent tool call (a 2-minute test run) is classified `active`
|
|
11
|
+
* and never produces a false idle edge. This is the load-bearing P1 guard.
|
|
12
|
+
* - Three edges, child→parent only (never cascade a parent's own state):
|
|
13
|
+
* needs_input : immediate; re-fire only when the COMPACT question changes.
|
|
14
|
+
* exited : immediate, once.
|
|
15
|
+
* idle : only after the child has been continuously `idle` for
|
|
16
|
+
* `idleConfirmMs` (hysteresis), once per idle episode.
|
|
17
|
+
* - Edge, not level: a state that stays idle emits one edge, not one per tick.
|
|
18
|
+
* - Reaped-child safety: a child that vanishes from the live set without our
|
|
19
|
+
* seeing `stopped` gets a synthetic `exited` (mirrors `diffLsStates`).
|
|
20
|
+
*
|
|
21
|
+
* Pure + synchronous (no clock, no fs — the caller passes `now`) so it is
|
|
22
|
+
* trivially unit-testable, like `lsWatch.ts` / `needsInput.ts`. The poll loop,
|
|
23
|
+
* inbox writes, payload enrichment, and startup reconcile live in
|
|
24
|
+
* `subcommands.ts` (`cmdNotifyd`); persistence lives in `notifyInbox.ts`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { NotifyEdge } from "./notifyInbox.ts";
|
|
28
|
+
|
|
29
|
+
/** One child's observed state this tick (from `deriveLiveState` + the registry). */
|
|
30
|
+
export interface ChildObservation {
|
|
31
|
+
pid: number;
|
|
32
|
+
wrapper_pid?: number;
|
|
33
|
+
/** The child's own start time — carried into events for the pid-reuse guard. */
|
|
34
|
+
started_at?: number;
|
|
35
|
+
/** The parent this child links to (parent wrapper pid). Required to route. */
|
|
36
|
+
parent_pid: number;
|
|
37
|
+
/** The parent's start time — carried so a synthetic exited keeps the parent guard. */
|
|
38
|
+
parent_started_at?: number;
|
|
39
|
+
cli: string;
|
|
40
|
+
cwd: string;
|
|
41
|
+
/** `deriveLiveState` state: active | idle | stopped | needs_input | stuck. */
|
|
42
|
+
state: string;
|
|
43
|
+
/** Compact question when state === "needs_input", else null. */
|
|
44
|
+
question: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A decided notification, before the daemon enriches it (seq/ts/tail/git). */
|
|
48
|
+
export interface PendingNotification {
|
|
49
|
+
parent_pid: number;
|
|
50
|
+
child_pid: number;
|
|
51
|
+
child_wrapper_pid?: number;
|
|
52
|
+
/** The child's start time (from the observation / carried state). */
|
|
53
|
+
child_started_at?: number;
|
|
54
|
+
/** The parent's start time (from the observation / carried state). */
|
|
55
|
+
parent_started_at?: number;
|
|
56
|
+
cli: string;
|
|
57
|
+
cwd: string;
|
|
58
|
+
edge: NotifyEdge;
|
|
59
|
+
prev_state: string | null;
|
|
60
|
+
state: string;
|
|
61
|
+
question: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-child debounce memory, carried across ticks. */
|
|
65
|
+
export interface ChildRouterState {
|
|
66
|
+
parent_pid: number;
|
|
67
|
+
wrapper_pid?: number;
|
|
68
|
+
/** The child's start time — so a synthetic exited (child already gone) keeps it. */
|
|
69
|
+
started_at?: number;
|
|
70
|
+
/** The parent's start time — carried so a synthetic exited keeps the parent guard. */
|
|
71
|
+
parent_started_at?: number;
|
|
72
|
+
cli: string;
|
|
73
|
+
cwd: string;
|
|
74
|
+
/** Last observed state. */
|
|
75
|
+
state: string;
|
|
76
|
+
/** When the current idle episode began (ms), or null if not idle. */
|
|
77
|
+
idleSince: number | null;
|
|
78
|
+
/** Whether we already emitted the idle edge for the current episode. */
|
|
79
|
+
idleEmitted: boolean;
|
|
80
|
+
/** Whether we are inside a needs_input episode we've emitted at least once. */
|
|
81
|
+
inNeedsInput: boolean;
|
|
82
|
+
/** The compact question of the last emitted needs_input (for change re-fire). */
|
|
83
|
+
needsInputQuestion: string | null;
|
|
84
|
+
/** Whether we already emitted the exited edge for this child. */
|
|
85
|
+
exitedEmitted: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type RouterState = Map<number, ChildRouterState>;
|
|
89
|
+
|
|
90
|
+
export interface RouterConfig {
|
|
91
|
+
/** How long a child must stay continuously idle before we emit an idle edge. */
|
|
92
|
+
idleConfirmMs: number;
|
|
93
|
+
/**
|
|
94
|
+
* pid → started_at for every child ACTUALLY ALIVE right now (from the registry).
|
|
95
|
+
* A tracked child that drops out of this tick's observations is carried forward
|
|
96
|
+
* (not false-exited) ONLY when the live pid's start time still MATCHES the
|
|
97
|
+
* tracked one — i.e. it is the SAME child, merely unobserved (its parent's
|
|
98
|
+
* watcher lapsed). If the pid is alive but with a DIFFERENT start time, the old
|
|
99
|
+
* child died and its pid was reused during the lapse → synthesize its exited
|
|
100
|
+
* immediately (identity-aware, no waiting for the watcher to return). If the
|
|
101
|
+
* tracked start time is unknown (rare), fall back to liveness-only carry-forward
|
|
102
|
+
* to avoid a false exited. When omitted entirely, any unobserved tracked child
|
|
103
|
+
* is treated as exited (the caller has no liveness info).
|
|
104
|
+
*/
|
|
105
|
+
aliveChildStartedAt?: Map<number, number>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const DEFAULT_IDLE_CONFIRM_MS = 30_000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Advance the router by one tick. Returns the notifications to push plus the
|
|
112
|
+
* next RouterState. Only children with a numeric `parent_pid` are considered —
|
|
113
|
+
* the caller is responsible for including only opted-in children.
|
|
114
|
+
*
|
|
115
|
+
* First-observation (baseline / startup-reconcile) semantics: a child we've
|
|
116
|
+
* never seen that is ALREADY terminal emits immediately for `needs_input` /
|
|
117
|
+
* `stopped`, and starts its idle timer at `now` for `idle` (so a child parked
|
|
118
|
+
* idle when the daemon starts still notifies after the confirm window). To avoid
|
|
119
|
+
* re-emitting the same baseline on every daemon restart, the caller SEEDS the
|
|
120
|
+
* prior state from each inbox's already-written edges (see cmdNotifyd).
|
|
121
|
+
*/
|
|
122
|
+
export function stepRouter(
|
|
123
|
+
prev: RouterState,
|
|
124
|
+
observations: ChildObservation[],
|
|
125
|
+
now: number,
|
|
126
|
+
config: Partial<RouterConfig> = {},
|
|
127
|
+
): { events: PendingNotification[]; next: RouterState } {
|
|
128
|
+
const idleConfirmMs = config.idleConfirmMs ?? DEFAULT_IDLE_CONFIRM_MS;
|
|
129
|
+
const events: PendingNotification[] = [];
|
|
130
|
+
const next: RouterState = new Map();
|
|
131
|
+
const seen = new Set<number>();
|
|
132
|
+
|
|
133
|
+
for (const obs of observations) {
|
|
134
|
+
if (typeof obs.parent_pid !== "number" || obs.parent_pid <= 0) continue;
|
|
135
|
+
seen.add(obs.pid);
|
|
136
|
+
let p = prev.get(obs.pid);
|
|
137
|
+
// Hot-path pid-reuse guard (mirrors the startup reconcile guard). Two cases,
|
|
138
|
+
// both fail-safe — an unverifiable identity is NEVER inherited:
|
|
139
|
+
// 1. CONFIRMED reuse: both start times known and different → the old child
|
|
140
|
+
// is gone. `seen` marks the pid so the vanished-loop won't fire; emit its
|
|
141
|
+
// synthetic exited here, then drop `p` so the new child rebuilds fresh.
|
|
142
|
+
// 2. UNVERIFIABLE: the observation has a start time but the tracked state has
|
|
143
|
+
// none (e.g. seeded without one). We can't confirm it's the same child,
|
|
144
|
+
// so we DON'T inherit its emitted-edge memory (a re-emit / duplicate is
|
|
145
|
+
// safe; a suppressed first edge is not) — but we also DON'T synthesize an
|
|
146
|
+
// exited, since it may well be the same child (avoid a false exited).
|
|
147
|
+
if (p && obs.started_at != null && p.started_at != null && p.started_at !== obs.started_at) {
|
|
148
|
+
if (!p.exitedEmitted) events.push(syntheticExited(p, obs.pid));
|
|
149
|
+
p = undefined;
|
|
150
|
+
} else if (p && obs.started_at != null && p.started_at == null) {
|
|
151
|
+
p = undefined; // unverifiable — rebuild fresh, no inherit, no synthetic exited
|
|
152
|
+
}
|
|
153
|
+
const cs: ChildRouterState = {
|
|
154
|
+
parent_pid: obs.parent_pid,
|
|
155
|
+
wrapper_pid: obs.wrapper_pid,
|
|
156
|
+
started_at: obs.started_at,
|
|
157
|
+
parent_started_at: obs.parent_started_at,
|
|
158
|
+
cli: obs.cli,
|
|
159
|
+
cwd: obs.cwd,
|
|
160
|
+
state: obs.state,
|
|
161
|
+
idleSince: p?.idleSince ?? null,
|
|
162
|
+
idleEmitted: p?.idleEmitted ?? false,
|
|
163
|
+
inNeedsInput: p?.inNeedsInput ?? false,
|
|
164
|
+
needsInputQuestion: p?.needsInputQuestion ?? null,
|
|
165
|
+
exitedEmitted: p?.exitedEmitted ?? false,
|
|
166
|
+
};
|
|
167
|
+
const prevState = p?.state ?? null;
|
|
168
|
+
const emit = (edge: NotifyEdge) =>
|
|
169
|
+
events.push({
|
|
170
|
+
parent_pid: obs.parent_pid,
|
|
171
|
+
child_pid: obs.pid,
|
|
172
|
+
child_wrapper_pid: obs.wrapper_pid,
|
|
173
|
+
child_started_at: obs.started_at,
|
|
174
|
+
parent_started_at: obs.parent_started_at,
|
|
175
|
+
cli: obs.cli,
|
|
176
|
+
cwd: obs.cwd,
|
|
177
|
+
edge,
|
|
178
|
+
prev_state: prevState,
|
|
179
|
+
state: obs.state,
|
|
180
|
+
question: edge === "needs_input" ? obs.question : null,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
switch (obs.state) {
|
|
184
|
+
case "needs_input": {
|
|
185
|
+
// Leaving idle/other → reset those episodes.
|
|
186
|
+
cs.idleSince = null;
|
|
187
|
+
cs.idleEmitted = false;
|
|
188
|
+
// Fire on entry, or when the compact question changed (a NEW question).
|
|
189
|
+
// Compare the compact (chrome-stripped) question so a spinner/elapsed-
|
|
190
|
+
// seconds cosmetic redraw doesn't double-fire.
|
|
191
|
+
if (!cs.inNeedsInput || cs.needsInputQuestion !== obs.question) {
|
|
192
|
+
emit("needs_input");
|
|
193
|
+
cs.inNeedsInput = true;
|
|
194
|
+
cs.needsInputQuestion = obs.question;
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case "idle": {
|
|
199
|
+
cs.inNeedsInput = false;
|
|
200
|
+
cs.needsInputQuestion = null;
|
|
201
|
+
// Continue the episode if we were already idle; else start it now.
|
|
202
|
+
if (p?.state === "idle" && p.idleSince != null) {
|
|
203
|
+
cs.idleSince = p.idleSince;
|
|
204
|
+
cs.idleEmitted = p.idleEmitted;
|
|
205
|
+
} else {
|
|
206
|
+
cs.idleSince = now;
|
|
207
|
+
cs.idleEmitted = false;
|
|
208
|
+
}
|
|
209
|
+
if (!cs.idleEmitted && now - (cs.idleSince ?? now) >= idleConfirmMs) {
|
|
210
|
+
emit("idle");
|
|
211
|
+
cs.idleEmitted = true;
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "stopped": {
|
|
216
|
+
cs.idleSince = null;
|
|
217
|
+
cs.idleEmitted = false;
|
|
218
|
+
cs.inNeedsInput = false;
|
|
219
|
+
cs.needsInputQuestion = null;
|
|
220
|
+
if (!cs.exitedEmitted) {
|
|
221
|
+
emit("exited");
|
|
222
|
+
cs.exitedEmitted = true;
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
// "active", "stuck", or any unknown busy state: real work (or a wedge) is
|
|
227
|
+
// happening — reset the idle/needs_input episodes so a later idle counts
|
|
228
|
+
// as a fresh episode, and emit nothing.
|
|
229
|
+
default: {
|
|
230
|
+
cs.idleSince = null;
|
|
231
|
+
cs.idleEmitted = false;
|
|
232
|
+
cs.inNeedsInput = false;
|
|
233
|
+
cs.needsInputQuestion = null;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
next.set(obs.pid, cs);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Reaped-child safety: a child we were tracking dropped out of the live set
|
|
242
|
+
// without our observing `stopped`. Synthesize one exited edge (once) so a
|
|
243
|
+
// "done" transition is never dropped, then forget it.
|
|
244
|
+
for (const [pid, cs] of prev) {
|
|
245
|
+
if (seen.has(pid)) continue;
|
|
246
|
+
// Distinguish "child died" from "child merely not observed this tick" (its
|
|
247
|
+
// parent's watcher lapsed, so it fell out of the observed scope) — and do it
|
|
248
|
+
// IDENTITY-AWARE, so a pid reused during the lapse doesn't masquerade as the
|
|
249
|
+
// same still-running child. Carry forward only when the SAME child is still
|
|
250
|
+
// alive (live start time matches, or the tracked start time is unknown —
|
|
251
|
+
// fail-safe against a false exited). Otherwise the child is gone (dead, or its
|
|
252
|
+
// pid now belongs to a different child) → synthesize its exited.
|
|
253
|
+
if (config.aliveChildStartedAt) {
|
|
254
|
+
const liveStart = config.aliveChildStartedAt.get(pid);
|
|
255
|
+
const sameChildAlive =
|
|
256
|
+
liveStart !== undefined && (cs.started_at == null || liveStart === cs.started_at);
|
|
257
|
+
if (sameChildAlive) {
|
|
258
|
+
next.set(pid, cs);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!cs.exitedEmitted) events.push(syntheticExited(cs, pid));
|
|
263
|
+
// Dead & gone (or pid reused) — drop from the tracked set.
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { events, next };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A synthetic `exited` for a child that left the live set without our observing
|
|
271
|
+
* its stop — either reaped between ticks, or displaced by a pid-reuse. Built from
|
|
272
|
+
* the last-known router state so it still carries the child's identity.
|
|
273
|
+
*/
|
|
274
|
+
function syntheticExited(cs: ChildRouterState, pid: number): PendingNotification {
|
|
275
|
+
return {
|
|
276
|
+
parent_pid: cs.parent_pid,
|
|
277
|
+
child_pid: pid,
|
|
278
|
+
child_wrapper_pid: cs.wrapper_pid,
|
|
279
|
+
child_started_at: cs.started_at,
|
|
280
|
+
parent_started_at: cs.parent_started_at,
|
|
281
|
+
cli: cs.cli,
|
|
282
|
+
cwd: cs.cwd,
|
|
283
|
+
edge: "exited",
|
|
284
|
+
prev_state: cs.state,
|
|
285
|
+
state: "stopped",
|
|
286
|
+
question: null,
|
|
287
|
+
};
|
|
288
|
+
}
|