@fitchmultz/pi-subagents 0.52.18 → 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 +34 -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/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/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/lifecycle.ts +38 -27
- package/src/shared/post-exit-stdio-guard.ts +48 -40
package/dist/agent/usage.js
CHANGED
|
@@ -7,6 +7,37 @@ import { isRecord } from "../shared/unknown.js";
|
|
|
7
7
|
import { Assert } from "./typebox.js";
|
|
8
8
|
import { contributionsSchema, resultSchema, } from "./contracts.js";
|
|
9
9
|
import { read, runDirectory, write } from "./storage.js";
|
|
10
|
+
/** Pending repair remains authoritative until its native terminal is published and validated. */
|
|
11
|
+
export function accountingState(result) {
|
|
12
|
+
if (result?.accountingPending !== undefined) {
|
|
13
|
+
return { kind: "pending", pending: result.accountingPending, error: result.accountingError };
|
|
14
|
+
}
|
|
15
|
+
const terminalEntryId = result?.terminalEntryId;
|
|
16
|
+
if (result?.contributions !== undefined) {
|
|
17
|
+
return { kind: "verified", contributions: result.contributions, terminalEntryId };
|
|
18
|
+
}
|
|
19
|
+
return result?.accountingError === undefined
|
|
20
|
+
? { kind: "unrecorded", terminalEntryId }
|
|
21
|
+
: { kind: "unavailable", error: result.accountingError, terminalEntryId };
|
|
22
|
+
}
|
|
23
|
+
export function accountingFields(state) {
|
|
24
|
+
const terminal = state.kind !== "pending" && state.terminalEntryId !== undefined
|
|
25
|
+
? { terminalEntryId: state.terminalEntryId }
|
|
26
|
+
: {};
|
|
27
|
+
switch (state.kind) {
|
|
28
|
+
case "unrecorded":
|
|
29
|
+
return terminal;
|
|
30
|
+
case "verified":
|
|
31
|
+
return { contributions: state.contributions, ...terminal };
|
|
32
|
+
case "pending":
|
|
33
|
+
return {
|
|
34
|
+
accountingPending: state.pending,
|
|
35
|
+
...(state.error === undefined ? {} : { accountingError: state.error }),
|
|
36
|
+
};
|
|
37
|
+
case "unavailable":
|
|
38
|
+
return { accountingError: state.error, ...terminal };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
10
41
|
/** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
|
|
11
42
|
function billing(entry) {
|
|
12
43
|
if (entry.checkpoint === true) {
|
|
@@ -143,10 +174,11 @@ export function readResult(base, launch) {
|
|
|
143
174
|
if (!result) {
|
|
144
175
|
return;
|
|
145
176
|
}
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
177
|
+
const accounting = accountingState(result);
|
|
178
|
+
if (accounting.kind !== "pending") {
|
|
148
179
|
return result;
|
|
149
180
|
}
|
|
181
|
+
const { pending } = accounting;
|
|
150
182
|
let contributions;
|
|
151
183
|
try {
|
|
152
184
|
contributions = childUsage(launch, new Set(pending.baseline), pending.terminalCandidate);
|
|
@@ -156,7 +188,14 @@ export function readResult(base, launch) {
|
|
|
156
188
|
return result;
|
|
157
189
|
}
|
|
158
190
|
const { accountingPending: _pending, accountingError: _error, ...execution } = result;
|
|
159
|
-
const repaired = {
|
|
191
|
+
const repaired = {
|
|
192
|
+
...execution,
|
|
193
|
+
...accountingFields({
|
|
194
|
+
kind: "verified",
|
|
195
|
+
terminalEntryId: pending.terminalCandidate,
|
|
196
|
+
contributions,
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
160
199
|
write(path.join(runDirectory(base, launch.id), "result.json"), repaired);
|
|
161
200
|
return repaired;
|
|
162
201
|
}
|
package/dist/agent/view.js
CHANGED
|
@@ -5,25 +5,25 @@ import { actor, alive, observeAlive } from "./process.js";
|
|
|
5
5
|
import { admissionSchema, commandSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
|
|
6
6
|
import { conversation, read, runDirectory } from "./storage.js";
|
|
7
7
|
import { published } from "./notifications.js";
|
|
8
|
-
import { readCanonicalResult, readResult } from "./usage.js";
|
|
8
|
+
import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.js";
|
|
9
9
|
import { Type } from "./typebox.js";
|
|
10
10
|
const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
|
|
11
11
|
function toolResult(value, text) {
|
|
12
12
|
return { content: [{ type: "text", text }], details: value };
|
|
13
13
|
}
|
|
14
14
|
function receiptResult(result) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
if (result === undefined) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const { contributions: _contributions, ...accounting } = accountingFields(accountingState(result));
|
|
19
|
+
return {
|
|
20
|
+
id: result.id,
|
|
21
|
+
owner: result.owner,
|
|
22
|
+
status: result.status,
|
|
23
|
+
endedAt: result.endedAt,
|
|
24
|
+
error: result.error,
|
|
25
|
+
...accounting,
|
|
26
|
+
};
|
|
27
27
|
}
|
|
28
28
|
/** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
|
|
29
29
|
function processView(base, launch, observed) {
|
package/dist/agent/wake.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
2
3
|
import { journalStamp } from "../shared/native-journal.js";
|
|
3
4
|
import { missing } from "./storage.js";
|
|
4
5
|
/** Identity and change stamp of a path; "" when absent. */
|
|
@@ -13,10 +14,10 @@ export function pathStamp(file) {
|
|
|
13
14
|
}
|
|
14
15
|
/** Native change hints for one directory identity; owners still reconcile canonical files. */
|
|
15
16
|
export class DirectoryWatch {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
state = {
|
|
18
|
+
kind: "unarmed",
|
|
19
|
+
};
|
|
18
20
|
checked = "";
|
|
19
|
-
failed = false;
|
|
20
21
|
directory;
|
|
21
22
|
relevant;
|
|
22
23
|
wake;
|
|
@@ -30,41 +31,41 @@ export class DirectoryWatch {
|
|
|
30
31
|
const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
|
|
31
32
|
const checked = this.checked;
|
|
32
33
|
this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
33
|
-
if (this.
|
|
34
|
+
if (this.state.kind === "armed" && identity === this.state.identity) {
|
|
34
35
|
return checked === this.checked ? "watching" : "changed";
|
|
35
36
|
}
|
|
36
37
|
// A replaced directory leaves an inode watch silent on Linux.
|
|
37
38
|
this.close();
|
|
38
|
-
return identity === "" || this.
|
|
39
|
+
return identity === "" || this.state.kind === "polling" ? "unwatched" : this.arm(identity);
|
|
39
40
|
}
|
|
40
41
|
arm(identity) {
|
|
41
42
|
try {
|
|
42
43
|
const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
|
|
43
|
-
if (watcher === this.watcher && this.relevant(file)) {
|
|
44
|
+
if (this.state.kind === "armed" && watcher === this.state.watcher && this.relevant(file)) {
|
|
44
45
|
this.wake();
|
|
45
46
|
}
|
|
46
47
|
});
|
|
47
48
|
watcher.on("error", () => {
|
|
48
|
-
if (watcher === this.watcher) {
|
|
49
|
-
|
|
50
|
-
this.
|
|
49
|
+
if (this.state.kind === "armed" && watcher === this.state.watcher) {
|
|
50
|
+
watcher.close();
|
|
51
|
+
this.state = { kind: "polling" };
|
|
51
52
|
this.wake();
|
|
52
53
|
}
|
|
53
54
|
});
|
|
54
|
-
this.
|
|
55
|
-
this.armed = identity;
|
|
55
|
+
this.state = { kind: "armed", watcher, identity };
|
|
56
56
|
return "armed";
|
|
57
57
|
}
|
|
58
58
|
catch (error) {
|
|
59
59
|
// Unsupported platforms and watch limits keep their owner's polling fallback.
|
|
60
|
-
this.
|
|
60
|
+
this.state = { kind: missing(error) ? "unarmed" : "polling" };
|
|
61
61
|
return "unwatched";
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
close() {
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
if (this.state.kind === "armed") {
|
|
66
|
+
this.state.watcher.close();
|
|
67
|
+
this.state = { kind: "unarmed" };
|
|
68
|
+
}
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
/** A coalesced wake for one owner loop; raised until that loop takes it. */
|
|
@@ -82,6 +83,7 @@ export class Wake {
|
|
|
82
83
|
}
|
|
83
84
|
/** Returns at once for a wake raised since the last take. */
|
|
84
85
|
async sleep(ms) {
|
|
86
|
+
assert.equal(this.resolve, undefined, "A Wake belongs to one waiting owner loop.");
|
|
85
87
|
if (this.raised) {
|
|
86
88
|
return;
|
|
87
89
|
}
|
package/dist/build-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.19","sha256":"a5cb823f7d9194659bb3c3e53c68e90032ef520a962758d896dccee78a3bd2c5"});
|
|
@@ -13,7 +13,7 @@ import { acceptBrokerConnection, registeredSessionInfo } from "./server-connecti
|
|
|
13
13
|
import { guardedDeliveryError } from "./guard.js";
|
|
14
14
|
import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
|
|
15
15
|
import { errorMessage, isRecord } from "../../shared/unknown.js";
|
|
16
|
-
import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
|
|
16
|
+
import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, preparedPublication, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
|
|
17
17
|
const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
|
|
18
18
|
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
19
19
|
class IntercomBroker {
|
|
@@ -192,7 +192,7 @@ class IntercomBroker {
|
|
|
192
192
|
? session.info
|
|
193
193
|
: session.topics.snapshot(session.info, prepared);
|
|
194
194
|
const from = messageSender(nextInfo);
|
|
195
|
-
const deliveries = this.planPublications(currentId, from, prepared
|
|
195
|
+
const deliveries = this.planPublications(currentId, from, preparedPublication(prepared));
|
|
196
196
|
// Validate every frame before committing state or delivering publications.
|
|
197
197
|
if (prepared.restore) {
|
|
198
198
|
session.topics.validateRestore(session.info, prepared, requestId);
|
|
@@ -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
|
}
|