@fitchmultz/pi-subagents 0.52.17 → 0.52.19
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 +51 -0
- package/README.md +4 -0
- package/dist/agent/host.js +12 -0
- package/dist/agent/index.js +70 -56
- package/dist/agent/launch.js +12 -3
- package/dist/agent/notifications.js +5 -5
- package/dist/agent/parking.js +26 -15
- package/dist/agent/runner.js +18 -16
- package/dist/agent/tool-schemas.js +2 -0
- package/dist/agent/usage.js +42 -3
- package/dist/agent/view.js +13 -13
- package/dist/agent/wake.js +17 -15
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +2 -2
- package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
- package/dist/pi-intercom/broker/snapshots.js +56 -25
- package/dist/pi-intercom/broker/spawn-claim.js +89 -0
- package/dist/pi-intercom/broker/spawn.js +31 -78
- package/dist/pi-intercom/inbound-delivery.js +2 -6
- package/dist/pi-intercom/lifecycle.js +26 -26
- package/dist/pi-intercom/reply-tracker.js +47 -55
- package/dist/pi-intercom/session-events.js +2 -0
- package/dist/shared/post-exit-stdio-guard.js +44 -37
- package/docs/development.md +25 -0
- package/docs/intercom.md +19 -2
- package/docs/reference.md +16 -5
- package/package.json +1 -1
- package/scripts/build.mjs +41 -14
- package/skills/pi-subagents/SKILL.md +8 -4
- package/src/agent/contracts.ts +17 -0
- package/src/agent/host.ts +19 -0
- package/src/agent/index.ts +91 -62
- package/src/agent/launch.ts +16 -3
- package/src/agent/notifications.ts +5 -5
- package/src/agent/parking.ts +39 -17
- package/src/agent/runner.ts +20 -21
- package/src/agent/tool-schemas.ts +2 -0
- package/src/agent/usage.ts +48 -3
- package/src/agent/view.ts +15 -13
- package/src/agent/wake.ts +19 -15
- package/src/pi-intercom/broker/broker.ts +2 -1
- package/src/pi-intercom/broker/snapshots.ts +82 -37
- package/src/pi-intercom/broker/spawn-claim.ts +98 -0
- package/src/pi-intercom/broker/spawn.ts +42 -82
- package/src/pi-intercom/inbound-delivery.ts +2 -6
- package/src/pi-intercom/lifecycle.ts +38 -27
- package/src/pi-intercom/reply-tracker.ts +50 -58
- package/src/pi-intercom/session-events.ts +3 -1
- package/src/shared/post-exit-stdio-guard.ts +48 -40
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import net from "net";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
prepareBrokerSocketPath,
|
|
13
13
|
} from "./paths.ts";
|
|
14
14
|
import { isBrokerPidReused } from "./pid.ts";
|
|
15
|
+
import { acquireSpawnClaim } from "./spawn-claim.ts";
|
|
15
16
|
import { errorMessage, hasErrorCode } from "../../shared/unknown.ts";
|
|
16
17
|
|
|
17
18
|
const EXTENSION_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
@@ -85,8 +86,8 @@ export async function spawnBrokerIfNeeded(
|
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
89
|
+
const release = acquireSpawnClaim(BROKER_SPAWN_LOCK);
|
|
90
|
+
if (release === undefined) {
|
|
90
91
|
await waitForBroker();
|
|
91
92
|
return;
|
|
92
93
|
}
|
|
@@ -138,7 +139,7 @@ export async function spawnBrokerIfNeeded(
|
|
|
138
139
|
});
|
|
139
140
|
});
|
|
140
141
|
} finally {
|
|
141
|
-
|
|
142
|
+
release();
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
|
|
@@ -153,26 +154,33 @@ export async function assertBrokerCanStart(): Promise<void> {
|
|
|
153
154
|
await stopUnhealthyBrokerBeforeSpawn();
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
157
|
+
type BrokerOwner =
|
|
158
|
+
| { readonly kind: "absent" }
|
|
159
|
+
| { readonly kind: "live"; readonly pid: number }
|
|
160
|
+
| { readonly kind: "uncertain"; readonly reason: string };
|
|
161
|
+
|
|
162
|
+
function readBrokerOwner(pidPath: string): BrokerOwner {
|
|
160
163
|
let raw: string;
|
|
161
164
|
try {
|
|
162
165
|
raw = readFileSync(pidPath, "utf-8");
|
|
163
|
-
} catch {
|
|
164
|
-
return
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return hasErrorCode(error, "ENOENT")
|
|
168
|
+
? { kind: "absent" }
|
|
169
|
+
: { kind: "uncertain", reason: `Cannot read broker PID record: ${errorMessage(error)}` };
|
|
165
170
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
const first = raw.trim().split("\n")[0] ?? "";
|
|
172
|
+
const pid = Number(first);
|
|
173
|
+
if (!/^[1-9]\d*$/.test(first) || !Number.isSafeInteger(pid)) {
|
|
174
|
+
return { kind: "uncertain", reason: "Broker PID record is invalid" };
|
|
169
175
|
}
|
|
170
176
|
try {
|
|
171
177
|
process.kill(pid, 0);
|
|
172
178
|
} catch (error) {
|
|
173
|
-
return hasErrorCode(error, "
|
|
179
|
+
return hasErrorCode(error, "ESRCH")
|
|
180
|
+
? { kind: "absent" }
|
|
181
|
+
: { kind: "uncertain", reason: `Cannot establish broker PID ${pid}: ${errorMessage(error)}` };
|
|
174
182
|
}
|
|
175
|
-
return isBrokerPidReused(pid, raw) ?
|
|
183
|
+
return isBrokerPidReused(pid, raw) ? { kind: "absent" } : { kind: "live", pid };
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
async function previousBrokerWarning(): Promise<string | undefined> {
|
|
@@ -183,9 +191,10 @@ async function previousBrokerWarning(): Promise<string | undefined> {
|
|
|
183
191
|
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.";
|
|
184
192
|
}
|
|
185
193
|
}
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
|
|
194
|
+
const previous = readBrokerOwner(join(getPiAgentDir(), "intercom", "broker.pid"));
|
|
195
|
+
if (previous.kind !== "absent") {
|
|
196
|
+
const owner = previous.kind === "live" ? `PID ${previous.pid}` : "with uncertain PID identity";
|
|
197
|
+
return `Older Intercom broker ${owner} 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.`;
|
|
189
198
|
}
|
|
190
199
|
return undefined;
|
|
191
200
|
}
|
|
@@ -194,13 +203,19 @@ export async function stopUnhealthyBrokerBeforeSpawn(): Promise<void> {
|
|
|
194
203
|
if (await checkSocketConnectable()) {
|
|
195
204
|
return;
|
|
196
205
|
}
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
const owner = readBrokerOwner(BROKER_PID);
|
|
207
|
+
switch (owner.kind) {
|
|
208
|
+
case "absent":
|
|
209
|
+
return;
|
|
210
|
+
case "live":
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Intercom broker PID ${owner.pid} may still own accepted work but its socket is unhealthy; refusing to spawn a second broker. Let the broker exit normally, then retry.`,
|
|
213
|
+
);
|
|
214
|
+
case "uncertain":
|
|
215
|
+
throw new Error(
|
|
216
|
+
`${owner.reason}; refusing to spawn a second broker while ownership is uncertain.`,
|
|
217
|
+
);
|
|
200
218
|
}
|
|
201
|
-
throw new Error(
|
|
202
|
-
`Intercom broker PID ${pid} may still own accepted work but its socket is unhealthy; refusing to spawn a second broker. Let the broker exit normally, then retry.`,
|
|
203
|
-
);
|
|
204
219
|
}
|
|
205
220
|
|
|
206
221
|
async function checkSocketConnectable(): Promise<boolean> {
|
|
@@ -227,63 +242,6 @@ function checkOneSocket(brokerSocket: string): Promise<boolean> {
|
|
|
227
242
|
});
|
|
228
243
|
}
|
|
229
244
|
|
|
230
|
-
function acquireSpawnLock(): boolean {
|
|
231
|
-
const maxRetries = 5;
|
|
232
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
233
|
-
try {
|
|
234
|
-
writeFileSync(BROKER_SPAWN_LOCK, `${process.pid}\n${Date.now()}\n`, { flag: "wx" });
|
|
235
|
-
return true;
|
|
236
|
-
} catch (error) {
|
|
237
|
-
if (!hasErrorCode(error, "EEXIST")) {
|
|
238
|
-
throw error;
|
|
239
|
-
}
|
|
240
|
-
if (isSpawnLockStale()) {
|
|
241
|
-
releaseSpawnLock();
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
return false;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function isSpawnLockStale(): boolean {
|
|
251
|
-
if (!existsSync(BROKER_SPAWN_LOCK)) {
|
|
252
|
-
return false;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
const [pidLine = "", createdAtLine = "0"] = readFileSync(BROKER_SPAWN_LOCK, "utf-8")
|
|
257
|
-
.trim()
|
|
258
|
-
.split("\n");
|
|
259
|
-
const pid = Number.parseInt(pidLine, 10);
|
|
260
|
-
const createdAt = Number.parseInt(createdAtLine, 10);
|
|
261
|
-
const ageMs = Date.now() - createdAt;
|
|
262
|
-
|
|
263
|
-
if (Number.isFinite(pid)) {
|
|
264
|
-
try {
|
|
265
|
-
process.kill(pid, 0);
|
|
266
|
-
} catch {
|
|
267
|
-
// The process that created the lock is gone.
|
|
268
|
-
return true;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return !Number.isFinite(createdAt) || ageMs > 10_000;
|
|
273
|
-
} catch {
|
|
274
|
-
// Unreadable lock contents are treated as stale so a new broker can start.
|
|
275
|
-
return true;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function releaseSpawnLock(): void {
|
|
280
|
-
try {
|
|
281
|
-
unlinkSync(BROKER_SPAWN_LOCK);
|
|
282
|
-
} catch {
|
|
283
|
-
// Another cleanup path may already have removed the lock.
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
245
|
async function waitForBroker(timeoutMs = 5000): Promise<void> {
|
|
288
246
|
const start = Date.now();
|
|
289
247
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -294,5 +252,7 @@ async function waitForBroker(timeoutMs = 5000): Promise<void> {
|
|
|
294
252
|
// Polling waits between probes rather than starting an unbounded batch.
|
|
295
253
|
await sleep(100);
|
|
296
254
|
}
|
|
297
|
-
throw new Error(
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Broker failed to start within timeout. Startup claim: ${BROKER_SPAWN_LOCK}. Live or uncertain owners are not reclaimed.`,
|
|
257
|
+
);
|
|
298
258
|
}
|
|
@@ -185,7 +185,7 @@ export class InboundDeliveryOwner {
|
|
|
185
185
|
const trigger = index === -1 ? undefined : rest.splice(index, 1)[0];
|
|
186
186
|
const replyTarget = entries.find((entry) => entry.message.expectsReply === true) ?? trigger;
|
|
187
187
|
if (replyTarget && this.journal.get(replyTarget.message.id) === replyTarget) {
|
|
188
|
-
this.hooks.replies.queueTurnContext(
|
|
188
|
+
this.hooks.replies.queueTurnContext(replyTarget.message.id);
|
|
189
189
|
}
|
|
190
190
|
for (const entry of rest) {
|
|
191
191
|
if (!this.live(generation)) {
|
|
@@ -295,11 +295,7 @@ export class InboundDeliveryOwner {
|
|
|
295
295
|
this.journal.markNative(entry.message.id);
|
|
296
296
|
}
|
|
297
297
|
if (delivery !== "passive") {
|
|
298
|
-
this.hooks.replies.queueTurnContext(
|
|
299
|
-
from: entry.from,
|
|
300
|
-
message: entry.message,
|
|
301
|
-
receivedAt: Date.now(),
|
|
302
|
-
});
|
|
298
|
+
this.hooks.replies.queueTurnContext(entry.message.id);
|
|
303
299
|
}
|
|
304
300
|
}
|
|
305
301
|
private nativeMessage(entry: InboundMessageEntry): Parameters<ExtensionAPI["sendMessage"]>[0] {
|
|
@@ -24,14 +24,21 @@ type ContextSnapshot = Readonly<Record<keyof ContextTarget, string | null>> &
|
|
|
24
24
|
readonly guardedDelivery: 1 | null;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
type ContextState =
|
|
28
|
+
| { readonly kind: "inactive"; readonly epoch: number }
|
|
29
|
+
| { readonly kind: "invalidated"; readonly epoch: number; readonly sessionId: string }
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: "live";
|
|
32
|
+
readonly epoch: number;
|
|
33
|
+
readonly context: ExtensionContext;
|
|
34
|
+
readonly sessionId: string;
|
|
35
|
+
readonly incarnation: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
27
38
|
/** Owns native context validity and occupancy, never journal or socket state. */
|
|
28
39
|
export class IntercomLifecycle {
|
|
29
|
-
private
|
|
30
|
-
private sessionId: string | null = null;
|
|
40
|
+
private state: ContextState = { kind: "inactive", epoch: 0 };
|
|
31
41
|
private model = "unknown";
|
|
32
|
-
private disposed = true;
|
|
33
|
-
private epoch = 0;
|
|
34
|
-
private incarnation: string | null = null;
|
|
35
42
|
private peerId: string | null = null;
|
|
36
43
|
private guardedDelivery = false;
|
|
37
44
|
private applicationGuard: string | undefined;
|
|
@@ -47,10 +54,10 @@ export class IntercomLifecycle {
|
|
|
47
54
|
this.lineage = lineage;
|
|
48
55
|
}
|
|
49
56
|
get generation(): number {
|
|
50
|
-
return this.epoch;
|
|
57
|
+
return this.state.epoch;
|
|
51
58
|
}
|
|
52
59
|
get currentSessionId(): string | null {
|
|
53
|
-
return this.sessionId;
|
|
60
|
+
return this.state.kind === "inactive" ? null : this.state.sessionId;
|
|
54
61
|
}
|
|
55
62
|
get toolCount(): number {
|
|
56
63
|
return this.tools.size;
|
|
@@ -88,7 +95,7 @@ export class IntercomLifecycle {
|
|
|
88
95
|
request.respond(this.snapshot());
|
|
89
96
|
}
|
|
90
97
|
snapshot(): ContextSnapshot {
|
|
91
|
-
if (!this.live()) {
|
|
98
|
+
if (this.state.kind !== "live" || !this.live()) {
|
|
92
99
|
return {
|
|
93
100
|
version: 1,
|
|
94
101
|
rootSessionId: null,
|
|
@@ -100,10 +107,10 @@ export class IntercomLifecycle {
|
|
|
100
107
|
}
|
|
101
108
|
return {
|
|
102
109
|
version: 1,
|
|
103
|
-
rootSessionId: this.lineage?.rootSessionId ?? this.sessionId,
|
|
110
|
+
rootSessionId: this.lineage?.rootSessionId ?? this.state.sessionId,
|
|
104
111
|
parentSessionId: this.lineage?.parentSessionId,
|
|
105
112
|
peerId: this.peerId,
|
|
106
|
-
incarnation: this.incarnation,
|
|
113
|
+
incarnation: this.state.incarnation,
|
|
107
114
|
connected: this.peerId !== null,
|
|
108
115
|
guardedDelivery: this.guardedDelivery ? 1 : null,
|
|
109
116
|
applicationGuard: this.applicationGuard,
|
|
@@ -135,39 +142,43 @@ export class IntercomLifecycle {
|
|
|
135
142
|
);
|
|
136
143
|
}
|
|
137
144
|
start(ctx: ExtensionContext): number {
|
|
138
|
-
this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
this.state = {
|
|
146
|
+
kind: "live",
|
|
147
|
+
epoch: this.state.epoch + 1,
|
|
148
|
+
context: ctx,
|
|
149
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
150
|
+
incarnation: randomUUID(),
|
|
151
|
+
};
|
|
143
152
|
this.applicationGuard = undefined;
|
|
144
153
|
this.model = ctx.model?.id ?? "unknown";
|
|
145
154
|
this.running = false;
|
|
146
155
|
this.activity = 0;
|
|
147
156
|
this.tools.clear();
|
|
148
157
|
this.transport(null);
|
|
149
|
-
return this.epoch;
|
|
158
|
+
return this.state.epoch;
|
|
150
159
|
}
|
|
151
160
|
invalidate(): void {
|
|
152
|
-
this.
|
|
153
|
-
this.
|
|
161
|
+
const epoch = this.state.epoch + 1;
|
|
162
|
+
this.state =
|
|
163
|
+
this.state.kind === "inactive"
|
|
164
|
+
? { kind: "inactive", epoch }
|
|
165
|
+
: { kind: "invalidated", epoch, sessionId: this.state.sessionId };
|
|
154
166
|
this.running = false;
|
|
155
167
|
this.tools.clear();
|
|
156
168
|
this.publishContext();
|
|
157
169
|
}
|
|
158
170
|
clear(): void {
|
|
159
|
-
this.
|
|
160
|
-
this.sessionId = null;
|
|
171
|
+
this.state = { kind: "inactive", epoch: this.state.epoch };
|
|
161
172
|
}
|
|
162
173
|
live(
|
|
163
|
-
ctx: ExtensionContext | null = this.context,
|
|
164
|
-
generation = this.epoch,
|
|
174
|
+
ctx: ExtensionContext | null = this.state.kind === "live" ? this.state.context : null,
|
|
175
|
+
generation = this.state.epoch,
|
|
165
176
|
): ExtensionContext | null {
|
|
166
|
-
if (this.
|
|
177
|
+
if (this.state.kind !== "live" || generation !== this.state.epoch || !ctx) {
|
|
167
178
|
return null;
|
|
168
179
|
}
|
|
169
180
|
try {
|
|
170
|
-
if (
|
|
181
|
+
if (ctx.sessionManager.getSessionId() !== this.state.sessionId) {
|
|
171
182
|
return null;
|
|
172
183
|
}
|
|
173
184
|
// Reading a native property detects invalidated contexts without modifying them.
|
|
@@ -240,12 +251,12 @@ export class IntercomLifecycle {
|
|
|
240
251
|
}
|
|
241
252
|
async registration(pendingAsks: number): Promise<Omit<SessionInfo, "id">> {
|
|
242
253
|
const live = this.live();
|
|
243
|
-
if (!live || this.
|
|
254
|
+
if (!live || this.state.kind !== "live") {
|
|
244
255
|
throw new Error("Intercom runtime not initialized");
|
|
245
256
|
}
|
|
246
257
|
return {
|
|
247
|
-
...this.identity(this.sessionId),
|
|
248
|
-
incarnation: this.incarnation
|
|
258
|
+
...this.identity(this.state.sessionId),
|
|
259
|
+
incarnation: this.state.incarnation,
|
|
249
260
|
guardedDelivery: 1,
|
|
250
261
|
applicationGuard: this.applicationGuard,
|
|
251
262
|
cwd: live.cwd,
|
|
@@ -93,9 +93,9 @@ function replyBySender(contexts: readonly IntercomContext[], to: string): Interc
|
|
|
93
93
|
}
|
|
94
94
|
export class ReplyTracker {
|
|
95
95
|
private readonly pendingAsks = new Map<string, IntercomContext>();
|
|
96
|
-
private readonly
|
|
97
|
-
private
|
|
98
|
-
private
|
|
96
|
+
private readonly pendingTurnIds: string[] = [];
|
|
97
|
+
private currentTurnId: string | null = null;
|
|
98
|
+
private activeAgentId: string | null = null;
|
|
99
99
|
private readonly askTimeoutMs: number;
|
|
100
100
|
|
|
101
101
|
constructor(askTimeoutMs = 10 * 60 * 1000) {
|
|
@@ -115,67 +115,66 @@ export class ReplyTracker {
|
|
|
115
115
|
return context;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
queueTurnContext(
|
|
119
|
-
if (
|
|
118
|
+
queueTurnContext(messageId: string): void {
|
|
119
|
+
if (!this.pendingAsks.has(messageId) || this.hasTurnContext(messageId)) {
|
|
120
120
|
return;
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
this.pendingTurnContexts.push(context);
|
|
122
|
+
this.pendingTurnIds.push(messageId);
|
|
126
123
|
}
|
|
127
124
|
|
|
128
125
|
beginTurn(now = Date.now()): void {
|
|
129
126
|
this.pruneExpired(now);
|
|
130
|
-
this.
|
|
131
|
-
if (this.
|
|
132
|
-
this.
|
|
127
|
+
this.currentTurnId = this.pendingTurnIds.shift() ?? null;
|
|
128
|
+
if (this.currentTurnId !== null) {
|
|
129
|
+
this.activeAgentId = this.currentTurnId;
|
|
133
130
|
}
|
|
134
131
|
}
|
|
135
132
|
|
|
136
133
|
currentTurn(): IntercomContext | null {
|
|
137
|
-
|
|
134
|
+
const id = this.currentTurnId ?? this.activeAgentId;
|
|
135
|
+
return id === null ? null : (this.pendingAsks.get(id) ?? null);
|
|
138
136
|
}
|
|
139
137
|
|
|
140
138
|
endTurn(): void {
|
|
141
|
-
this.
|
|
139
|
+
this.currentTurnId = null;
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
endAgent(): void {
|
|
145
|
-
this.
|
|
146
|
-
this.
|
|
143
|
+
this.currentTurnId = null;
|
|
144
|
+
this.activeAgentId = null;
|
|
147
145
|
}
|
|
148
146
|
|
|
149
147
|
reset(): void {
|
|
150
148
|
this.pendingAsks.clear();
|
|
151
|
-
this.
|
|
152
|
-
this.
|
|
153
|
-
this.activeAgentContext = null;
|
|
149
|
+
this.pendingTurnIds.length = 0;
|
|
150
|
+
this.endAgent();
|
|
154
151
|
}
|
|
155
152
|
|
|
156
153
|
expireSender(sessionId: string): number {
|
|
157
|
-
const
|
|
154
|
+
const expiring = new Set(
|
|
155
|
+
[...this.pendingAsks.values()]
|
|
156
|
+
.filter((context) => context.from.id === sessionId)
|
|
157
|
+
.map((context) => context.message.id),
|
|
158
|
+
);
|
|
158
159
|
let expired = 0;
|
|
159
|
-
for (const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
expired += 1;
|
|
163
|
-
}
|
|
160
|
+
for (const messageId of expiring) {
|
|
161
|
+
this.pendingAsks.delete(messageId);
|
|
162
|
+
expired += 1;
|
|
164
163
|
}
|
|
165
|
-
const beforeQueued = this.
|
|
166
|
-
for (let index = this.
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
-
this.
|
|
164
|
+
const beforeQueued = this.pendingTurnIds.length;
|
|
165
|
+
for (let index = this.pendingTurnIds.length - 1; index >= 0; index -= 1) {
|
|
166
|
+
const id = this.pendingTurnIds[index];
|
|
167
|
+
if (id !== undefined && expiring.has(id)) {
|
|
168
|
+
this.pendingTurnIds.splice(index, 1);
|
|
170
169
|
}
|
|
171
170
|
}
|
|
172
|
-
expired += beforeQueued - this.
|
|
173
|
-
if (this.
|
|
174
|
-
this.
|
|
171
|
+
expired += beforeQueued - this.pendingTurnIds.length;
|
|
172
|
+
if (this.currentTurnId !== null && expiring.has(this.currentTurnId)) {
|
|
173
|
+
this.currentTurnId = null;
|
|
175
174
|
expired += 1;
|
|
176
175
|
}
|
|
177
|
-
if (this.
|
|
178
|
-
this.
|
|
176
|
+
if (this.activeAgentId !== null && expiring.has(this.activeAgentId)) {
|
|
177
|
+
this.activeAgentId = null;
|
|
179
178
|
expired += 1;
|
|
180
179
|
}
|
|
181
180
|
return expired;
|
|
@@ -188,30 +187,23 @@ export class ReplyTracker {
|
|
|
188
187
|
this.pruneExpired(now);
|
|
189
188
|
|
|
190
189
|
const pending = Array.from(this.pendingAsks.values());
|
|
191
|
-
const contexts = this.currentTurnContext
|
|
192
|
-
? [
|
|
193
|
-
this.currentTurnContext,
|
|
194
|
-
...pending.filter(
|
|
195
|
-
(context) => context.message.id !== this.currentTurnContext?.message.id,
|
|
196
|
-
),
|
|
197
|
-
]
|
|
198
|
-
: pending;
|
|
199
|
-
|
|
200
190
|
const replyTo = options.replyTo ?? "";
|
|
201
191
|
const to = options.to ?? "";
|
|
202
192
|
if (replyTo !== "") {
|
|
203
|
-
return explicitReply(
|
|
193
|
+
return explicitReply(pending, replyTo, to);
|
|
204
194
|
}
|
|
205
195
|
if (to !== "") {
|
|
206
|
-
return replyBySender(
|
|
196
|
+
return replyBySender(pending, to);
|
|
207
197
|
}
|
|
208
198
|
return this.implicitReplyTarget(pending);
|
|
209
199
|
}
|
|
210
200
|
|
|
211
201
|
/** Without an explicit target, the turn context wins, then a sole pending ask. */
|
|
212
202
|
private implicitReplyTarget(pending: readonly IntercomContext[]): IntercomContext {
|
|
213
|
-
|
|
214
|
-
|
|
203
|
+
const current =
|
|
204
|
+
this.currentTurnId === null ? undefined : this.pendingAsks.get(this.currentTurnId);
|
|
205
|
+
if (current) {
|
|
206
|
+
return current;
|
|
215
207
|
}
|
|
216
208
|
const [solePending] = pending;
|
|
217
209
|
if (pending.length === 1 && solePending !== undefined) {
|
|
@@ -234,24 +226,24 @@ export class ReplyTracker {
|
|
|
234
226
|
|
|
235
227
|
private hasTurnContext(messageId: string): boolean {
|
|
236
228
|
return (
|
|
237
|
-
this.
|
|
238
|
-
this.
|
|
239
|
-
this.
|
|
229
|
+
this.pendingTurnIds.includes(messageId) ||
|
|
230
|
+
this.currentTurnId === messageId ||
|
|
231
|
+
this.activeAgentId === messageId
|
|
240
232
|
);
|
|
241
233
|
}
|
|
242
234
|
|
|
243
235
|
private removeContext(messageId: string): void {
|
|
244
236
|
this.pendingAsks.delete(messageId);
|
|
245
|
-
for (let index = this.
|
|
246
|
-
if (this.
|
|
247
|
-
this.
|
|
237
|
+
for (let index = this.pendingTurnIds.length - 1; index >= 0; index -= 1) {
|
|
238
|
+
if (this.pendingTurnIds[index] === messageId) {
|
|
239
|
+
this.pendingTurnIds.splice(index, 1);
|
|
248
240
|
}
|
|
249
241
|
}
|
|
250
|
-
if (this.
|
|
251
|
-
this.
|
|
242
|
+
if (this.currentTurnId === messageId) {
|
|
243
|
+
this.currentTurnId = null;
|
|
252
244
|
}
|
|
253
|
-
if (this.
|
|
254
|
-
this.
|
|
245
|
+
if (this.activeAgentId === messageId) {
|
|
246
|
+
this.activeAgentId = null;
|
|
255
247
|
}
|
|
256
248
|
}
|
|
257
249
|
|
|
@@ -33,7 +33,7 @@ type Connection = Readonly<
|
|
|
33
33
|
>
|
|
34
34
|
>;
|
|
35
35
|
type Journal = Readonly<
|
|
36
|
-
Pick<InboundJournal, "clearNative" | "consume" | "reconcileConsumed" | "reset" | "retire">
|
|
36
|
+
Pick<InboundJournal, "clearNative" | "consume" | "get" | "reconcileConsumed" | "reset" | "retire">
|
|
37
37
|
>;
|
|
38
38
|
type ReplyWaitHandle = Readonly<Pick<ReplyWait, "reject">>;
|
|
39
39
|
type InboundDeliveryHandle = Readonly<
|
|
@@ -101,6 +101,8 @@ export class IntercomSessionEvents {
|
|
|
101
101
|
client?.isConnected() !== true ||
|
|
102
102
|
!context ||
|
|
103
103
|
context.message.expectsReply !== true ||
|
|
104
|
+
// A native turn can start without consuming its queued input after an abort.
|
|
105
|
+
journal.get(context.message.id) !== undefined ||
|
|
104
106
|
error === null ||
|
|
105
107
|
error === ""
|
|
106
108
|
) {
|
|
@@ -19,6 +19,7 @@ interface ChildWithPipedStdio {
|
|
|
19
19
|
readonly stdout: ChildProcess["stdout"];
|
|
20
20
|
readonly stderr: ChildProcess["stderr"];
|
|
21
21
|
readonly on: ChildProcess["on"];
|
|
22
|
+
readonly off: ChildProcess["off"];
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
interface ChildWithKill {
|
|
@@ -356,13 +357,17 @@ export function attachChildProcessLifecycle(
|
|
|
356
357
|
}
|
|
357
358
|
|
|
358
359
|
function attachPostExitStdioGuard(child: ChildWithPipedStdio): () => void {
|
|
359
|
-
let
|
|
360
|
+
let state:
|
|
361
|
+
| { readonly kind: "waiting" }
|
|
362
|
+
| { readonly kind: "draining"; idle: NodeJS.Timeout | undefined; readonly hard: NodeJS.Timeout }
|
|
363
|
+
| { readonly kind: "closed" } = { kind: "waiting" };
|
|
360
364
|
let stdoutEnded = false;
|
|
361
365
|
let stderrEnded = false;
|
|
362
|
-
let idleTimer: NodeJS.Timeout | undefined;
|
|
363
|
-
let hardTimer: NodeJS.Timeout | undefined;
|
|
364
366
|
|
|
365
367
|
const destroyUnendedStdio = () => {
|
|
368
|
+
if (state.kind !== "draining") {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
366
371
|
if (!stdoutEnded) {
|
|
367
372
|
try {
|
|
368
373
|
child.stdout?.destroy();
|
|
@@ -377,55 +382,58 @@ function attachPostExitStdioGuard(child: ChildWithPipedStdio): () => void {
|
|
|
377
382
|
// The process has exited; failed pipe destruction must not prevent other cleanup.
|
|
378
383
|
}
|
|
379
384
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const clearTimers = () => {
|
|
383
|
-
if (idleTimer) {
|
|
384
|
-
clearTimeout(idleTimer);
|
|
385
|
-
idleTimer = undefined;
|
|
386
|
-
}
|
|
387
|
-
if (hardTimer) {
|
|
388
|
-
clearTimeout(hardTimer);
|
|
389
|
-
hardTimer = undefined;
|
|
390
|
-
}
|
|
385
|
+
cleanup();
|
|
391
386
|
};
|
|
392
387
|
|
|
393
388
|
const armIdleTimer = () => {
|
|
394
|
-
if (
|
|
389
|
+
if (state.kind !== "draining") {
|
|
395
390
|
return;
|
|
396
391
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
idleTimer = setTimeout(destroyUnendedStdio, 2000);
|
|
401
|
-
idleTimer.unref();
|
|
392
|
+
clearTimeout(state.idle);
|
|
393
|
+
state.idle = setTimeout(destroyUnendedStdio, 2000);
|
|
394
|
+
state.idle.unref();
|
|
402
395
|
};
|
|
403
|
-
|
|
404
|
-
child.stdout?.on("data", armIdleTimer);
|
|
405
|
-
child.stderr?.on("data", armIdleTimer);
|
|
406
|
-
child.stdout?.on("end", () => {
|
|
396
|
+
const stdoutEnd = () => {
|
|
407
397
|
stdoutEnded = true;
|
|
408
398
|
if (stderrEnded) {
|
|
409
|
-
|
|
399
|
+
cleanup();
|
|
410
400
|
}
|
|
411
|
-
}
|
|
412
|
-
|
|
401
|
+
};
|
|
402
|
+
const stderrEnd = () => {
|
|
413
403
|
stderrEnded = true;
|
|
414
404
|
if (stdoutEnded) {
|
|
415
|
-
|
|
405
|
+
cleanup();
|
|
416
406
|
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
armIdleTimer();
|
|
421
|
-
if (hardTimer) {
|
|
407
|
+
};
|
|
408
|
+
const exited = () => {
|
|
409
|
+
if (state.kind !== "waiting") {
|
|
422
410
|
return;
|
|
423
411
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
412
|
+
const hard = setTimeout(destroyUnendedStdio, 8000);
|
|
413
|
+
hard.unref();
|
|
414
|
+
state = { kind: "draining", idle: undefined, hard };
|
|
415
|
+
armIdleTimer();
|
|
416
|
+
};
|
|
417
|
+
function cleanup(): void {
|
|
418
|
+
if (state.kind === "draining") {
|
|
419
|
+
clearTimeout(state.idle);
|
|
420
|
+
clearTimeout(state.hard);
|
|
421
|
+
}
|
|
422
|
+
state = { kind: "closed" };
|
|
423
|
+
child.stdout?.off("data", armIdleTimer);
|
|
424
|
+
child.stderr?.off("data", armIdleTimer);
|
|
425
|
+
child.stdout?.off("end", stdoutEnd);
|
|
426
|
+
child.stderr?.off("end", stderrEnd);
|
|
427
|
+
child.off("exit", exited);
|
|
428
|
+
child.off("close", cleanup);
|
|
429
|
+
child.off("error", cleanup);
|
|
430
|
+
}
|
|
431
|
+
child.stdout?.on("data", armIdleTimer);
|
|
432
|
+
child.stderr?.on("data", armIdleTimer);
|
|
433
|
+
child.stdout?.on("end", stdoutEnd);
|
|
434
|
+
child.stderr?.on("end", stderrEnd);
|
|
435
|
+
child.on("exit", exited);
|
|
436
|
+
child.on("close", cleanup);
|
|
437
|
+
child.on("error", cleanup);
|
|
438
|
+
return cleanup;
|
|
431
439
|
}
|