@fitchmultz/pi-subagents 0.52.4 → 0.52.6
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/CHANGELOG.md +29 -0
- package/README.md +5 -4
- package/dist/agent/completion-watches.js +61 -0
- package/dist/agent/contracts.js +9 -0
- package/dist/agent/host.js +5 -0
- package/dist/agent/index.js +20 -51
- package/dist/agent/legacy.js +41 -11
- package/dist/agent/notifications.js +2 -2
- package/dist/agent/parking.js +121 -13
- package/dist/agent/process.js +107 -63
- package/dist/agent/receipts.js +21 -4
- package/dist/agent/runner.js +19 -5
- package/dist/agent/shutdown.js +43 -0
- package/dist/agent/view.js +5 -1
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +7 -6
- package/dist/pi-intercom/broker/client-wake.d.ts +17 -0
- package/dist/pi-intercom/broker/client-wake.js +54 -0
- package/dist/pi-intercom/broker/client.d.ts +2 -0
- package/dist/pi-intercom/broker/client.js +23 -16
- package/dist/pi-intercom/broker/parked.js +122 -5
- package/dist/pi-intercom/broker/spawn.js +10 -6
- package/dist/pi-intercom/config.js +3 -2
- package/dist/pi-intercom/connection.js +72 -11
- package/dist/pi-intercom/inbound-delivery.js +10 -5
- package/dist/pi-intercom/index.js +41 -6
- package/dist/pi-intercom/outbound.js +24 -7
- package/dist/pi-intercom/reply-wait.js +6 -1
- package/dist/pi-intercom/tool-results.js +2 -0
- package/dist/pi-intercom/types.d.ts +6 -0
- package/dist/shared/background-reporting.js +72 -0
- package/dist/shared/parking.js +6 -0
- package/docs/development.md +15 -3
- package/docs/intercom.md +14 -0
- package/docs/performance.md +2 -2
- package/docs/reference.md +13 -5
- package/package.json +1 -1
- package/scripts/package-smoke.mjs +21 -3
- package/src/agent/completion-watches.ts +73 -0
- package/src/agent/contracts.ts +11 -0
- package/src/agent/host.ts +6 -0
- package/src/agent/index.ts +24 -54
- package/src/agent/legacy.ts +44 -12
- package/src/agent/notifications.ts +2 -3
- package/src/agent/parking.ts +148 -12
- package/src/agent/process.ts +130 -66
- package/src/agent/receipts.ts +25 -4
- package/src/agent/runner.ts +25 -5
- package/src/agent/shutdown.ts +50 -0
- package/src/agent/view.ts +9 -0
- package/src/pi-intercom/broker/broker.ts +8 -7
- package/src/pi-intercom/broker/client-wake.ts +61 -0
- package/src/pi-intercom/broker/client.ts +23 -17
- package/src/pi-intercom/broker/parked.ts +152 -10
- package/src/pi-intercom/broker/spawn.ts +10 -9
- package/src/pi-intercom/config.ts +5 -2
- package/src/pi-intercom/connection.ts +88 -12
- package/src/pi-intercom/inbound-delivery.ts +21 -6
- package/src/pi-intercom/index.ts +64 -21
- package/src/pi-intercom/outbound.ts +30 -6
- package/src/pi-intercom/reply-wait.ts +6 -1
- package/src/pi-intercom/tool-results.ts +1 -0
- package/src/pi-intercom/types.ts +4 -0
- package/src/shared/background-reporting.ts +78 -0
- package/src/shared/parking.ts +16 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { nativePeerId } from "../session-targets.js";
|
|
3
|
-
import {
|
|
3
|
+
import { isRecord } from "../../shared/unknown.js";
|
|
4
|
+
import { validateIntercomMessageSize, writeMessage } from "./framing.js";
|
|
4
5
|
export function reachable(target) {
|
|
5
6
|
const socket = target?.socket;
|
|
6
7
|
return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
|
|
@@ -8,6 +9,17 @@ export function reachable(target) {
|
|
|
8
9
|
function requested(id) {
|
|
9
10
|
return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
|
|
10
11
|
}
|
|
12
|
+
function wakeResponse(msg) {
|
|
13
|
+
if (typeof msg.requestId === "string" && isRecord(msg.result)) {
|
|
14
|
+
if (msg.result.status === "requested") {
|
|
15
|
+
return { id: msg.requestId, result: { status: "requested" } };
|
|
16
|
+
}
|
|
17
|
+
if (msg.result.status === "failed" && typeof msg.result.reason === "string") {
|
|
18
|
+
return { id: msg.requestId, result: { status: "failed", reason: msg.result.reason } };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw new Error("Invalid wake response");
|
|
22
|
+
}
|
|
11
23
|
/** A fenced child keeps its identity and queued input; it never departs while parked. */
|
|
12
24
|
export class ParkedSessions {
|
|
13
25
|
sessions;
|
|
@@ -16,6 +28,20 @@ export class ParkedSessions {
|
|
|
16
28
|
this.sessions = sessions;
|
|
17
29
|
this.deliver = deliver;
|
|
18
30
|
}
|
|
31
|
+
handle(socket, id, msg) {
|
|
32
|
+
if (msg.type === "park" || msg.type === "unpark") {
|
|
33
|
+
this.park(socket, id, msg.type === "park");
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (msg.type === "wake_result") {
|
|
37
|
+
const owner = this.sessions.get(id);
|
|
38
|
+
if (owner) {
|
|
39
|
+
this.respond(owner, msg);
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
19
45
|
park(socket, id, on) {
|
|
20
46
|
const session = this.sessions.get(id);
|
|
21
47
|
if (on && session?.parking === true) {
|
|
@@ -57,6 +83,7 @@ export class ParkedSessions {
|
|
|
57
83
|
}
|
|
58
84
|
/** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
|
|
59
85
|
registered(id, previous) {
|
|
86
|
+
this.registeredWake(previous);
|
|
60
87
|
this.flush(id, previous?.parked?.retained ?? []);
|
|
61
88
|
for (const child of this.sessions.values()) {
|
|
62
89
|
if (nativePeerId(child.info.rootSessionId ?? "") === id) {
|
|
@@ -64,6 +91,26 @@ export class ParkedSessions {
|
|
|
64
91
|
}
|
|
65
92
|
}
|
|
66
93
|
}
|
|
94
|
+
registeredWake(previous) {
|
|
95
|
+
const main = previous && this.main(previous);
|
|
96
|
+
const attempt = previous?.parked?.attempt;
|
|
97
|
+
if (reachable(main) && attempt?.owner === main.socket) {
|
|
98
|
+
writeMessage(main.socket, { type: "wake_registered", requestId: attempt.id });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
capabilities(msg) {
|
|
102
|
+
return {
|
|
103
|
+
...(msg.wakeResults === 1 ? { wakeResults: 1 } : {}),
|
|
104
|
+
...(msg.wakeNotices === 1 ? { wakeNotices: 1 } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
features(msg) {
|
|
108
|
+
return {
|
|
109
|
+
parking: msg.parking === true,
|
|
110
|
+
wakeResults: msg.wakeResults === 1,
|
|
111
|
+
wakeNotices: msg.wakeNotices === 1,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
67
114
|
/** A transport handoff, not a departure: no session_left and queued input stays. */
|
|
68
115
|
closed(session) {
|
|
69
116
|
const { parked } = session;
|
|
@@ -81,12 +128,21 @@ export class ParkedSessions {
|
|
|
81
128
|
return [...this.sessions.values()].every(({ parked }) => parked?.closed === true && parked.retained.length === 0);
|
|
82
129
|
}
|
|
83
130
|
retain(target, from, message) {
|
|
84
|
-
target?.parked?.retained.push({ from, message });
|
|
85
|
-
if (target) {
|
|
131
|
+
target?.parked?.retained.push({ from, message, sender: this.sessions.get(from.id)?.socket });
|
|
132
|
+
if (target && this.waking(message)) {
|
|
86
133
|
this.wake(target);
|
|
87
134
|
}
|
|
88
135
|
return target?.parked !== undefined;
|
|
89
136
|
}
|
|
137
|
+
queueReason(target, messageId) {
|
|
138
|
+
const last = target?.parked?.lastFailure;
|
|
139
|
+
const reason = last === undefined
|
|
140
|
+
? "Queued for a parked child; delivery awaits its return."
|
|
141
|
+
: `Queued for a parked child; the last wake failed: ${last} The message may still arrive after a later wake.`;
|
|
142
|
+
return validateIntercomMessageSize({ type: "delivery_queued", messageId, reason }) === null
|
|
143
|
+
? reason
|
|
144
|
+
: "Queued for a parked child; its last wake failed. Inspect the agent run for details.";
|
|
145
|
+
}
|
|
90
146
|
/** Reads of an exited parked child go to its main, which answers from captured history. */
|
|
91
147
|
reader(id) {
|
|
92
148
|
const child = this.sessions.get(id);
|
|
@@ -95,10 +151,71 @@ export class ParkedSessions {
|
|
|
95
151
|
// Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
|
|
96
152
|
wake(child) {
|
|
97
153
|
const main = this.main(child);
|
|
98
|
-
const
|
|
99
|
-
if (main
|
|
154
|
+
const parked = child.parked;
|
|
155
|
+
if (!reachable(main) ||
|
|
156
|
+
parked?.closed !== true ||
|
|
157
|
+
!parked.retained.some(({ message }) => this.waking(message))) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!main.wakeResults) {
|
|
100
161
|
writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (parked.attempt?.owner === main.socket) {
|
|
165
|
+
parked.attempt.retry = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const attempt = {
|
|
169
|
+
id: randomUUID(),
|
|
170
|
+
owner: main.socket,
|
|
171
|
+
count: parked.retained.length,
|
|
172
|
+
retry: false,
|
|
173
|
+
};
|
|
174
|
+
parked.attempt = attempt;
|
|
175
|
+
writeMessage(main.socket, { type: "wake", sessionId: child.info.id, requestId: attempt.id });
|
|
176
|
+
}
|
|
177
|
+
waking(message) {
|
|
178
|
+
return (message.passive !== true && message.delivery !== "passive" && message.topic === undefined);
|
|
179
|
+
}
|
|
180
|
+
/** Only the main sent this request can report it, and only while that child stays parked. */
|
|
181
|
+
respond(owner, msg) {
|
|
182
|
+
const { id, result } = wakeResponse(msg);
|
|
183
|
+
const child = [...this.sessions.values()].find((peer) => peer.parked?.attempt?.id === id &&
|
|
184
|
+
peer.parked.attempt.owner === owner.socket &&
|
|
185
|
+
this.main(peer) === owner);
|
|
186
|
+
const parked = child?.parked;
|
|
187
|
+
const attempt = parked?.attempt;
|
|
188
|
+
if (!child || !parked || !attempt) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
parked.attempt = undefined;
|
|
192
|
+
if (result.status === "failed") {
|
|
193
|
+
parked.lastFailure = result.reason;
|
|
194
|
+
for (const retained of parked.retained.slice(0, attempt.count)) {
|
|
195
|
+
this.notify(child, retained, result.reason);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (attempt.retry) {
|
|
199
|
+
this.wake(child);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
notify(child, { from, sender, message }, reason) {
|
|
203
|
+
const origin = this.sessions.get(from.id);
|
|
204
|
+
if (message.expectsReply !== true ||
|
|
205
|
+
origin?.wakeNotices !== true ||
|
|
206
|
+
origin.socket !== sender ||
|
|
207
|
+
!reachable(origin)) {
|
|
208
|
+
return;
|
|
101
209
|
}
|
|
210
|
+
const frame = {
|
|
211
|
+
type: "wake_failed",
|
|
212
|
+
sessionId: child.info.id,
|
|
213
|
+
messageId: message.id,
|
|
214
|
+
reason,
|
|
215
|
+
};
|
|
216
|
+
writeMessage(origin.socket, validateIntercomMessageSize(frame) === null
|
|
217
|
+
? frame
|
|
218
|
+
: { ...frame, reason: "Wake failed; inspect the agent run for details." });
|
|
102
219
|
}
|
|
103
220
|
main(child) {
|
|
104
221
|
const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
|
|
@@ -44,9 +44,13 @@ export function getBrokerSpawnOptions() {
|
|
|
44
44
|
function toError(error) {
|
|
45
45
|
return error instanceof Error ? error : new Error(errorMessage(error));
|
|
46
46
|
}
|
|
47
|
-
export async function spawnBrokerIfNeeded(brokerCommand, brokerArgs) {
|
|
47
|
+
export async function spawnBrokerIfNeeded(brokerCommand, brokerArgs, warning = (message) => {
|
|
48
|
+
if (message !== undefined) {
|
|
49
|
+
console.warn(message);
|
|
50
|
+
}
|
|
51
|
+
}) {
|
|
48
52
|
prepareBrokerSocketPath();
|
|
49
|
-
await
|
|
53
|
+
warning(await previousBrokerWarning());
|
|
50
54
|
if (await isBrokerRunning()) {
|
|
51
55
|
return;
|
|
52
56
|
}
|
|
@@ -130,20 +134,20 @@ function readLivePid(pidPath) {
|
|
|
130
134
|
}
|
|
131
135
|
return isBrokerPidReused(pid, raw) ? null : pid;
|
|
132
136
|
}
|
|
133
|
-
async function
|
|
137
|
+
async function previousBrokerWarning() {
|
|
134
138
|
const previousSockets = getPreviousBrokerSocketPaths();
|
|
135
139
|
for (const socketPath of previousSockets) {
|
|
136
140
|
// Migration probes are sequential and never register or send to old peers.
|
|
137
141
|
// oxlint-disable-next-line no-await-in-loop
|
|
138
142
|
if (isOwnedBrokerSocket(socketPath) && (await checkOneSocket(socketPath))) {
|
|
139
|
-
|
|
140
|
-
return;
|
|
143
|
+
return "An older agent-directory Intercom broker is still running. Its peers are not part of shared same-user discovery until they reload with the updated extension. Existing sessions and accepted work are untouched; no legacy fallback is used.";
|
|
141
144
|
}
|
|
142
145
|
}
|
|
143
146
|
const previousPid = readLivePid(join(getPiAgentDir(), "intercom", "broker.pid"));
|
|
144
147
|
if (previousPid !== null) {
|
|
145
|
-
|
|
148
|
+
return `Older Intercom broker PID ${previousPid} may still own accepted work. Its peers need to reload with the updated extension to join shared same-user discovery. Nothing was stopped or replaced; no legacy fallback is used.`;
|
|
146
149
|
}
|
|
150
|
+
return undefined;
|
|
147
151
|
}
|
|
148
152
|
export async function stopUnhealthyBrokerBeforeSpawn() {
|
|
149
153
|
if (await checkSocketConnectable()) {
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getPiAgentDir } from "./agent-dir.js";
|
|
4
4
|
import { isRecord, isUnknownArray } from "./validation.js";
|
|
5
|
+
import { errorMessage } from "../shared/unknown.js";
|
|
5
6
|
const defaults = {
|
|
6
7
|
brokerCommand: process.execPath,
|
|
7
8
|
brokerArgs: [],
|
|
@@ -65,7 +66,7 @@ function configuredTimeout(parsed, key) {
|
|
|
65
66
|
}
|
|
66
67
|
return value;
|
|
67
68
|
}
|
|
68
|
-
export function loadConfig() {
|
|
69
|
+
export function loadConfig(report = console.error) {
|
|
69
70
|
const configPath = join(getPiAgentDir(), "intercom", "config.json");
|
|
70
71
|
if (!existsSync(configPath)) {
|
|
71
72
|
return { ...defaults };
|
|
@@ -87,7 +88,7 @@ export function loadConfig() {
|
|
|
87
88
|
};
|
|
88
89
|
}
|
|
89
90
|
catch (error) {
|
|
90
|
-
|
|
91
|
+
report(`Failed to load intercom config at ${configPath}: ${errorMessage(error)}. Using defaults.`);
|
|
91
92
|
return { ...defaults };
|
|
92
93
|
}
|
|
93
94
|
}
|
|
@@ -17,10 +17,13 @@ export class IntercomConnection {
|
|
|
17
17
|
lifecycle;
|
|
18
18
|
config;
|
|
19
19
|
hooks;
|
|
20
|
-
|
|
20
|
+
wakes = new Map();
|
|
21
|
+
reporter;
|
|
22
|
+
constructor(lifecycle, config, hooks, reporter) {
|
|
21
23
|
this.lifecycle = lifecycle;
|
|
22
24
|
this.config = config;
|
|
23
25
|
this.hooks = hooks;
|
|
26
|
+
this.reporter = reporter;
|
|
24
27
|
}
|
|
25
28
|
get active() {
|
|
26
29
|
return this.client;
|
|
@@ -157,6 +160,17 @@ export class IntercomConnection {
|
|
|
157
160
|
next.respondLast(request.requestId, { error: asError(error).message });
|
|
158
161
|
}
|
|
159
162
|
}
|
|
163
|
+
receive(next, from, message) {
|
|
164
|
+
const ctx = this.lifecycle.live();
|
|
165
|
+
if (this.client !== next || !ctx) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const owner = `inbound:${from.id}:${message.id}`;
|
|
169
|
+
this.hooks
|
|
170
|
+
.incoming(ctx, from, message)
|
|
171
|
+
.then(() => this.reporter.recover(owner))
|
|
172
|
+
.catch((error) => this.reporter.report(owner, `Intercom inbound ${message.id} from ${from.id} failed: ${asError(error).message}`));
|
|
173
|
+
}
|
|
160
174
|
createClient() {
|
|
161
175
|
const next = new IntercomClient({
|
|
162
176
|
sendTimeoutMs: this.config.sendTimeoutMs,
|
|
@@ -167,17 +181,48 @@ export class IntercomConnection {
|
|
|
167
181
|
this.answerLast(next, request);
|
|
168
182
|
});
|
|
169
183
|
next.on("message", (from, message) => {
|
|
170
|
-
|
|
171
|
-
if (this.client !== next || !ctx) {
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
this.hooks.incoming(ctx, from, message).catch((error) => {
|
|
175
|
-
console.error("Intercom inbound delivery failed:", error);
|
|
176
|
-
});
|
|
184
|
+
this.receive(next, from, message);
|
|
177
185
|
});
|
|
178
186
|
next.on("wake", (peer) => {
|
|
179
187
|
if (this.client === next) {
|
|
180
|
-
this.hooks.wake(peer)
|
|
188
|
+
void this.hooks.wake(peer).catch(() => {
|
|
189
|
+
// Old brokers cannot receive wake outcomes.
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
next.on("wake_request", (peer, requestId) => {
|
|
194
|
+
if (this.client === next) {
|
|
195
|
+
const registered = new AbortController();
|
|
196
|
+
this.wakes.set(requestId, registered);
|
|
197
|
+
void this.hooks
|
|
198
|
+
.wake(peer, registered.signal)
|
|
199
|
+
.catch((error) => ({
|
|
200
|
+
status: "failed",
|
|
201
|
+
reason: asError(error).message,
|
|
202
|
+
}))
|
|
203
|
+
.then((result) => {
|
|
204
|
+
if (this.client === next && !registered.signal.aborted) {
|
|
205
|
+
next.wakes.respond(requestId, result);
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
if (this.wakes.get(requestId) === registered) {
|
|
210
|
+
this.wakes.delete(requestId);
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
.catch(() => {
|
|
214
|
+
// A lost broker transport retains the input for the next registration.
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
next.on("wake_registered", (requestId) => {
|
|
219
|
+
if (this.client === next) {
|
|
220
|
+
this.wakes.get(requestId)?.abort();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
next.on("wake_failed", (peer, messageId, reason) => {
|
|
224
|
+
if (this.client === next) {
|
|
225
|
+
this.hooks.wakeFailed(peer, messageId, reason);
|
|
181
226
|
}
|
|
182
227
|
});
|
|
183
228
|
next.on("session_left", (id) => {
|
|
@@ -191,6 +236,7 @@ export class IntercomConnection {
|
|
|
191
236
|
return;
|
|
192
237
|
}
|
|
193
238
|
this.hooks.disconnected(error);
|
|
239
|
+
this.cancelWakes();
|
|
194
240
|
this.client = null;
|
|
195
241
|
this.lifecycle.transport(null);
|
|
196
242
|
this.clearReconnect();
|
|
@@ -204,7 +250,14 @@ export class IntercomConnection {
|
|
|
204
250
|
async connect(ctx, generation, sessionId) {
|
|
205
251
|
const next = this.createClient();
|
|
206
252
|
try {
|
|
207
|
-
await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs)
|
|
253
|
+
await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs, (message) => {
|
|
254
|
+
if (message === undefined) {
|
|
255
|
+
this.reporter.recover("older-broker");
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
this.reporter.report("older-broker", message, "warning");
|
|
259
|
+
}
|
|
260
|
+
});
|
|
208
261
|
await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()), nativePeerId(sessionId));
|
|
209
262
|
if (!this.lifecycle.live(ctx, generation)) {
|
|
210
263
|
await next.disconnect();
|
|
@@ -214,6 +267,7 @@ export class IntercomConnection {
|
|
|
214
267
|
this.lifecycle.transport(next.sessionId, next.supportsGuardedDelivery);
|
|
215
268
|
this.syncIdentity(ctx.sessionManager.getSessionId());
|
|
216
269
|
this.attempt = 0;
|
|
270
|
+
this.reporter.recover("connection-cleanup");
|
|
217
271
|
return next;
|
|
218
272
|
}
|
|
219
273
|
catch (error) {
|
|
@@ -222,7 +276,7 @@ export class IntercomConnection {
|
|
|
222
276
|
}
|
|
223
277
|
// A failed registration still owns its socket.
|
|
224
278
|
await next.disconnect().catch((cleanupError) => {
|
|
225
|
-
|
|
279
|
+
this.reporter.report("connection-cleanup", `Intercom failed connection cleanup: ${asError(cleanupError).message}`);
|
|
226
280
|
});
|
|
227
281
|
throw asError(error);
|
|
228
282
|
}
|
|
@@ -275,6 +329,7 @@ export class IntercomConnection {
|
|
|
275
329
|
}
|
|
276
330
|
async shutdown() {
|
|
277
331
|
this.clearTimers();
|
|
332
|
+
this.cancelWakes();
|
|
278
333
|
// The bounded launcher attempt is joined before socket shutdown.
|
|
279
334
|
await Promise.allSettled([this.connecting]);
|
|
280
335
|
if (this.client) {
|
|
@@ -282,6 +337,12 @@ export class IntercomConnection {
|
|
|
282
337
|
this.client = null;
|
|
283
338
|
}
|
|
284
339
|
}
|
|
340
|
+
cancelWakes() {
|
|
341
|
+
for (const wake of this.wakes.values()) {
|
|
342
|
+
wake.abort();
|
|
343
|
+
}
|
|
344
|
+
this.wakes.clear();
|
|
345
|
+
}
|
|
285
346
|
}
|
|
286
347
|
export async function resolveConnectedTarget(active, name) {
|
|
287
348
|
const sessions = await active.listSessions();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { requestedDelivery } from "./inbound-record.js";
|
|
2
2
|
import { runCooperatively } from "../shared/cooperative.js";
|
|
3
|
+
import { asError } from "./validation.js";
|
|
3
4
|
/** Owns cooperative inbound batches, restoration and the flush timer. */
|
|
4
5
|
export class InboundDeliveryOwner {
|
|
5
6
|
timer = null;
|
|
@@ -74,9 +75,7 @@ export class InboundDeliveryOwner {
|
|
|
74
75
|
this.clearTimer();
|
|
75
76
|
this.timer = setTimeout(() => {
|
|
76
77
|
this.timer = null;
|
|
77
|
-
this.flush(generation)?.catch((error) => {
|
|
78
|
-
console.error("Intercom inbound delivery failed:", error);
|
|
79
|
-
});
|
|
78
|
+
this.flush(generation)?.catch((error) => this.hooks.reporter.report("inbound-delivery", `Intercom inbound delivery failed: ${asError(error).message}`));
|
|
80
79
|
}, delayMs);
|
|
81
80
|
this.timer.unref();
|
|
82
81
|
}
|
|
@@ -105,9 +104,15 @@ export class InboundDeliveryOwner {
|
|
|
105
104
|
this.journal.queued().length > 0) {
|
|
106
105
|
this.schedule(0);
|
|
107
106
|
}
|
|
107
|
+
this.hooks.reporter.recover("inbound-cleanup");
|
|
108
108
|
};
|
|
109
|
-
task
|
|
110
|
-
|
|
109
|
+
task
|
|
110
|
+
.then(() => {
|
|
111
|
+
this.hooks.reporter.recover("inbound-delivery");
|
|
112
|
+
finish();
|
|
113
|
+
}, finish)
|
|
114
|
+
.catch((error) => {
|
|
115
|
+
this.hooks.reporter.report("inbound-cleanup", `Intercom inbound cleanup failed: ${asError(error).message}`);
|
|
111
116
|
});
|
|
112
117
|
return task;
|
|
113
118
|
}
|
|
@@ -13,6 +13,7 @@ import { IntercomTools } from "./tools.js";
|
|
|
13
13
|
import { IntercomSessionEvents } from "./session-events.js";
|
|
14
14
|
import { registerIntercomTools } from "./tool-registration.js";
|
|
15
15
|
import { isParkRequest, PARK, PARKED_HISTORY, WAKE } from "../shared/parking.js";
|
|
16
|
+
import { BackgroundReporter } from "../shared/background-reporting.js";
|
|
16
17
|
/** The agent runtime answers for its exited parked child from the captured native session. */
|
|
17
18
|
function parkedHistory(pi, peer) {
|
|
18
19
|
let history;
|
|
@@ -24,9 +25,43 @@ function parkedHistory(pi, peer) {
|
|
|
24
25
|
});
|
|
25
26
|
return history ?? Promise.reject(new Error("No agent runtime owns parked history."));
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
function wakeParked(pi, peer, signal) {
|
|
29
|
+
let result;
|
|
30
|
+
pi.events.emit(WAKE, {
|
|
31
|
+
peer,
|
|
32
|
+
signal,
|
|
33
|
+
reply: (outcome) => {
|
|
34
|
+
result = outcome;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return (result ??
|
|
38
|
+
Promise.resolve({ status: "failed", reason: "No agent runtime owns this parked child." }));
|
|
39
|
+
}
|
|
40
|
+
function setupReporting(pi) {
|
|
41
|
+
let configError;
|
|
42
|
+
const config = loadConfig((message) => {
|
|
43
|
+
configError = message;
|
|
44
|
+
});
|
|
29
45
|
const lifecycle = new IntercomLifecycle(pi, config, readIntercomLineage());
|
|
46
|
+
const reporter = new BackgroundReporter(() => lifecycle.live());
|
|
47
|
+
let pendingConfigError = configError;
|
|
48
|
+
pi.on("session_start", (_event, ctx) => {
|
|
49
|
+
reporter.start();
|
|
50
|
+
if (pendingConfigError !== undefined) {
|
|
51
|
+
if (ctx.hasUI) {
|
|
52
|
+
ctx.ui.notify(pendingConfigError, "warning");
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.error(pendingConfigError);
|
|
56
|
+
}
|
|
57
|
+
pendingConfigError = undefined;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
pi.on("session_shutdown", () => reporter.shutdown());
|
|
61
|
+
return { config, lifecycle, reporter };
|
|
62
|
+
}
|
|
63
|
+
export default function piIntercomExtension(pi) {
|
|
64
|
+
const { config, lifecycle, reporter } = setupReporting(pi);
|
|
30
65
|
const replies = new ReplyTracker(config.askTimeoutMs);
|
|
31
66
|
const journal = new InboundJournal(pi, lifecycle, replies);
|
|
32
67
|
const wait = new ReplyWait(lifecycle, config.askTimeoutMs);
|
|
@@ -43,13 +78,13 @@ export default function piIntercomExtension(pi) {
|
|
|
43
78
|
wait.reject(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
|
|
44
79
|
},
|
|
45
80
|
parkedHistory: (peer) => parkedHistory(pi, peer.id),
|
|
46
|
-
wake: (peer) =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
});
|
|
81
|
+
wake: (peer, signal) => wakeParked(pi, peer, signal),
|
|
82
|
+
wakeFailed: (peer, messageId, reason) => wait.wakeFailed(peer, messageId, reason),
|
|
83
|
+
}, reporter);
|
|
50
84
|
const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
|
|
51
85
|
replies,
|
|
52
86
|
syncStatus: () => connection.syncStatus(),
|
|
87
|
+
reporter,
|
|
53
88
|
});
|
|
54
89
|
lifecycle.registerContextEvents(() => connection.syncStatus());
|
|
55
90
|
const inbound = new IntercomInbound(lifecycle, config, {
|
|
@@ -3,7 +3,7 @@ import { resolveConnectedTarget, resolvePeerHealth, } from "./connection.js";
|
|
|
3
3
|
import { RECIPIENT_TURN_FAILED_ATTACHMENT, } from "./runtime-types.js";
|
|
4
4
|
import { errorMessage } from "./validation.js";
|
|
5
5
|
import { formatAttachments } from "./message-format.js";
|
|
6
|
-
import { AskDeliveryError, failureDetails, RecoveryError, } from "./tool-results.js";
|
|
6
|
+
import { AskDeliveryError, failureDetails, RecoveryError, WakeFailedError, } from "./tool-results.js";
|
|
7
7
|
import { sendOptions, toolError, toolText } from "./tool-arguments.js";
|
|
8
8
|
export class IntercomOutbound {
|
|
9
9
|
owners;
|
|
@@ -142,6 +142,15 @@ export class IntercomOutbound {
|
|
|
142
142
|
return toolText(`**Reply from ${to}:**\n${text}${attachments !== undefined && attachments.length > 0 ? formatAttachments(attachments) : ""}`);
|
|
143
143
|
}
|
|
144
144
|
askFailure(error, to, questionId) {
|
|
145
|
+
if (error instanceof WakeFailedError) {
|
|
146
|
+
return toolError(`The parked child could not be woken: ${error.message} Your ask is still queued and may arrive after a later wake. Check the recipient before sending it again.`, failureDetails("wake_failed", [{ action: "list" }, { action: "status" }], {
|
|
147
|
+
error: true,
|
|
148
|
+
messageId: questionId,
|
|
149
|
+
accepted: true,
|
|
150
|
+
delivered: false,
|
|
151
|
+
queued: true,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
145
154
|
if (error instanceof AskDeliveryError) {
|
|
146
155
|
const result = this.deliveryFailure(to, error.result);
|
|
147
156
|
return { ...result, details: { ...result.details, error: true } };
|
|
@@ -272,17 +281,25 @@ export class IntercomOutbound {
|
|
|
272
281
|
this.owners.replies.markReplied(target.message.id);
|
|
273
282
|
this.markActivity();
|
|
274
283
|
this.recordSent(sender, { text: body, replyTo: target.message.id }, result);
|
|
275
|
-
return
|
|
276
|
-
messageId: result.id,
|
|
277
|
-
delivered: true,
|
|
278
|
-
replyTo: target.message.id,
|
|
279
|
-
reasonCode: "reply_sent",
|
|
280
|
-
});
|
|
284
|
+
return this.replySent(sender, target.message.id, result);
|
|
281
285
|
}
|
|
282
286
|
catch (error) {
|
|
283
287
|
return this.replyFailure(error, replyTo);
|
|
284
288
|
}
|
|
285
289
|
}
|
|
290
|
+
replySent(sender, replyTo, result) {
|
|
291
|
+
const queued = result.queued === true;
|
|
292
|
+
return toolText(queued
|
|
293
|
+
? `Reply queued for ${sender} (${result.reason ?? "queued"})`
|
|
294
|
+
: `Reply sent to ${sender}`, {
|
|
295
|
+
messageId: result.id,
|
|
296
|
+
accepted: result.accepted,
|
|
297
|
+
delivered: result.delivered,
|
|
298
|
+
queued,
|
|
299
|
+
replyTo,
|
|
300
|
+
reasonCode: queued ? "message_queued" : "reply_sent",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
286
303
|
replyFailure(error, replyTo) {
|
|
287
304
|
const reason = error instanceof RecoveryError ? error.code : "reply_failed";
|
|
288
305
|
const actions = reason === "no_pending_reply"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asError } from "./validation.js";
|
|
2
|
-
import { AskDeliveryError, RecoveryError } from "./tool-results.js";
|
|
2
|
+
import { AskDeliveryError, RecoveryError, WakeFailedError } from "./tool-results.js";
|
|
3
3
|
/** One correlated blocking wait; admitted before send so a fast reply cannot race it. */
|
|
4
4
|
export class ReplyWait {
|
|
5
5
|
waiter = null;
|
|
@@ -23,6 +23,11 @@ export class ReplyWait {
|
|
|
23
23
|
this.reject(new Error(`Reply peer disconnected before answering: ${id}`));
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
wakeFailed(peer, messageId, reason) {
|
|
27
|
+
if (this.waiter?.from === peer && this.waiter.replyTo === messageId) {
|
|
28
|
+
this.reject(new WakeFailedError(reason));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
26
31
|
receive(sender, message, onMatch) {
|
|
27
32
|
const waiter = this.waiter;
|
|
28
33
|
if (!waiter || sender.id !== waiter.from || message.replyTo !== waiter.replyTo) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getPiAgentDir } from "../pi-intercom/agent-dir.js";
|
|
4
|
+
/** One active failure per owner. Recovery is established by the owner, not by the reporter. */
|
|
5
|
+
export class BackgroundReporter {
|
|
6
|
+
episodes = new Map();
|
|
7
|
+
context;
|
|
8
|
+
shuttingDown = false;
|
|
9
|
+
headless = false;
|
|
10
|
+
constructor(context) {
|
|
11
|
+
this.context = context;
|
|
12
|
+
}
|
|
13
|
+
start() {
|
|
14
|
+
this.shuttingDown = false;
|
|
15
|
+
}
|
|
16
|
+
shutdown() {
|
|
17
|
+
try {
|
|
18
|
+
this.headless = this.context()?.hasUI === false;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
this.headless = false;
|
|
22
|
+
}
|
|
23
|
+
this.shuttingDown = true;
|
|
24
|
+
}
|
|
25
|
+
recover(owner) {
|
|
26
|
+
this.episodes.delete(owner);
|
|
27
|
+
}
|
|
28
|
+
published(owner, message) {
|
|
29
|
+
if (owner !== undefined) {
|
|
30
|
+
this.episodes.set(owner, message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
repeated(owner, message) {
|
|
34
|
+
return owner !== undefined && this.episodes.get(owner) === message;
|
|
35
|
+
}
|
|
36
|
+
report(owner, message, severity = "error") {
|
|
37
|
+
if (this.repeated(owner, message)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (this.shuttingDown && this.headless) {
|
|
41
|
+
console.error(message);
|
|
42
|
+
this.published(owner, message);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const ctx = this.context();
|
|
47
|
+
if (!this.shuttingDown && ctx) {
|
|
48
|
+
if (ctx.hasUI) {
|
|
49
|
+
ctx.ui.notify(message, severity);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.error(message);
|
|
53
|
+
}
|
|
54
|
+
this.published(owner, message);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A context may be invalidated between its owner's check and notification.
|
|
60
|
+
}
|
|
61
|
+
// Pi may have already stopped its renderer during session_shutdown. Never write to the terminal.
|
|
62
|
+
try {
|
|
63
|
+
const dir = path.join(getPiAgentDir(), "intercom");
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
fs.appendFileSync(path.join(dir, "background-errors.log"), `${new Date().toISOString()} ${message}\n`, { mode: 0o600 });
|
|
66
|
+
this.published(owner, message);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// No terminal write is safe here; the diagnostic store itself may be unavailable.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|