@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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type SessionSnapshot } from "../types.ts";
|
|
2
2
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
3
3
|
export declare class SnapshotReader {
|
|
4
|
-
private
|
|
5
|
-
private
|
|
4
|
+
private phase;
|
|
5
|
+
private open;
|
|
6
|
+
fail(): void;
|
|
6
7
|
private addSession;
|
|
7
8
|
append(sessions: readonly unknown[], receipts: unknown): void;
|
|
8
9
|
finish(): SessionSnapshot;
|
|
@@ -48,15 +48,25 @@ function appendRows(previous, rows, supplied) {
|
|
|
48
48
|
}
|
|
49
49
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
50
50
|
export class SnapshotReader {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
phase = { kind: "open", sessions: new Map(), receipts: [] };
|
|
52
|
+
open() {
|
|
53
|
+
if (this.phase.kind !== "open") {
|
|
54
|
+
throw new Error(`Session snapshot is ${this.phase.kind}`);
|
|
55
|
+
}
|
|
56
|
+
return this.phase;
|
|
57
|
+
}
|
|
58
|
+
fail() {
|
|
59
|
+
if (this.phase.kind === "open") {
|
|
60
|
+
this.phase = { kind: "failed" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
addSession(state, row) {
|
|
54
64
|
if (!isRecord(row) || typeof row.id !== "string") {
|
|
55
65
|
throw new Error("Invalid session snapshot row");
|
|
56
66
|
}
|
|
57
67
|
const topics = optionalArray(row.topics);
|
|
58
68
|
const subscriptions = optionalArray(row.subscriptions);
|
|
59
|
-
const previous =
|
|
69
|
+
const previous = state.sessions.get(row.id);
|
|
60
70
|
const info = normalizeSessionInfo({
|
|
61
71
|
...previous?.info,
|
|
62
72
|
...row,
|
|
@@ -67,33 +77,42 @@ export class SnapshotReader {
|
|
|
67
77
|
throw new Error("Invalid session snapshot");
|
|
68
78
|
}
|
|
69
79
|
const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
|
|
70
|
-
|
|
80
|
+
state.sessions.set(info.id, {
|
|
71
81
|
info: identity,
|
|
72
82
|
topics: appendRows(previous?.topics, newTopics, "topics" in row),
|
|
73
83
|
subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
|
|
74
84
|
});
|
|
75
85
|
}
|
|
76
86
|
append(sessions, receipts) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
const state = this.open();
|
|
88
|
+
try {
|
|
89
|
+
for (const row of sessions) {
|
|
90
|
+
this.addSession(state, row);
|
|
91
|
+
}
|
|
92
|
+
if (receipts === undefined) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!isUnknownArray(receipts)) {
|
|
96
|
+
throw new Error("Invalid topic delivery receipts");
|
|
97
|
+
}
|
|
98
|
+
state.receipts.push(...receipts.map(receipt));
|
|
82
99
|
}
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
catch (error) {
|
|
101
|
+
this.fail();
|
|
102
|
+
throw error;
|
|
85
103
|
}
|
|
86
|
-
this.receipts.push(...receipts.map(receipt));
|
|
87
104
|
}
|
|
88
105
|
finish() {
|
|
106
|
+
const state = this.open();
|
|
89
107
|
const sessions = [];
|
|
90
|
-
for (const { info, topics, subscriptions } of
|
|
108
|
+
for (const { info, topics, subscriptions } of state.sessions.values()) {
|
|
91
109
|
sessions.push({
|
|
92
110
|
...info,
|
|
93
111
|
...(topics !== undefined ? { topics } : {}),
|
|
94
112
|
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
95
113
|
});
|
|
96
114
|
}
|
|
97
|
-
|
|
115
|
+
this.phase = { kind: "finished" };
|
|
116
|
+
return { sessions, receipts: state.receipts };
|
|
98
117
|
}
|
|
99
118
|
}
|
|
@@ -2,15 +2,33 @@ import { type SessionInfo, type TopicUpdate, type Message, type BrokerMessage, t
|
|
|
2
2
|
/** Direct messages and old clients receive identity, not potentially large topic state. */
|
|
3
3
|
export declare function messageSender(info: SessionInfo): SessionInfo;
|
|
4
4
|
export declare function snapshotFrames(requestId: string, infos: readonly SessionInfo[], stream: boolean): BrokerMessage[];
|
|
5
|
-
|
|
6
|
-
readonly
|
|
7
|
-
readonly
|
|
8
|
-
|
|
9
|
-
readonly
|
|
5
|
+
type PreparedTopicChange = {
|
|
6
|
+
readonly kind: "unchanged";
|
|
7
|
+
readonly restore: false;
|
|
8
|
+
} | {
|
|
9
|
+
readonly kind: "normalize";
|
|
10
|
+
readonly restore: true;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "publish";
|
|
13
|
+
readonly restore: false;
|
|
14
|
+
readonly topic: TopicUpdate;
|
|
15
|
+
readonly bytes: number;
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: "restore-topic";
|
|
18
|
+
readonly restore: true;
|
|
19
|
+
readonly topic: TopicUpdate;
|
|
20
|
+
readonly bytes: number;
|
|
21
|
+
} | {
|
|
22
|
+
readonly kind: "subscription";
|
|
10
23
|
readonly restore: boolean;
|
|
11
|
-
readonly
|
|
24
|
+
readonly subscription: TopicSubscription;
|
|
12
25
|
readonly bytes: number;
|
|
13
|
-
}
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "unsubscribe";
|
|
28
|
+
readonly restore: false;
|
|
29
|
+
readonly topic: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function preparedPublication(prepared: PreparedTopicChange): TopicUpdate | undefined;
|
|
14
32
|
/** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
|
|
15
33
|
export declare class TopicSnapshot {
|
|
16
34
|
private readonly topics;
|
|
@@ -157,7 +157,26 @@ class SnapshotRecords {
|
|
|
157
157
|
return values;
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
-
const UNCHANGED = {
|
|
160
|
+
const UNCHANGED = { kind: "unchanged", restore: false };
|
|
161
|
+
function updatedTopic(prepared) {
|
|
162
|
+
return prepared.kind === "publish" || prepared.kind === "restore-topic" ? prepared : undefined;
|
|
163
|
+
}
|
|
164
|
+
function subscriptionKey(prepared) {
|
|
165
|
+
switch (prepared.kind) {
|
|
166
|
+
case "subscription":
|
|
167
|
+
return prepared.subscription.topic;
|
|
168
|
+
case "unsubscribe":
|
|
169
|
+
return prepared.topic;
|
|
170
|
+
case "unchanged":
|
|
171
|
+
case "normalize":
|
|
172
|
+
case "publish":
|
|
173
|
+
case "restore-topic":
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function preparedPublication(prepared) {
|
|
178
|
+
return prepared.kind === "publish" ? prepared.topic : undefined;
|
|
179
|
+
}
|
|
161
180
|
/** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
|
|
162
181
|
export class TopicSnapshot {
|
|
163
182
|
topics;
|
|
@@ -177,47 +196,53 @@ export class TopicSnapshot {
|
|
|
177
196
|
throw new Error("Topic snapshots unavailable for this client");
|
|
178
197
|
}
|
|
179
198
|
const mutation = topicMutation(change);
|
|
180
|
-
const base = { restore: change.action === "restore", changed: true, bytes: 0 };
|
|
181
199
|
switch (mutation.kind) {
|
|
182
200
|
case "publication":
|
|
183
|
-
return this.preparePublication(mutation
|
|
201
|
+
return this.preparePublication(mutation);
|
|
184
202
|
case "subscription":
|
|
185
203
|
return {
|
|
186
|
-
|
|
204
|
+
kind: "subscription",
|
|
205
|
+
restore: change.action === "restore",
|
|
187
206
|
subscription: mutation.subscription,
|
|
188
207
|
bytes: intercomMessageSizeBytes(mutation.subscription),
|
|
189
208
|
};
|
|
190
209
|
case "unsubscribe":
|
|
191
|
-
return {
|
|
210
|
+
return { kind: "unsubscribe", restore: false, topic: mutation.topic };
|
|
192
211
|
}
|
|
193
212
|
}
|
|
194
|
-
preparePublication(mutation
|
|
213
|
+
preparePublication(mutation) {
|
|
195
214
|
const previous = this.topics.get(mutation.topic.topic);
|
|
196
215
|
const update = publicationUpdate(mutation.topic, previous, mutation.publish);
|
|
197
216
|
if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
|
|
198
|
-
return
|
|
217
|
+
return { kind: "normalize", restore: true };
|
|
199
218
|
}
|
|
200
219
|
return {
|
|
201
|
-
...
|
|
220
|
+
...(mutation.publish
|
|
221
|
+
? { kind: "publish", restore: false }
|
|
222
|
+
: { kind: "restore-topic", restore: true }),
|
|
202
223
|
topic: update,
|
|
203
224
|
bytes: intercomMessageSizeBytes(update),
|
|
204
|
-
...(mutation.publish ? { publication: update } : {}),
|
|
205
225
|
};
|
|
206
226
|
}
|
|
207
227
|
snapshot(info, prepared = UNCHANGED) {
|
|
208
|
-
const
|
|
209
|
-
const
|
|
228
|
+
const topic = updatedTopic(prepared)?.topic;
|
|
229
|
+
const subscription = prepared.kind === "subscription" ? prepared.subscription : undefined;
|
|
230
|
+
const changed = prepared.kind !== "unchanged";
|
|
231
|
+
const topics = this.topics.valuesAfter(topic?.topic, topic, changed);
|
|
232
|
+
const subscriptions = this.subscriptions.valuesAfter(subscriptionKey(prepared), subscription, changed);
|
|
210
233
|
return {
|
|
211
234
|
...info,
|
|
212
235
|
...(topics !== undefined ? { topics } : {}),
|
|
213
236
|
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
214
|
-
...(prepared
|
|
237
|
+
...(preparedPublication(prepared) ? { lastIntercomActivity: Date.now() } : {}),
|
|
215
238
|
};
|
|
216
239
|
}
|
|
217
240
|
validateRestore(info, prepared, requestId) {
|
|
218
241
|
snapshotFrames(requestId, [{ ...info, topics: [], subscriptions: [] }], true);
|
|
219
|
-
const
|
|
220
|
-
const
|
|
242
|
+
const topic = updatedTopic(prepared);
|
|
243
|
+
const subscription = prepared.kind === "subscription" ? prepared : undefined;
|
|
244
|
+
const topicBytes = this.topics.maximumAfter(topic?.topic.topic, topic?.bytes ?? 0);
|
|
245
|
+
const subscriptionBytes = this.subscriptions.maximumAfter(subscription?.subscription.topic, subscription?.bytes ?? 0);
|
|
221
246
|
for (const [field, bytes] of [
|
|
222
247
|
["topics", topicBytes],
|
|
223
248
|
["subscriptions", subscriptionBytes],
|
|
@@ -234,18 +259,24 @@ export class TopicSnapshot {
|
|
|
234
259
|
}
|
|
235
260
|
}
|
|
236
261
|
commit(prepared) {
|
|
237
|
-
if (prepared.
|
|
238
|
-
|
|
239
|
-
this.subscriptions.accept();
|
|
240
|
-
}
|
|
241
|
-
if (prepared.topic) {
|
|
242
|
-
this.topics.set(prepared.topic, prepared.bytes);
|
|
243
|
-
}
|
|
244
|
-
else if (prepared.subscription) {
|
|
245
|
-
this.subscriptions.set(prepared.subscription, prepared.bytes);
|
|
262
|
+
if (prepared.kind === "unchanged") {
|
|
263
|
+
return;
|
|
246
264
|
}
|
|
247
|
-
|
|
248
|
-
|
|
265
|
+
this.topics.accept();
|
|
266
|
+
this.subscriptions.accept();
|
|
267
|
+
switch (prepared.kind) {
|
|
268
|
+
case "publish":
|
|
269
|
+
case "restore-topic":
|
|
270
|
+
this.topics.set(prepared.topic, prepared.bytes);
|
|
271
|
+
return;
|
|
272
|
+
case "subscription":
|
|
273
|
+
this.subscriptions.set(prepared.subscription, prepared.bytes);
|
|
274
|
+
return;
|
|
275
|
+
case "unsubscribe":
|
|
276
|
+
this.subscriptions.delete(prepared.topic);
|
|
277
|
+
return;
|
|
278
|
+
case "normalize":
|
|
279
|
+
return;
|
|
249
280
|
}
|
|
250
281
|
}
|
|
251
282
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { hasErrorCode } from "../../shared/unknown.js";
|
|
3
|
+
import { brokerPidRecord, isLinuxProcessReplaced } from "./pid.js";
|
|
4
|
+
function ownerState(fd) {
|
|
5
|
+
try {
|
|
6
|
+
const lines = readFileSync(fd, "utf8").split("\n");
|
|
7
|
+
const first = lines[0] ?? "";
|
|
8
|
+
const pid = Number(first);
|
|
9
|
+
if (!/^[1-9]\d*$/.test(first) || !Number.isSafeInteger(pid)) {
|
|
10
|
+
return "uncertain";
|
|
11
|
+
}
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return isLinuxProcessReplaced(pid, lines.slice(2).join("\n")) ? "exited" : "live";
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return hasErrorCode(error, "ESRCH") ? "exited" : "uncertain";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The open descriptor pins the original inode until its own cleanup finishes. */
|
|
20
|
+
function removeOwned(file, fd) {
|
|
21
|
+
try {
|
|
22
|
+
const owned = fstatSync(fd, { bigint: true });
|
|
23
|
+
const current = lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
24
|
+
if (current?.dev !== owned.dev || current.ino !== owned.ino) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
unlinkSync(file);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function reclaimExited(file) {
|
|
35
|
+
let fd;
|
|
36
|
+
try {
|
|
37
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return hasErrorCode(error, "ENOENT");
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return ownerState(fd) === "exited" && removeOwned(file, fd);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function publishClaim(file, fd) {
|
|
50
|
+
const release = () => {
|
|
51
|
+
try {
|
|
52
|
+
removeOwned(file, fd);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
// Keep legacy PID/timestamp lines; qualified native identity can prove later PID reuse.
|
|
60
|
+
const record = brokerPidRecord();
|
|
61
|
+
const identity = record.slice(record.indexOf("\n") + 1);
|
|
62
|
+
writeFileSync(fd, `${process.pid}\n${Date.now()}\n${identity}`);
|
|
63
|
+
return release;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
release();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** An acquired claim returns its release operation; live or uncertain claims remain untouched. */
|
|
71
|
+
export function acquireSpawnClaim(file) {
|
|
72
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
73
|
+
let fd;
|
|
74
|
+
try {
|
|
75
|
+
fd = openSync(file, "wx", 0o600);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (!hasErrorCode(error, "EEXIST")) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (reclaimExited(file)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
return publishClaim(file, fd);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
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";
|
|
6
6
|
import { getPiAgentDir } from "../agent-dir.js";
|
|
7
7
|
import { getBrokerRuntimeDir, getBrokerSocketPath, getPreviousBrokerSocketPaths, isOwnedBrokerSocket, prepareBrokerSocketPath, } from "./paths.js";
|
|
8
8
|
import { isBrokerPidReused } from "./pid.js";
|
|
9
|
+
import { acquireSpawnClaim } from "./spawn-claim.js";
|
|
9
10
|
import { errorMessage, hasErrorCode } from "../../shared/unknown.js";
|
|
10
11
|
const EXTENSION_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
11
12
|
const BROKER_PID = join(getBrokerRuntimeDir(), "broker.pid");
|
|
@@ -54,8 +55,8 @@ export async function spawnBrokerIfNeeded(brokerCommand, brokerArgs, warning = (
|
|
|
54
55
|
if (await isBrokerRunning()) {
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
58
|
+
const release = acquireSpawnClaim(BROKER_SPAWN_LOCK);
|
|
59
|
+
if (release === undefined) {
|
|
59
60
|
await waitForBroker();
|
|
60
61
|
return;
|
|
61
62
|
}
|
|
@@ -99,7 +100,7 @@ export async function spawnBrokerIfNeeded(brokerCommand, brokerArgs, warning = (
|
|
|
99
100
|
});
|
|
100
101
|
}
|
|
101
102
|
finally {
|
|
102
|
-
|
|
103
|
+
release();
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
export async function isBrokerRunning() {
|
|
@@ -111,28 +112,30 @@ export async function assertBrokerCanStart() {
|
|
|
111
112
|
}
|
|
112
113
|
await stopUnhealthyBrokerBeforeSpawn();
|
|
113
114
|
}
|
|
114
|
-
function
|
|
115
|
-
if (!existsSync(pidPath)) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
115
|
+
function readBrokerOwner(pidPath) {
|
|
118
116
|
let raw;
|
|
119
117
|
try {
|
|
120
118
|
raw = readFileSync(pidPath, "utf-8");
|
|
121
119
|
}
|
|
122
|
-
catch {
|
|
123
|
-
return
|
|
120
|
+
catch (error) {
|
|
121
|
+
return hasErrorCode(error, "ENOENT")
|
|
122
|
+
? { kind: "absent" }
|
|
123
|
+
: { kind: "uncertain", reason: `Cannot read broker PID record: ${errorMessage(error)}` };
|
|
124
124
|
}
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
const first = raw.trim().split("\n")[0] ?? "";
|
|
126
|
+
const pid = Number(first);
|
|
127
|
+
if (!/^[1-9]\d*$/.test(first) || !Number.isSafeInteger(pid)) {
|
|
128
|
+
return { kind: "uncertain", reason: "Broker PID record is invalid" };
|
|
128
129
|
}
|
|
129
130
|
try {
|
|
130
131
|
process.kill(pid, 0);
|
|
131
132
|
}
|
|
132
133
|
catch (error) {
|
|
133
|
-
return hasErrorCode(error, "
|
|
134
|
+
return hasErrorCode(error, "ESRCH")
|
|
135
|
+
? { kind: "absent" }
|
|
136
|
+
: { kind: "uncertain", reason: `Cannot establish broker PID ${pid}: ${errorMessage(error)}` };
|
|
134
137
|
}
|
|
135
|
-
return isBrokerPidReused(pid, raw) ?
|
|
138
|
+
return isBrokerPidReused(pid, raw) ? { kind: "absent" } : { kind: "live", pid };
|
|
136
139
|
}
|
|
137
140
|
async function previousBrokerWarning() {
|
|
138
141
|
const previousSockets = getPreviousBrokerSocketPaths();
|
|
@@ -142,9 +145,10 @@ async function previousBrokerWarning() {
|
|
|
142
145
|
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.";
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
+
const previous = readBrokerOwner(join(getPiAgentDir(), "intercom", "broker.pid"));
|
|
149
|
+
if (previous.kind !== "absent") {
|
|
150
|
+
const owner = previous.kind === "live" ? `PID ${previous.pid}` : "with uncertain PID identity";
|
|
151
|
+
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.`;
|
|
148
152
|
}
|
|
149
153
|
return undefined;
|
|
150
154
|
}
|
|
@@ -152,11 +156,15 @@ export async function stopUnhealthyBrokerBeforeSpawn() {
|
|
|
152
156
|
if (await checkSocketConnectable()) {
|
|
153
157
|
return;
|
|
154
158
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
159
|
+
const owner = readBrokerOwner(BROKER_PID);
|
|
160
|
+
switch (owner.kind) {
|
|
161
|
+
case "absent":
|
|
162
|
+
return;
|
|
163
|
+
case "live":
|
|
164
|
+
throw new Error(`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.`);
|
|
165
|
+
case "uncertain":
|
|
166
|
+
throw new Error(`${owner.reason}; refusing to spawn a second broker while ownership is uncertain.`);
|
|
158
167
|
}
|
|
159
|
-
throw new Error(`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.`);
|
|
160
168
|
}
|
|
161
169
|
async function checkSocketConnectable() {
|
|
162
170
|
const socketPath = getBrokerSocketPath();
|
|
@@ -180,61 +188,6 @@ function checkOneSocket(brokerSocket) {
|
|
|
180
188
|
timeout.unref();
|
|
181
189
|
});
|
|
182
190
|
}
|
|
183
|
-
function acquireSpawnLock() {
|
|
184
|
-
const maxRetries = 5;
|
|
185
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
186
|
-
try {
|
|
187
|
-
writeFileSync(BROKER_SPAWN_LOCK, `${process.pid}\n${Date.now()}\n`, { flag: "wx" });
|
|
188
|
-
return true;
|
|
189
|
-
}
|
|
190
|
-
catch (error) {
|
|
191
|
-
if (!hasErrorCode(error, "EEXIST")) {
|
|
192
|
-
throw error;
|
|
193
|
-
}
|
|
194
|
-
if (isSpawnLockStale()) {
|
|
195
|
-
releaseSpawnLock();
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
function isSpawnLockStale() {
|
|
204
|
-
if (!existsSync(BROKER_SPAWN_LOCK)) {
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
207
|
-
try {
|
|
208
|
-
const [pidLine = "", createdAtLine = "0"] = readFileSync(BROKER_SPAWN_LOCK, "utf-8")
|
|
209
|
-
.trim()
|
|
210
|
-
.split("\n");
|
|
211
|
-
const pid = Number.parseInt(pidLine, 10);
|
|
212
|
-
const createdAt = Number.parseInt(createdAtLine, 10);
|
|
213
|
-
const ageMs = Date.now() - createdAt;
|
|
214
|
-
if (Number.isFinite(pid)) {
|
|
215
|
-
try {
|
|
216
|
-
process.kill(pid, 0);
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
// The process that created the lock is gone.
|
|
220
|
-
return true;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return !Number.isFinite(createdAt) || ageMs > 10_000;
|
|
224
|
-
}
|
|
225
|
-
catch {
|
|
226
|
-
// Unreadable lock contents are treated as stale so a new broker can start.
|
|
227
|
-
return true;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
function releaseSpawnLock() {
|
|
231
|
-
try {
|
|
232
|
-
unlinkSync(BROKER_SPAWN_LOCK);
|
|
233
|
-
}
|
|
234
|
-
catch {
|
|
235
|
-
// Another cleanup path may already have removed the lock.
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
191
|
async function waitForBroker(timeoutMs = 5000) {
|
|
239
192
|
const start = Date.now();
|
|
240
193
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -245,5 +198,5 @@ async function waitForBroker(timeoutMs = 5000) {
|
|
|
245
198
|
// Polling waits between probes rather than starting an unbounded batch.
|
|
246
199
|
await sleep(100);
|
|
247
200
|
}
|
|
248
|
-
throw new Error(
|
|
201
|
+
throw new Error(`Broker failed to start within timeout. Startup claim: ${BROKER_SPAWN_LOCK}. Live or uncertain owners are not reclaimed.`);
|
|
249
202
|
}
|
|
@@ -8,12 +8,8 @@ export function isContextRequest(value) {
|
|
|
8
8
|
}
|
|
9
9
|
/** Owns native context validity and occupancy, never journal or socket state. */
|
|
10
10
|
export class IntercomLifecycle {
|
|
11
|
-
|
|
12
|
-
sessionId = null;
|
|
11
|
+
state = { kind: "inactive", epoch: 0 };
|
|
13
12
|
model = "unknown";
|
|
14
|
-
disposed = true;
|
|
15
|
-
epoch = 0;
|
|
16
|
-
incarnation = null;
|
|
17
13
|
peerId = null;
|
|
18
14
|
guardedDelivery = false;
|
|
19
15
|
applicationGuard;
|
|
@@ -29,10 +25,10 @@ export class IntercomLifecycle {
|
|
|
29
25
|
this.lineage = lineage;
|
|
30
26
|
}
|
|
31
27
|
get generation() {
|
|
32
|
-
return this.epoch;
|
|
28
|
+
return this.state.epoch;
|
|
33
29
|
}
|
|
34
30
|
get currentSessionId() {
|
|
35
|
-
return this.sessionId;
|
|
31
|
+
return this.state.kind === "inactive" ? null : this.state.sessionId;
|
|
36
32
|
}
|
|
37
33
|
get toolCount() {
|
|
38
34
|
return this.tools.size;
|
|
@@ -64,7 +60,7 @@ export class IntercomLifecycle {
|
|
|
64
60
|
request.respond(this.snapshot());
|
|
65
61
|
}
|
|
66
62
|
snapshot() {
|
|
67
|
-
if (!this.live()) {
|
|
63
|
+
if (this.state.kind !== "live" || !this.live()) {
|
|
68
64
|
return {
|
|
69
65
|
version: 1,
|
|
70
66
|
rootSessionId: null,
|
|
@@ -76,10 +72,10 @@ export class IntercomLifecycle {
|
|
|
76
72
|
}
|
|
77
73
|
return {
|
|
78
74
|
version: 1,
|
|
79
|
-
rootSessionId: this.lineage?.rootSessionId ?? this.sessionId,
|
|
75
|
+
rootSessionId: this.lineage?.rootSessionId ?? this.state.sessionId,
|
|
80
76
|
parentSessionId: this.lineage?.parentSessionId,
|
|
81
77
|
peerId: this.peerId,
|
|
82
|
-
incarnation: this.incarnation,
|
|
78
|
+
incarnation: this.state.incarnation,
|
|
83
79
|
connected: this.peerId !== null,
|
|
84
80
|
guardedDelivery: this.guardedDelivery ? 1 : null,
|
|
85
81
|
applicationGuard: this.applicationGuard,
|
|
@@ -107,36 +103,40 @@ export class IntercomLifecycle {
|
|
|
107
103
|
this.applicationGuard === guard.applicationGuard);
|
|
108
104
|
}
|
|
109
105
|
start(ctx) {
|
|
110
|
-
this.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
106
|
+
this.state = {
|
|
107
|
+
kind: "live",
|
|
108
|
+
epoch: this.state.epoch + 1,
|
|
109
|
+
context: ctx,
|
|
110
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
111
|
+
incarnation: randomUUID(),
|
|
112
|
+
};
|
|
115
113
|
this.applicationGuard = undefined;
|
|
116
114
|
this.model = ctx.model?.id ?? "unknown";
|
|
117
115
|
this.running = false;
|
|
118
116
|
this.activity = 0;
|
|
119
117
|
this.tools.clear();
|
|
120
118
|
this.transport(null);
|
|
121
|
-
return this.epoch;
|
|
119
|
+
return this.state.epoch;
|
|
122
120
|
}
|
|
123
121
|
invalidate() {
|
|
124
|
-
this.
|
|
125
|
-
this.
|
|
122
|
+
const epoch = this.state.epoch + 1;
|
|
123
|
+
this.state =
|
|
124
|
+
this.state.kind === "inactive"
|
|
125
|
+
? { kind: "inactive", epoch }
|
|
126
|
+
: { kind: "invalidated", epoch, sessionId: this.state.sessionId };
|
|
126
127
|
this.running = false;
|
|
127
128
|
this.tools.clear();
|
|
128
129
|
this.publishContext();
|
|
129
130
|
}
|
|
130
131
|
clear() {
|
|
131
|
-
this.
|
|
132
|
-
this.sessionId = null;
|
|
132
|
+
this.state = { kind: "inactive", epoch: this.state.epoch };
|
|
133
133
|
}
|
|
134
|
-
live(ctx = this.context, generation = this.epoch) {
|
|
135
|
-
if (this.
|
|
134
|
+
live(ctx = this.state.kind === "live" ? this.state.context : null, generation = this.state.epoch) {
|
|
135
|
+
if (this.state.kind !== "live" || generation !== this.state.epoch || !ctx) {
|
|
136
136
|
return null;
|
|
137
137
|
}
|
|
138
138
|
try {
|
|
139
|
-
if (
|
|
139
|
+
if (ctx.sessionManager.getSessionId() !== this.state.sessionId) {
|
|
140
140
|
return null;
|
|
141
141
|
}
|
|
142
142
|
// Reading a native property detects invalidated contexts without modifying them.
|
|
@@ -209,12 +209,12 @@ export class IntercomLifecycle {
|
|
|
209
209
|
}
|
|
210
210
|
async registration(pendingAsks) {
|
|
211
211
|
const live = this.live();
|
|
212
|
-
if (!live || this.
|
|
212
|
+
if (!live || this.state.kind !== "live") {
|
|
213
213
|
throw new Error("Intercom runtime not initialized");
|
|
214
214
|
}
|
|
215
215
|
return {
|
|
216
|
-
...this.identity(this.sessionId),
|
|
217
|
-
incarnation: this.incarnation
|
|
216
|
+
...this.identity(this.state.sessionId),
|
|
217
|
+
incarnation: this.state.incarnation,
|
|
218
218
|
guardedDelivery: 1,
|
|
219
219
|
applicationGuard: this.applicationGuard,
|
|
220
220
|
cwd: live.cwd,
|