@fitchmultz/pi-subagents 0.52.18 → 0.52.20
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 +50 -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/bridge-presence.js +30 -18
- package/dist/pi-intercom/bridge-wake.js +60 -37
- package/dist/pi-intercom/broker/broker.js +2 -2
- package/dist/pi-intercom/broker/client-last.js +6 -18
- package/dist/pi-intercom/broker/client.js +16 -50
- package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
- package/dist/pi-intercom/broker/pending-requests.js +26 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
- package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
- 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/lifecycle.js +26 -26
- package/dist/shared/post-exit-stdio-guard.js +44 -37
- package/docs/development.md +25 -0
- package/docs/intercom.md +13 -0
- 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/bridge-presence.ts +37 -18
- package/src/pi-intercom/bridge-wake.ts +70 -38
- package/src/pi-intercom/broker/broker.ts +2 -1
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client.ts +36 -65
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
- 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/lifecycle.ts +38 -27
- package/src/shared/post-exit-stdio-guard.ts +48 -40
|
@@ -202,17 +202,52 @@ class SnapshotRecords<T extends { readonly topic: string }> {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
readonly
|
|
207
|
-
readonly
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
205
|
+
type PreparedTopicChange =
|
|
206
|
+
| { readonly kind: "unchanged"; readonly restore: false }
|
|
207
|
+
| { readonly kind: "normalize"; readonly restore: true }
|
|
208
|
+
| {
|
|
209
|
+
readonly kind: "publish";
|
|
210
|
+
readonly restore: false;
|
|
211
|
+
readonly topic: TopicUpdate;
|
|
212
|
+
readonly bytes: number;
|
|
213
|
+
}
|
|
214
|
+
| {
|
|
215
|
+
readonly kind: "restore-topic";
|
|
216
|
+
readonly restore: true;
|
|
217
|
+
readonly topic: TopicUpdate;
|
|
218
|
+
readonly bytes: number;
|
|
219
|
+
}
|
|
220
|
+
| {
|
|
221
|
+
readonly kind: "subscription";
|
|
222
|
+
readonly restore: boolean;
|
|
223
|
+
readonly subscription: TopicSubscription;
|
|
224
|
+
readonly bytes: number;
|
|
225
|
+
}
|
|
226
|
+
| { readonly kind: "unsubscribe"; readonly restore: false; readonly topic: string };
|
|
227
|
+
|
|
228
|
+
const UNCHANGED: PreparedTopicChange = { kind: "unchanged", restore: false };
|
|
229
|
+
|
|
230
|
+
function updatedTopic(prepared: PreparedTopicChange) {
|
|
231
|
+
return prepared.kind === "publish" || prepared.kind === "restore-topic" ? prepared : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function subscriptionKey(prepared: PreparedTopicChange): string | undefined {
|
|
235
|
+
switch (prepared.kind) {
|
|
236
|
+
case "subscription":
|
|
237
|
+
return prepared.subscription.topic;
|
|
238
|
+
case "unsubscribe":
|
|
239
|
+
return prepared.topic;
|
|
240
|
+
case "unchanged":
|
|
241
|
+
case "normalize":
|
|
242
|
+
case "publish":
|
|
243
|
+
case "restore-topic":
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
213
246
|
}
|
|
214
247
|
|
|
215
|
-
|
|
248
|
+
export function preparedPublication(prepared: PreparedTopicChange): TopicUpdate | undefined {
|
|
249
|
+
return prepared.kind === "publish" ? prepared.topic : undefined;
|
|
250
|
+
}
|
|
216
251
|
|
|
217
252
|
/** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
|
|
218
253
|
export class TopicSnapshot {
|
|
@@ -236,62 +271,64 @@ export class TopicSnapshot {
|
|
|
236
271
|
throw new Error("Topic snapshots unavailable for this client");
|
|
237
272
|
}
|
|
238
273
|
const mutation = topicMutation(change);
|
|
239
|
-
const base = { restore: change.action === "restore", changed: true, bytes: 0 };
|
|
240
274
|
switch (mutation.kind) {
|
|
241
275
|
case "publication":
|
|
242
|
-
return this.preparePublication(mutation
|
|
276
|
+
return this.preparePublication(mutation);
|
|
243
277
|
case "subscription":
|
|
244
278
|
return {
|
|
245
|
-
|
|
279
|
+
kind: "subscription",
|
|
280
|
+
restore: change.action === "restore",
|
|
246
281
|
subscription: mutation.subscription,
|
|
247
282
|
bytes: intercomMessageSizeBytes(mutation.subscription),
|
|
248
283
|
};
|
|
249
284
|
case "unsubscribe":
|
|
250
|
-
return {
|
|
285
|
+
return { kind: "unsubscribe", restore: false, topic: mutation.topic };
|
|
251
286
|
}
|
|
252
287
|
}
|
|
253
288
|
|
|
254
289
|
private preparePublication(
|
|
255
290
|
mutation: Extract<TopicMutation, { readonly kind: "publication" }>,
|
|
256
|
-
base: PreparedTopicChange,
|
|
257
291
|
): PreparedTopicChange {
|
|
258
292
|
const previous = this.topics.get(mutation.topic.topic);
|
|
259
293
|
const update = publicationUpdate(mutation.topic, previous, mutation.publish);
|
|
260
294
|
if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
|
|
261
|
-
return
|
|
295
|
+
return { kind: "normalize", restore: true };
|
|
262
296
|
}
|
|
263
297
|
return {
|
|
264
|
-
...
|
|
298
|
+
...(mutation.publish
|
|
299
|
+
? ({ kind: "publish", restore: false } as const)
|
|
300
|
+
: ({ kind: "restore-topic", restore: true } as const)),
|
|
265
301
|
topic: update,
|
|
266
302
|
bytes: intercomMessageSizeBytes(update),
|
|
267
|
-
...(mutation.publish ? { publication: update } : {}),
|
|
268
303
|
};
|
|
269
304
|
}
|
|
270
305
|
|
|
271
306
|
snapshot(info: SessionInfo, prepared = UNCHANGED): SessionInfo {
|
|
272
|
-
const
|
|
307
|
+
const topic = updatedTopic(prepared)?.topic;
|
|
308
|
+
const subscription = prepared.kind === "subscription" ? prepared.subscription : undefined;
|
|
309
|
+
const changed = prepared.kind !== "unchanged";
|
|
310
|
+
const topics = this.topics.valuesAfter(topic?.topic, topic, changed);
|
|
273
311
|
const subscriptions = this.subscriptions.valuesAfter(
|
|
274
|
-
prepared
|
|
275
|
-
|
|
276
|
-
|
|
312
|
+
subscriptionKey(prepared),
|
|
313
|
+
subscription,
|
|
314
|
+
changed,
|
|
277
315
|
);
|
|
278
316
|
return {
|
|
279
317
|
...info,
|
|
280
318
|
...(topics !== undefined ? { topics } : {}),
|
|
281
319
|
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
282
|
-
...(prepared
|
|
320
|
+
...(preparedPublication(prepared) ? { lastIntercomActivity: Date.now() } : {}),
|
|
283
321
|
};
|
|
284
322
|
}
|
|
285
323
|
|
|
286
324
|
validateRestore(info: SessionInfo, prepared: PreparedTopicChange, requestId: string): void {
|
|
287
325
|
snapshotFrames(requestId, [{ ...info, topics: [], subscriptions: [] }], true);
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
);
|
|
326
|
+
const topic = updatedTopic(prepared);
|
|
327
|
+
const subscription = prepared.kind === "subscription" ? prepared : undefined;
|
|
328
|
+
const topicBytes = this.topics.maximumAfter(topic?.topic.topic, topic?.bytes ?? 0);
|
|
292
329
|
const subscriptionBytes = this.subscriptions.maximumAfter(
|
|
293
|
-
|
|
294
|
-
|
|
330
|
+
subscription?.subscription.topic,
|
|
331
|
+
subscription?.bytes ?? 0,
|
|
295
332
|
);
|
|
296
333
|
for (const [field, bytes] of [
|
|
297
334
|
["topics", topicBytes],
|
|
@@ -311,16 +348,24 @@ export class TopicSnapshot {
|
|
|
311
348
|
}
|
|
312
349
|
|
|
313
350
|
commit(prepared: PreparedTopicChange): void {
|
|
314
|
-
if (prepared.
|
|
315
|
-
|
|
316
|
-
this.subscriptions.accept();
|
|
351
|
+
if (prepared.kind === "unchanged") {
|
|
352
|
+
return;
|
|
317
353
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
354
|
+
this.topics.accept();
|
|
355
|
+
this.subscriptions.accept();
|
|
356
|
+
switch (prepared.kind) {
|
|
357
|
+
case "publish":
|
|
358
|
+
case "restore-topic":
|
|
359
|
+
this.topics.set(prepared.topic, prepared.bytes);
|
|
360
|
+
return;
|
|
361
|
+
case "subscription":
|
|
362
|
+
this.subscriptions.set(prepared.subscription, prepared.bytes);
|
|
363
|
+
return;
|
|
364
|
+
case "unsubscribe":
|
|
365
|
+
this.subscriptions.delete(prepared.topic);
|
|
366
|
+
return;
|
|
367
|
+
case "normalize":
|
|
368
|
+
return;
|
|
324
369
|
}
|
|
325
370
|
}
|
|
326
371
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants,
|
|
4
|
+
fstatSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { hasErrorCode } from "../../shared/unknown.ts";
|
|
12
|
+
import { brokerPidRecord, isLinuxProcessReplaced } from "./pid.ts";
|
|
13
|
+
|
|
14
|
+
type OwnerState = "live" | "exited" | "uncertain";
|
|
15
|
+
|
|
16
|
+
function ownerState(fd: number): OwnerState {
|
|
17
|
+
try {
|
|
18
|
+
const lines = readFileSync(fd, "utf8").split("\n");
|
|
19
|
+
const first = lines[0] ?? "";
|
|
20
|
+
const pid = Number(first);
|
|
21
|
+
if (!/^[1-9]\d*$/.test(first) || !Number.isSafeInteger(pid)) {
|
|
22
|
+
return "uncertain";
|
|
23
|
+
}
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return isLinuxProcessReplaced(pid, lines.slice(2).join("\n")) ? "exited" : "live";
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return hasErrorCode(error, "ESRCH") ? "exited" : "uncertain";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The open descriptor pins the original inode until its own cleanup finishes. */
|
|
32
|
+
function removeOwned(file: string, fd: number): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const owned = fstatSync(fd, { bigint: true });
|
|
35
|
+
const current = lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
36
|
+
if (current?.dev !== owned.dev || current.ino !== owned.ino) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
unlinkSync(file);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reclaimExited(file: string): boolean {
|
|
47
|
+
let fd: number;
|
|
48
|
+
try {
|
|
49
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return hasErrorCode(error, "ENOENT");
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return ownerState(fd) === "exited" && removeOwned(file, fd);
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function publishClaim(file: string, fd: number): () => void {
|
|
61
|
+
const release = () => {
|
|
62
|
+
try {
|
|
63
|
+
removeOwned(file, fd);
|
|
64
|
+
} finally {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
// Keep legacy PID/timestamp lines; qualified native identity can prove later PID reuse.
|
|
70
|
+
const record = brokerPidRecord();
|
|
71
|
+
const identity = record.slice(record.indexOf("\n") + 1);
|
|
72
|
+
writeFileSync(fd, `${process.pid}\n${Date.now()}\n${identity}`);
|
|
73
|
+
return release;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
release();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An acquired claim returns its release operation; live or uncertain claims remain untouched. */
|
|
81
|
+
export function acquireSpawnClaim(file: string): (() => void) | undefined {
|
|
82
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
83
|
+
let fd: number;
|
|
84
|
+
try {
|
|
85
|
+
fd = openSync(file, "wx", 0o600);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!hasErrorCode(error, "EEXIST")) {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
if (reclaimExited(file)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
return publishClaim(file, fd);
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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,
|