agent-yes 1.265.4 → 1.266.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{SUPPORTED_CLIS-C3tcRetU.js → SUPPORTED_CLIS-AFV5pjSx.js} +2 -2
- package/dist/{SUPPORTED_CLIS-CY3SaV0D.js → SUPPORTED_CLIS-DRVuSo48.js} +3 -3
- package/dist/{agentShare-g6N1A6A_.js → agentShare-qdFSp9Uq.js} +6 -5
- package/dist/{callback-L4k-v2GG.js → callback-Da7MRLh6.js} +6 -5
- package/dist/{callback-z-w0ND5z.js → callback-DfNFdZin.js} +5 -5
- package/dist/{callbackCore-Cv4EOGjE.js → callbackCore-DcptaZwH.js} +1 -1
- package/dist/{callbackCore-Cg3Ye0ZS.js → callbackCore-K99x3C75.js} +1 -1
- package/dist/{channels-BwpN_8mu.js → channels-DQaZbare.js} +2 -2
- package/dist/cli.js +11 -11
- package/dist/{cwdConflictWarn-DZTxTGXq.js → cwdConflictWarn-CtQhl-vF.js} +1 -1
- package/dist/{cwdPassthroughHint-DDVkhBiD.js → cwdPassthroughHint-apaX_O7p.js} +1 -1
- package/dist/{expose-BG17GV7D.js → expose-BXn6My6A.js} +1 -1
- package/dist/{forkNested-VroZS-nk.js → forkNested-CDfWPiHw.js} +12 -2
- package/dist/{hist-CoXBBjJt.js → hist-BpocAe1N.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/initMsg-BjRseolL.js +3 -0
- package/dist/initMsg-C-w4A_eF.js +85 -0
- package/dist/needsInput-103zhZ90.js +79 -0
- package/dist/{nodeRuntime-CT0w6zsB.js → nodeRuntime-CRoEMWkp.js} +1 -1
- package/dist/{notifyDaemon-BL1tmxUK.js → notifyDaemon-BFzqcdN1.js} +32 -5
- package/dist/notifyStore-CEDiVyis.js +4 -0
- package/dist/notifyStore-Cbw2ZJ0X.js +559 -0
- package/dist/{openBrowser-Cw8hg7ok.js → openBrowser-DhQ9pJRi.js} +1 -1
- package/dist/parentLink-B798ASCW.js +5 -0
- package/dist/parentLink-C0cIRjfL.js +53 -0
- package/dist/parentPingLoop-BIKxzbPH.js +469 -0
- package/dist/{remotes-DWVw-Q7V.js → remotes-Dg_JscKk.js} +2 -2
- package/dist/{remotes-iIvqiFWK.js → remotes-iMhdZytp.js} +3 -3
- package/dist/{rustBinary-DSVRhDzO.js → rustBinary-JGNROWfY.js} +2 -2
- package/dist/{schedule-i4o5SJ_h.js → schedule-DbLpjgl5.js} +6 -6
- package/dist/{serve-CpGJD2Kt.js → serve-ucVXOci1.js} +31 -30
- package/dist/{setup-B-iQKsQW.js → setup-De7uYRZb.js} +4 -4
- package/dist/{share-DOnynkf4.js → share-Db_zb6gH.js} +1 -1
- package/dist/{share-B-e_9WBx.js → share-NUhi-Wdd.js} +1 -1
- package/dist/{spawnGate-S6EoFdzX.js → spawnGate-BvaZeHE7.js} +1 -1
- package/dist/{spawnGate-BmsTv340.js → spawnGate-CEXVmTtv.js} +2 -2
- package/dist/subcommands-C04exkIM.js +11 -0
- package/dist/{subcommands-CQPnT5qu.js → subcommands-DtF31RoW.js} +41 -656
- package/dist/{systemPathLink-CjLmiPbB.js → systemPathLink-7TFPwJna.js} +1 -1
- package/dist/{terminal-CybRgMZu.js → terminal-Dm32SXU-.js} +2 -2
- package/dist/{todoCli-DZmOmcvd.js → todoCli-c_R7D7Y-.js} +1 -1
- package/dist/{tray-Cjm3a8JH.js → tray-DZnGjJBK.js} +1 -1
- package/dist/trayApp-CZbwLK9t.js +5 -0
- package/dist/{trayApp-UglHbk_m.js → trayApp-CfkPG0wy.js} +2 -2
- package/dist/{ts-nuUnrVcf.js → ts-CGtoPER5.js} +43 -10
- package/dist/{versionChecker-CMOLbEEi.js → versionChecker-CJgW9DHY.js} +2 -2
- package/dist/{webrtcLink-CS4D-3G1.js → webrtcLink-DyCj380i.js} +1 -1
- package/dist/{webrtcRemote-zQybN9IR.js → webrtcRemote-BDg0jjTs.js} +2 -2
- package/dist/{widget-y5Bpvwsa.js → widget-DXtaX3QR.js} +3 -3
- package/dist/{workspaceConfig-BDqp7fYl.js → workspaceConfig-HiWQzq6A.js} +1 -1
- package/dist/{ws-CH8GzcQn.js → ws-CqlS5JhN.js} +5 -4
- package/dist/{ws-BrLV-AlH.js → ws-aKRZpI9b.js} +9 -11
- package/package.json +1 -1
- package/ts/forkNested.spec.ts +22 -0
- package/ts/forkNested.ts +19 -0
- package/ts/index.ts +77 -12
- package/ts/initMsg.spec.ts +114 -0
- package/ts/initMsg.ts +107 -0
- package/ts/ownerHeartbeat.ts +40 -1
- package/ts/parentLink.spec.ts +64 -0
- package/ts/parentLink.ts +58 -0
- package/ts/parentPing.spec.ts +176 -0
- package/ts/parentPing.ts +195 -0
- package/ts/parentPingLoop.delivery.spec.ts +236 -0
- package/ts/parentPingLoop.spec.ts +79 -0
- package/ts/parentPingLoop.ts +196 -0
- package/ts/parentPingSend.spec.ts +195 -0
- package/ts/parentPingSend.ts +148 -0
- package/ts/parentWatching.spec.ts +61 -0
- package/ts/parentWatching.ts +89 -0
- package/ts/subcommands.ts +11 -2
- package/ts/trayApp.spec.ts +31 -4
- package/ts/ws.ts +17 -12
- package/dist/subcommands-Cgt2-kjg.js +0 -10
- package/dist/trayApp-CmwwBg8v.js +0 -5
package/ts/initMsg.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ay-init-msg …>` — the attribution wrapper around a SUB-AGENT's INITIAL prompt.
|
|
3
|
+
*
|
|
4
|
+
* `ay send` already wraps every agent→agent message in `<ay-msg nonce from …>`
|
|
5
|
+
* so the recipient knows who pinged it and exactly how to reply (see cmdSend in
|
|
6
|
+
* ts/subcommands.ts). The initial prompt had no such wrapper: an agent spawned by
|
|
7
|
+
* `ay claude -- "<task>"` from inside another agent's Bash tool received a bare
|
|
8
|
+
* task string with no idea that (a) it was spawned by an agent rather than a
|
|
9
|
+
* human, (b) that agent is blocked/waiting on it, or (c) how to talk back. It
|
|
10
|
+
* would finish, sit at an idle prompt, and nobody would ever learn.
|
|
11
|
+
*
|
|
12
|
+
* So the first message gets the same treatment as every later one — same
|
|
13
|
+
* nonce-delimited XML-ish framing, same `reply:` route — plus the one thing a
|
|
14
|
+
* later message doesn't need: an explicit REPORTING DUTY (ping the parent when
|
|
15
|
+
* you finish, and when you're stuck). The runtime enforces that duty
|
|
16
|
+
* independently in ts/parentPing.ts; this block is what makes the agent do it
|
|
17
|
+
* deliberately, with content, instead of the wrapper's terse automatic ping.
|
|
18
|
+
*
|
|
19
|
+
* Pure + fs-free (the caller supplies the nonce) so it is trivially unit-testable,
|
|
20
|
+
* mirroring `notifyRouter.ts` / `resultEnvelope.ts`. Mirrored in the Rust runtime
|
|
21
|
+
* by `rs/src/init_msg.rs` — keep the two formats byte-identical.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { homedir } from "os";
|
|
25
|
+
|
|
26
|
+
/** The agent that spawned this one, as far as the wrapper could resolve it. */
|
|
27
|
+
export interface InitSpawner {
|
|
28
|
+
cli: string;
|
|
29
|
+
/** The spawner's agent pid (display only — pids die on restart). */
|
|
30
|
+
pid: number;
|
|
31
|
+
/**
|
|
32
|
+
* The spawner's stable agent_id, the ACTUAL reply route: it survives the
|
|
33
|
+
* parent restarting (a pid does not). Falls back to the pid when a legacy
|
|
34
|
+
* record carries no id.
|
|
35
|
+
*/
|
|
36
|
+
agentId?: string | null;
|
|
37
|
+
cwd: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Replace a leading $HOME with `~`. Local copy of subcommands' shortenPath so
|
|
41
|
+
* this module stays import-light (it runs on the wrapper's cold-start path). */
|
|
42
|
+
export function shortenHome(p: string, home = homedir()): string {
|
|
43
|
+
if (!home || !p.startsWith(home)) return p;
|
|
44
|
+
const rest = p.slice(home.length);
|
|
45
|
+
if (rest !== "" && rest !== "/" && rest !== "\\") return "~" + rest;
|
|
46
|
+
return "~";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The stable id to address a reply to — agent_id when we have one, else pid. */
|
|
50
|
+
export function replyTargetOf(spawner: InitSpawner): string {
|
|
51
|
+
const id = spawner.agentId?.trim();
|
|
52
|
+
return id ? id : String(spawner.pid);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Wrap a sub-agent's initial `prompt` in `<ay-init-msg …>`.
|
|
57
|
+
*
|
|
58
|
+
* `nonce` is generated by the CALLER, after the prompt text already exists, so
|
|
59
|
+
* text inside the body can't forge a matching open/close marker — the same
|
|
60
|
+
* forgery guard `<ay-msg>` relies on (nonce match, not tag syntax, is what makes
|
|
61
|
+
* the boundary trustworthy; strict-XML validity is deliberately sacrificed).
|
|
62
|
+
*
|
|
63
|
+
* Returns the prompt unchanged when there is no spawner (a top-level agent
|
|
64
|
+
* started from a human shell has nobody to report to, and the block would be
|
|
65
|
+
* pure noise on the overwhelmingly common path).
|
|
66
|
+
*/
|
|
67
|
+
export function buildInitMsg(
|
|
68
|
+
prompt: string,
|
|
69
|
+
spawner: InitSpawner | null | undefined,
|
|
70
|
+
nonce: string,
|
|
71
|
+
): string {
|
|
72
|
+
if (!spawner) return prompt;
|
|
73
|
+
const target = replyTargetOf(spawner);
|
|
74
|
+
const where = shortenHome(spawner.cwd);
|
|
75
|
+
return [
|
|
76
|
+
`<ay-init-msg ${nonce} from ${spawner.cli} #${spawner.pid} @ ${where} — reply: ay send ${target} "...">`,
|
|
77
|
+
`<ay-task ${nonce}>`,
|
|
78
|
+
prompt,
|
|
79
|
+
`</ay-task ${nonce}>`,
|
|
80
|
+
``,
|
|
81
|
+
`The task above was given to you by that agent — it spawned you and returned`,
|
|
82
|
+
`immediately, so it is NOT watching your terminal and will not see anything you`,
|
|
83
|
+
`print. Reach it by RUNNING THESE SHELL COMMANDS (in your bash/terminal tool):`,
|
|
84
|
+
` ay send ${target} "..." report progress, ask a question, deliver the result`,
|
|
85
|
+
` ay tail ${target} read what it has been doing`,
|
|
86
|
+
``,
|
|
87
|
+
// Observed in the wild: a subagent under Claude Code read this block, reached
|
|
88
|
+
// for its harness's OWN agent-messaging tool ("SendMessage"/"ListAgents"),
|
|
89
|
+
// got "No agent named '<id>' is reachable" — that tool only knows the
|
|
90
|
+
// harness's own subagents, not agent-yes's registry — and gave up, reporting
|
|
91
|
+
// nothing. Naming the collision explicitly is what stops it: the agent must
|
|
92
|
+
// know these are shell commands and that its built-in tool cannot see us.
|
|
93
|
+
`\`ay\` is a SHELL COMMAND — run it in your bash/terminal tool. It is NOT your`,
|
|
94
|
+
`harness's built-in agent/message tool: that tool only knows agents your harness`,
|
|
95
|
+
`spawned and will say "no agent named ${target}". Do not substitute it, and do`,
|
|
96
|
+
`not conclude the parent is unreachable if it fails — use the shell.`,
|
|
97
|
+
``,
|
|
98
|
+
`Reporting duty — you MUST run \`ay send ${target} "..."\` when either happens:`,
|
|
99
|
+
` 1. You finish the task. Send the outcome itself (what changed, what you found,`,
|
|
100
|
+
` files/PRs touched), not just "done" — it cannot read your transcript.`,
|
|
101
|
+
` 2. You are blocked or stuck: a decision only it can make, a missing`,
|
|
102
|
+
` credential/permission, a failing step you cannot get past. Say what you`,
|
|
103
|
+
` tried and what you need. Do NOT sit at an idle prompt waiting.`,
|
|
104
|
+
`Until you do one of those, it is waiting on you.`,
|
|
105
|
+
`</ay-init-msg ${nonce}>`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
package/ts/ownerHeartbeat.ts
CHANGED
|
@@ -138,6 +138,12 @@ export function startInlineHeartbeat(opts: OwnerHeartbeatOptions): OwnerHeartbea
|
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** Upper bound on how long stop() waits for the worker thread to go away. Far
|
|
142
|
+
* longer than a terminate needs; short enough that shutdown never looks wedged.
|
|
143
|
+
* Overshooting only leaks an already-unref'd thread that cannot hold the
|
|
144
|
+
* process open. */
|
|
145
|
+
const STOP_TIMEOUT_MS = 2_000;
|
|
146
|
+
|
|
141
147
|
export function startOwnerHeartbeat(opts: OwnerHeartbeatOptions): OwnerHeartbeat {
|
|
142
148
|
try {
|
|
143
149
|
const worker = new Worker(WORKER_SRC, {
|
|
@@ -154,10 +160,43 @@ export function startOwnerHeartbeat(opts: OwnerHeartbeatOptions): OwnerHeartbeat
|
|
|
154
160
|
// relying on the main loop's own token check.
|
|
155
161
|
worker.on("error", () => {});
|
|
156
162
|
if (typeof worker.unref === "function") worker.unref();
|
|
163
|
+
|
|
164
|
+
// The worker EXITS ON ITS OWN whenever fencing fails: losing the token
|
|
165
|
+
// clears its interval, which drains its event loop. That is the normal,
|
|
166
|
+
// expected end for a superseded beat — so by the time anyone calls stop(),
|
|
167
|
+
// the thread is very often already gone, and `terminate()` on an
|
|
168
|
+
// already-exited worker does not reliably settle (it hangs under bun).
|
|
169
|
+
// Track the exit and race it, with a bounded fallback, so stop() ALWAYS
|
|
170
|
+
// resolves: it is awaited on the daemon's shutdown path, where hanging would
|
|
171
|
+
// wedge the process instead of merely leaking a thread.
|
|
172
|
+
let exited = false;
|
|
173
|
+
const exitedPromise = new Promise<void>((resolve) => {
|
|
174
|
+
worker.once("exit", () => {
|
|
175
|
+
exited = true;
|
|
176
|
+
resolve();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
157
179
|
return {
|
|
158
180
|
isolated: true,
|
|
159
181
|
stop: async () => {
|
|
160
|
-
|
|
182
|
+
if (exited) return;
|
|
183
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
184
|
+
const timeout = new Promise<void>((resolve) => {
|
|
185
|
+
timer = setTimeout(resolve, STOP_TIMEOUT_MS);
|
|
186
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
await Promise.race([
|
|
190
|
+
worker.terminate().then(
|
|
191
|
+
() => {},
|
|
192
|
+
() => {},
|
|
193
|
+
),
|
|
194
|
+
exitedPromise,
|
|
195
|
+
timeout,
|
|
196
|
+
]);
|
|
197
|
+
} finally {
|
|
198
|
+
if (timer) clearTimeout(timer);
|
|
199
|
+
}
|
|
161
200
|
},
|
|
162
201
|
};
|
|
163
202
|
} catch {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveSpawner, spawnerFromRecords } from "./parentLink.ts";
|
|
3
|
+
import type { GlobalPidRecord } from "./globalPidIndex.ts";
|
|
4
|
+
|
|
5
|
+
const rec = (over: Partial<GlobalPidRecord> = {}): GlobalPidRecord => ({
|
|
6
|
+
pid: 100,
|
|
7
|
+
cli: "claude",
|
|
8
|
+
prompt: null,
|
|
9
|
+
cwd: "/repo",
|
|
10
|
+
log_file: null,
|
|
11
|
+
status: "active",
|
|
12
|
+
exit_code: null,
|
|
13
|
+
exit_reason: null,
|
|
14
|
+
started_at: 1,
|
|
15
|
+
wrapper_pid: 99,
|
|
16
|
+
agent_id: "agt_a",
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("spawnerFromRecords", () => {
|
|
21
|
+
it("matches on wrapper_pid — that is the value a child inherits as AGENT_YES_PID", () => {
|
|
22
|
+
expect(spawnerFromRecords([rec()], 99)).toEqual({
|
|
23
|
+
cli: "claude",
|
|
24
|
+
pid: 100,
|
|
25
|
+
agentId: "agt_a",
|
|
26
|
+
cwd: "/repo",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns null when nothing matches (parent aged out, or lives on another host)", () => {
|
|
31
|
+
expect(spawnerFromRecords([rec()], 12345)).toBeNull();
|
|
32
|
+
expect(spawnerFromRecords([], 99)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("prefers a LIVE record over a recycled wrapper pid's exited one", () => {
|
|
36
|
+
const dead = rec({ pid: 100, status: "exited", agent_id: "agt_old" });
|
|
37
|
+
const live = rec({ pid: 200, status: "active", agent_id: "agt_new" });
|
|
38
|
+
expect(spawnerFromRecords([dead, live], 99)?.agentId).toBe("agt_new");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("falls back to an exited record rather than losing the link entirely", () => {
|
|
42
|
+
const dead = rec({ status: "exited", agent_id: "agt_old" });
|
|
43
|
+
expect(spawnerFromRecords([dead], 99)?.agentId).toBe("agt_old");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("normalises a missing agent_id to null so the caller falls back to the pid", () => {
|
|
47
|
+
expect(spawnerFromRecords([rec({ agent_id: undefined })], 99)?.agentId).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("resolveSpawner", () => {
|
|
52
|
+
it("short-circuits on a non-parent without touching the registry", async () => {
|
|
53
|
+
await expect(resolveSpawner(undefined)).resolves.toBeNull();
|
|
54
|
+
await expect(resolveSpawner(null)).resolves.toBeNull();
|
|
55
|
+
await expect(resolveSpawner(0)).resolves.toBeNull();
|
|
56
|
+
await expect(resolveSpawner(-1)).resolves.toBeNull();
|
|
57
|
+
await expect(resolveSpawner(1.5)).resolves.toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null (never throws) when the registry has no such parent", async () => {
|
|
61
|
+
// A pid that cannot be a live wrapper in this test process's registry.
|
|
62
|
+
await expect(resolveSpawner(2 ** 30)).resolves.toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
package/ts/parentLink.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve "who spawned me" from the wrapper-pid link the registry already
|
|
3
|
+
* carries, into an addressable identity.
|
|
4
|
+
*
|
|
5
|
+
* The link itself is just a number: a nested `ay` inherits its parent wrapper's
|
|
6
|
+
* `AGENT_YES_PID` and records it as `parent_pid` (see globalPidIndex's
|
|
7
|
+
* GlobalPidRecord). To actually TALK to that parent — which is what
|
|
8
|
+
* `<ay-init-msg>` and the finished/stuck ping both need — we have to map that
|
|
9
|
+
* wrapper pid back to the parent's canonical record, because the reply route is
|
|
10
|
+
* its `agent_id` (stable across restart), not its pid.
|
|
11
|
+
*
|
|
12
|
+
* Shared by ts/initMsg.ts's caller and ts/parentPing's delivery so both address
|
|
13
|
+
* the SAME identity — a child that reports to a different route than the one
|
|
14
|
+
* printed in its own init block would be maddening to debug.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readGlobalPids, type GlobalPidRecord } from "./globalPidIndex.ts";
|
|
18
|
+
import type { InitSpawner } from "./initMsg.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The parent agent for `parentPid` (a parent WRAPPER pid), or null.
|
|
22
|
+
*
|
|
23
|
+
* Null covers three different situations that all mean the same thing here —
|
|
24
|
+
* "nobody to report to": no parent at all (top-level, human-launched); a parent
|
|
25
|
+
* whose record aged out of the registry; or a parent on another host. Callers
|
|
26
|
+
* treat all three as "skip the wrapper / skip the ping" rather than guessing a
|
|
27
|
+
* route that would silently go nowhere.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveSpawner(
|
|
30
|
+
parentPid: number | null | undefined,
|
|
31
|
+
): Promise<InitSpawner | null> {
|
|
32
|
+
if (typeof parentPid !== "number" || !Number.isInteger(parentPid) || parentPid <= 0) return null;
|
|
33
|
+
let records: GlobalPidRecord[];
|
|
34
|
+
try {
|
|
35
|
+
records = await readGlobalPids();
|
|
36
|
+
} catch {
|
|
37
|
+
return null; // registry unreadable — never block a spawn over attribution
|
|
38
|
+
}
|
|
39
|
+
return spawnerFromRecords(records, parentPid);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pure half of resolveSpawner, so the match rule is testable without the fs. */
|
|
43
|
+
export function spawnerFromRecords(
|
|
44
|
+
records: GlobalPidRecord[],
|
|
45
|
+
parentPid: number,
|
|
46
|
+
): InitSpawner | null {
|
|
47
|
+
// Prefer a live record: a recycled wrapper pid can match an old exited entry
|
|
48
|
+
// too, and reporting into a dead agent's fifo is worse than not reporting.
|
|
49
|
+
const matches = records.filter((r) => r.wrapper_pid === parentPid);
|
|
50
|
+
const parent = matches.find((r) => r.status !== "exited") ?? matches[0];
|
|
51
|
+
if (!parent) return null;
|
|
52
|
+
return {
|
|
53
|
+
cli: parent.cli,
|
|
54
|
+
pid: parent.pid,
|
|
55
|
+
agentId: parent.agent_id ?? null,
|
|
56
|
+
cwd: parent.cwd,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PING_CONFIG,
|
|
4
|
+
initialPingState,
|
|
5
|
+
repeatDelayMs,
|
|
6
|
+
stepParentPing,
|
|
7
|
+
type PingConfig,
|
|
8
|
+
type PingObservation,
|
|
9
|
+
type PingState,
|
|
10
|
+
} from "./parentPing.ts";
|
|
11
|
+
|
|
12
|
+
const CFG: PingConfig = {
|
|
13
|
+
idleConfirmMs: 30_000,
|
|
14
|
+
repeatBaseMs: 100_000,
|
|
15
|
+
repeatMaxMs: 400_000,
|
|
16
|
+
maxRepeats: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Drive a sequence of observations, collecting every ping decided. */
|
|
20
|
+
function run(obs: PingObservation[], cfg = CFG) {
|
|
21
|
+
let state: PingState = initialPingState();
|
|
22
|
+
const pings = [];
|
|
23
|
+
for (const o of obs) {
|
|
24
|
+
const r = stepParentPing(state, o, cfg);
|
|
25
|
+
state = r.state;
|
|
26
|
+
if (r.ping) pings.push(r.ping);
|
|
27
|
+
}
|
|
28
|
+
return { state, pings };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("repeatDelayMs", () => {
|
|
32
|
+
it("doubles per attempt and caps", () => {
|
|
33
|
+
expect(repeatDelayMs(1, CFG)).toBe(100_000);
|
|
34
|
+
expect(repeatDelayMs(2, CFG)).toBe(200_000);
|
|
35
|
+
expect(repeatDelayMs(3, CFG)).toBe(400_000);
|
|
36
|
+
expect(repeatDelayMs(4, CFG)).toBe(400_000); // capped
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("finished (settled idle)", () => {
|
|
41
|
+
it("does not fire before idleConfirmMs — a breath between tool calls is not 'done'", () => {
|
|
42
|
+
const { pings } = run([
|
|
43
|
+
{ now: 0, state: "idle" },
|
|
44
|
+
{ now: 10_000, state: "idle" },
|
|
45
|
+
{ now: 29_999, state: "idle" },
|
|
46
|
+
]);
|
|
47
|
+
expect(pings).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("fires once the idle run clears idleConfirmMs", () => {
|
|
51
|
+
const { pings } = run([
|
|
52
|
+
{ now: 0, state: "idle" },
|
|
53
|
+
{ now: 30_000, state: "idle" },
|
|
54
|
+
{ now: 35_000, state: "idle" },
|
|
55
|
+
]);
|
|
56
|
+
expect(pings).toEqual([{ reason: "finished", attempt: 1, question: null }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("resets the idle timer when the agent picks work back up", () => {
|
|
60
|
+
const { pings } = run([
|
|
61
|
+
{ now: 0, state: "idle" },
|
|
62
|
+
{ now: 20_000, state: "active" },
|
|
63
|
+
{ now: 25_000, state: "idle" },
|
|
64
|
+
{ now: 50_000, state: "idle" }, // only 25s into the NEW idle run
|
|
65
|
+
]);
|
|
66
|
+
expect(pings).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("re-arms after activity, so a second round of work reports again", () => {
|
|
70
|
+
const { pings } = run([
|
|
71
|
+
{ now: 0, state: "idle" },
|
|
72
|
+
{ now: 40_000, state: "idle" }, // finished #1
|
|
73
|
+
{ now: 50_000, state: "active" },
|
|
74
|
+
{ now: 60_000, state: "idle" },
|
|
75
|
+
{ now: 100_000, state: "idle" }, // finished #2
|
|
76
|
+
]);
|
|
77
|
+
expect(pings.map((p) => [p.reason, p.attempt])).toEqual([
|
|
78
|
+
["finished", 1],
|
|
79
|
+
["finished", 1],
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("stuck (needs_input)", () => {
|
|
85
|
+
it("fires immediately — a blocked agent has no hysteresis to earn", () => {
|
|
86
|
+
const { pings } = run([{ now: 0, state: "needs_input", question: "Approve edit?" }]);
|
|
87
|
+
expect(pings).toEqual([{ reason: "stuck", attempt: 1, question: "Approve edit?" }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not re-fire on the same unchanged question", () => {
|
|
91
|
+
const { pings } = run([
|
|
92
|
+
{ now: 0, state: "needs_input", question: "Approve edit?" },
|
|
93
|
+
{ now: 1_000, state: "needs_input", question: "Approve edit?" },
|
|
94
|
+
{ now: 2_000, state: "needs_input", question: "Approve edit?" },
|
|
95
|
+
]);
|
|
96
|
+
expect(pings).toHaveLength(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("re-fires on a CHANGED question — that is new information, not a repeat", () => {
|
|
100
|
+
const { pings } = run([
|
|
101
|
+
{ now: 0, state: "needs_input", question: "Approve edit?" },
|
|
102
|
+
{ now: 1_000, state: "needs_input", question: "Run tests?" },
|
|
103
|
+
]);
|
|
104
|
+
expect(pings.map((p) => p.question)).toEqual(["Approve edit?", "Run tests?"]);
|
|
105
|
+
expect(pings.every((p) => p.attempt === 1)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("a stuck episode that decays to plain idle stays 'stuck', never 'finished'", () => {
|
|
109
|
+
const { pings } = run([
|
|
110
|
+
{ now: 0, state: "needs_input", question: "Approve edit?" },
|
|
111
|
+
{ now: 1_000, state: "idle" },
|
|
112
|
+
{ now: 200_000, state: "idle" },
|
|
113
|
+
]);
|
|
114
|
+
expect(pings.map((p) => p.reason)).toEqual(["stuck", "stuck"]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("keep pinging until something changes", () => {
|
|
119
|
+
it("repeats an unanswered episode on the backoff, up to maxRepeats", () => {
|
|
120
|
+
const { pings } = run([
|
|
121
|
+
{ now: 0, state: "needs_input", question: "q" }, // attempt 1
|
|
122
|
+
{ now: 99_000, state: "needs_input", question: "q" }, // not due
|
|
123
|
+
{ now: 100_000, state: "needs_input", question: "q" }, // attempt 2
|
|
124
|
+
{ now: 300_000, state: "needs_input", question: "q" }, // attempt 3
|
|
125
|
+
{ now: 900_000, state: "needs_input", question: "q" }, // maxRepeats exhausted
|
|
126
|
+
{ now: 999_999, state: "needs_input", question: "q" },
|
|
127
|
+
]);
|
|
128
|
+
expect(pings.map((p) => p.attempt)).toEqual([1, 2, 3]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("stops nagging once the agent goes active again", () => {
|
|
132
|
+
const { pings } = run([
|
|
133
|
+
{ now: 0, state: "needs_input", question: "q" },
|
|
134
|
+
{ now: 100_000, state: "active" },
|
|
135
|
+
{ now: 500_000, state: "active" },
|
|
136
|
+
]);
|
|
137
|
+
expect(pings).toHaveLength(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("exited", () => {
|
|
142
|
+
it("fires once and is terminal — nothing follows it", () => {
|
|
143
|
+
const { pings } = run([
|
|
144
|
+
{ now: 0, state: "active" },
|
|
145
|
+
{ now: 1_000, state: "exited" },
|
|
146
|
+
{ now: 2_000, state: "exited" },
|
|
147
|
+
{ now: 3_000, state: "idle" },
|
|
148
|
+
{ now: 400_000, state: "idle" },
|
|
149
|
+
]);
|
|
150
|
+
expect(pings).toEqual([{ reason: "exited", attempt: 1, question: null }]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("supersedes an open stuck episode", () => {
|
|
154
|
+
const { pings } = run([
|
|
155
|
+
{ now: 0, state: "needs_input", question: "q" },
|
|
156
|
+
{ now: 1_000, state: "exited" },
|
|
157
|
+
]);
|
|
158
|
+
expect(pings.map((p) => p.reason)).toEqual(["stuck", "exited"]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("defaults", () => {
|
|
163
|
+
it("ships a 30s idle confirm, matching notifyRouter", () => {
|
|
164
|
+
expect(DEFAULT_PING_CONFIG.idleConfirmMs).toBe(30_000);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("uses the shipped defaults when no config is passed", () => {
|
|
168
|
+
let s = initialPingState();
|
|
169
|
+
let r = stepParentPing(s, { now: 0, state: "idle" });
|
|
170
|
+
expect(r.ping).toBeNull();
|
|
171
|
+
r = stepParentPing(r.state, { now: 29_000, state: "idle" });
|
|
172
|
+
expect(r.ping).toBeNull();
|
|
173
|
+
r = stepParentPing(r.state, { now: 31_000, state: "idle" });
|
|
174
|
+
expect(r.ping?.reason).toBe("finished");
|
|
175
|
+
});
|
|
176
|
+
});
|
package/ts/parentPing.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sub-agent's own duty to PING ITS PARENT — pure edge/backoff state machine.
|
|
3
|
+
*
|
|
4
|
+
* `ay notifyd` already detects idle/needs_input/exited edges and files them into
|
|
5
|
+
* the parent's inbox, but that is a PULL channel: nothing is delivered unless the
|
|
6
|
+
* parent is running `ay notify watch`. The whole failure mode we care about is a
|
|
7
|
+
* parent that spawned a fan-out and then went back to its own work — i.e. exactly
|
|
8
|
+
* the parent that is NOT watching. `<ay-init-msg>` tells the child to report back,
|
|
9
|
+
* but an LLM that runs out of steam mid-task, crashes, or simply forgets will not.
|
|
10
|
+
*
|
|
11
|
+
* So the CHILD'S WRAPPER pushes, unconditionally: when the agent it supervises
|
|
12
|
+
* finishes (settled idle), gets stuck (parked on a question / wedged), or exits,
|
|
13
|
+
* it injects a message straight into the parent's stdin via `ay send`. That is
|
|
14
|
+
* the same channel a peer agent uses, so it lands in the parent's context whether
|
|
15
|
+
* or not the parent ever asked for it.
|
|
16
|
+
*
|
|
17
|
+
* "Keep pinging": a single ping can be missed — the parent may be mid-tool-call,
|
|
18
|
+
* or may have been compacted since. So an unresolved episode RE-pings on an
|
|
19
|
+
* escalating backoff up to `maxRepeats`, and the whole episode resets the moment
|
|
20
|
+
* the child goes active again (it got an answer, or picked the work back up).
|
|
21
|
+
*
|
|
22
|
+
* Pure + synchronous (the caller passes `now`) so it is unit-testable without a
|
|
23
|
+
* PTY or a clock, mirroring `notifyRouter.ts`. The delivery side lives in
|
|
24
|
+
* `parentPingSend.ts`; the wiring lives in `ts/index.ts`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** What the child looks like right now, as classified by its own wrapper. */
|
|
28
|
+
export type SelfState = "active" | "idle" | "needs_input" | "exited";
|
|
29
|
+
|
|
30
|
+
/** Why we are pinging — becomes the report's headline. */
|
|
31
|
+
export type PingReason = "finished" | "stuck" | "exited";
|
|
32
|
+
|
|
33
|
+
export interface PingObservation {
|
|
34
|
+
now: number;
|
|
35
|
+
state: SelfState;
|
|
36
|
+
/** Compact question text when state === "needs_input", else null. */
|
|
37
|
+
question?: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PingConfig {
|
|
41
|
+
/** How long the child must sit continuously idle before we call it "finished". */
|
|
42
|
+
idleConfirmMs: number;
|
|
43
|
+
/** Delay before the FIRST repeat of an unacknowledged episode. */
|
|
44
|
+
repeatBaseMs: number;
|
|
45
|
+
/** Cap for the doubling backoff between repeats. */
|
|
46
|
+
repeatMaxMs: number;
|
|
47
|
+
/** How many times to re-ping one episode before giving up (0 = ping once). */
|
|
48
|
+
maxRepeats: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_PING_CONFIG: PingConfig = {
|
|
52
|
+
// Matches notifyRouter's DEFAULT_IDLE_CONFIRM_MS: long enough that a pause
|
|
53
|
+
// between tool calls never reads as "done", short enough to be useful.
|
|
54
|
+
idleConfirmMs: 30_000,
|
|
55
|
+
repeatBaseMs: 120_000, // 2m → 4m → 8m → 15m (capped)
|
|
56
|
+
repeatMaxMs: 900_000,
|
|
57
|
+
maxRepeats: 4,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface PingState {
|
|
61
|
+
/** Last observed state, so we can detect the transition INTO an episode. */
|
|
62
|
+
state: SelfState | null;
|
|
63
|
+
/** When the current idle run began, or null when not idle. */
|
|
64
|
+
idleSince: number | null;
|
|
65
|
+
/** The reason of the episode we are currently pinging about, or null. */
|
|
66
|
+
episode: PingReason | null;
|
|
67
|
+
/** The question text the current `stuck` episode was opened on. */
|
|
68
|
+
episodeQuestion: string | null;
|
|
69
|
+
/** How many pings this episode has already emitted (first send = 1). */
|
|
70
|
+
sent: number;
|
|
71
|
+
/** When the next repeat is due, or null when nothing is scheduled. */
|
|
72
|
+
nextAt: number | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function initialPingState(): PingState {
|
|
76
|
+
return {
|
|
77
|
+
state: null,
|
|
78
|
+
idleSince: null,
|
|
79
|
+
episode: null,
|
|
80
|
+
episodeQuestion: null,
|
|
81
|
+
sent: 0,
|
|
82
|
+
nextAt: null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A decided ping, for the caller to deliver. */
|
|
87
|
+
export interface PingDecision {
|
|
88
|
+
reason: PingReason;
|
|
89
|
+
/** 1 for the first ping of an episode, 2+ for a repeat nobody answered. */
|
|
90
|
+
attempt: number;
|
|
91
|
+
question: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Exponential backoff for repeat N (1-based), capped. */
|
|
95
|
+
export function repeatDelayMs(attempt: number, cfg: PingConfig): number {
|
|
96
|
+
const raw = cfg.repeatBaseMs * 2 ** Math.max(0, attempt - 1);
|
|
97
|
+
return Math.min(raw, cfg.repeatMaxMs);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Advance by one tick. Returns the ping to send (or null) and the next state.
|
|
102
|
+
*
|
|
103
|
+
* Episode semantics:
|
|
104
|
+
* - `needs_input` opens a `stuck` episode immediately. A CHANGED question
|
|
105
|
+
* re-opens it (the agent is asking something new — that's fresh information,
|
|
106
|
+
* not a repeat), which also resets the backoff.
|
|
107
|
+
* - `idle` opens a `finished` episode only after `idleConfirmMs` of continuous
|
|
108
|
+
* idleness — hysteresis, so a breath between tool calls is not "done".
|
|
109
|
+
* - `exited` fires once, terminally, and cannot be superseded or repeated: the
|
|
110
|
+
* process is gone, so there is nothing left to re-observe.
|
|
111
|
+
* - `active` closes any open episode. The child is working again; whatever we
|
|
112
|
+
* reported is stale and the parent will hear about the next edge.
|
|
113
|
+
*/
|
|
114
|
+
export function stepParentPing(
|
|
115
|
+
prev: PingState,
|
|
116
|
+
obs: PingObservation,
|
|
117
|
+
cfg: PingConfig = DEFAULT_PING_CONFIG,
|
|
118
|
+
): { state: PingState; ping: PingDecision | null } {
|
|
119
|
+
const state: PingState = { ...prev };
|
|
120
|
+
const question = obs.question ?? null;
|
|
121
|
+
const wasExited = prev.episode === "exited";
|
|
122
|
+
state.state = obs.state;
|
|
123
|
+
|
|
124
|
+
// Terminal: once we've announced the exit there is nothing further to say.
|
|
125
|
+
if (wasExited) {
|
|
126
|
+
state.idleSince = null;
|
|
127
|
+
state.nextAt = null;
|
|
128
|
+
return { state, ping: null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (obs.state === "exited") {
|
|
132
|
+
state.idleSince = null;
|
|
133
|
+
state.episode = "exited";
|
|
134
|
+
state.episodeQuestion = null;
|
|
135
|
+
state.sent = 1;
|
|
136
|
+
state.nextAt = null;
|
|
137
|
+
return { state, ping: { reason: "exited", attempt: 1, question: null } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (obs.state === "active") {
|
|
141
|
+
// Working again — the episode (if any) is resolved or moot.
|
|
142
|
+
state.idleSince = null;
|
|
143
|
+
state.episode = null;
|
|
144
|
+
state.episodeQuestion = null;
|
|
145
|
+
state.sent = 0;
|
|
146
|
+
state.nextAt = null;
|
|
147
|
+
return { state, ping: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (obs.state === "needs_input") {
|
|
151
|
+
state.idleSince = null;
|
|
152
|
+
const isNewEpisode = prev.episode !== "stuck" || prev.episodeQuestion !== question;
|
|
153
|
+
if (isNewEpisode) {
|
|
154
|
+
state.episode = "stuck";
|
|
155
|
+
state.episodeQuestion = question;
|
|
156
|
+
state.sent = 1;
|
|
157
|
+
state.nextAt = obs.now + repeatDelayMs(1, cfg);
|
|
158
|
+
return { state, ping: { reason: "stuck", attempt: 1, question } };
|
|
159
|
+
}
|
|
160
|
+
return repeatIfDue(state, obs.now, cfg, question);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// obs.state === "idle"
|
|
164
|
+
state.idleSince = prev.idleSince ?? obs.now;
|
|
165
|
+
if (prev.episode === "finished") return repeatIfDue(state, obs.now, cfg, null);
|
|
166
|
+
// A `stuck` episode that decayed into plain idle keeps its identity — the agent
|
|
167
|
+
// is still parked on the same unanswered thing, just no longer matching the
|
|
168
|
+
// question pattern (a menu that scrolled off the tail, say). Re-classifying it
|
|
169
|
+
// as `finished` would tell the parent "done" about an agent that is blocked.
|
|
170
|
+
if (prev.episode === "stuck") return repeatIfDue(state, obs.now, cfg, prev.episodeQuestion);
|
|
171
|
+
if (obs.now - state.idleSince < cfg.idleConfirmMs) return { state, ping: null };
|
|
172
|
+
state.episode = "finished";
|
|
173
|
+
state.episodeQuestion = null;
|
|
174
|
+
state.sent = 1;
|
|
175
|
+
state.nextAt = obs.now + repeatDelayMs(1, cfg);
|
|
176
|
+
return { state, ping: { reason: "finished", attempt: 1, question: null } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Emit the next repeat of an already-open episode if its timer is due. */
|
|
180
|
+
function repeatIfDue(
|
|
181
|
+
state: PingState,
|
|
182
|
+
now: number,
|
|
183
|
+
cfg: PingConfig,
|
|
184
|
+
question: string | null,
|
|
185
|
+
): { state: PingState; ping: PingDecision | null } {
|
|
186
|
+
if (state.nextAt === null || now < state.nextAt) return { state, ping: null };
|
|
187
|
+
if (state.sent > cfg.maxRepeats) {
|
|
188
|
+
state.nextAt = null; // gave up nagging; the inbox/`ay ls` still shows the state
|
|
189
|
+
return { state, ping: null };
|
|
190
|
+
}
|
|
191
|
+
const attempt = state.sent + 1;
|
|
192
|
+
state.sent = attempt;
|
|
193
|
+
state.nextAt = attempt > cfg.maxRepeats ? null : now + repeatDelayMs(attempt, cfg);
|
|
194
|
+
return { state, ping: { reason: state.episode!, attempt, question } };
|
|
195
|
+
}
|