@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -0
  3. package/dist/agent/host.js +12 -0
  4. package/dist/agent/index.js +70 -56
  5. package/dist/agent/launch.js +12 -3
  6. package/dist/agent/notifications.js +5 -5
  7. package/dist/agent/parking.js +26 -15
  8. package/dist/agent/runner.js +18 -16
  9. package/dist/agent/tool-schemas.js +2 -0
  10. package/dist/agent/usage.js +42 -3
  11. package/dist/agent/view.js +13 -13
  12. package/dist/agent/wake.js +17 -15
  13. package/dist/build-info.js +1 -1
  14. package/dist/pi-intercom/broker/broker.js +2 -2
  15. package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
  16. package/dist/pi-intercom/broker/snapshots.js +56 -25
  17. package/dist/pi-intercom/broker/spawn-claim.js +89 -0
  18. package/dist/pi-intercom/broker/spawn.js +31 -78
  19. package/dist/pi-intercom/lifecycle.js +26 -26
  20. package/dist/shared/post-exit-stdio-guard.js +44 -37
  21. package/docs/development.md +25 -0
  22. package/docs/intercom.md +13 -0
  23. package/docs/reference.md +16 -5
  24. package/package.json +1 -1
  25. package/scripts/build.mjs +41 -14
  26. package/skills/pi-subagents/SKILL.md +8 -4
  27. package/src/agent/contracts.ts +17 -0
  28. package/src/agent/host.ts +19 -0
  29. package/src/agent/index.ts +91 -62
  30. package/src/agent/launch.ts +16 -3
  31. package/src/agent/notifications.ts +5 -5
  32. package/src/agent/parking.ts +39 -17
  33. package/src/agent/runner.ts +20 -21
  34. package/src/agent/tool-schemas.ts +2 -0
  35. package/src/agent/usage.ts +48 -3
  36. package/src/agent/view.ts +15 -13
  37. package/src/agent/wake.ts +19 -15
  38. package/src/pi-intercom/broker/broker.ts +2 -1
  39. package/src/pi-intercom/broker/snapshots.ts +82 -37
  40. package/src/pi-intercom/broker/spawn-claim.ts +98 -0
  41. package/src/pi-intercom/broker/spawn.ts +42 -82
  42. package/src/pi-intercom/lifecycle.ts +38 -27
  43. package/src/shared/post-exit-stdio-guard.ts +48 -40
package/src/agent/view.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  import type { RunsInput } from "./tool-schemas.ts";
16
16
  import { conversation, read, runDirectory } from "./storage.ts";
17
17
  import { published } from "./notifications.ts";
18
- import { readCanonicalResult, readResult } from "./usage.ts";
18
+ import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.ts";
19
19
  import { Type } from "./typebox.ts";
20
20
 
21
21
  const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
@@ -72,18 +72,20 @@ function toolResult(value: unknown, text: string): ToolReply {
72
72
  return { content: [{ type: "text" as const, text }], details: value };
73
73
  }
74
74
  function receiptResult(result: Result | undefined) {
75
- return result === undefined
76
- ? undefined
77
- : {
78
- id: result.id,
79
- owner: result.owner,
80
- status: result.status,
81
- endedAt: result.endedAt,
82
- terminalEntryId: result.terminalEntryId,
83
- error: result.error,
84
- accountingError: result.accountingError,
85
- accountingPending: result.accountingPending,
86
- };
75
+ if (result === undefined) {
76
+ return;
77
+ }
78
+ const { contributions: _contributions, ...accounting } = accountingFields(
79
+ accountingState(result),
80
+ );
81
+ return {
82
+ id: result.id,
83
+ owner: result.owner,
84
+ status: result.status,
85
+ endedAt: result.endedAt,
86
+ error: result.error,
87
+ ...accounting,
88
+ };
87
89
  }
88
90
  /** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
89
91
  function processView(base: string, launch: Launch, observed?: ReadonlyMap<string, boolean>) {
package/src/agent/wake.ts 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.ts";
3
4
  import { missing } from "./storage.ts";
4
5
 
@@ -21,10 +22,12 @@ export type WatchState = "unwatched" | "armed" | "changed" | "watching";
21
22
 
22
23
  /** Native change hints for one directory identity; owners still reconcile canonical files. */
23
24
  export class DirectoryWatch {
24
- private watcher?: fs.FSWatcher | undefined;
25
- private armed = "";
25
+ private state:
26
+ | { readonly kind: "unarmed" | "polling" }
27
+ | { readonly kind: "armed"; readonly watcher: fs.FSWatcher; readonly identity: string } = {
28
+ kind: "unarmed",
29
+ };
26
30
  private checked = "";
27
- private failed = false;
28
31
  private readonly directory: string;
29
32
  private readonly relevant: (file: string | null) => boolean;
30
33
  private readonly wake: () => void;
@@ -38,40 +41,40 @@ export class DirectoryWatch {
38
41
  const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
39
42
  const checked = this.checked;
40
43
  this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
41
- if (this.watcher !== undefined && identity === this.armed) {
44
+ if (this.state.kind === "armed" && identity === this.state.identity) {
42
45
  return checked === this.checked ? "watching" : "changed";
43
46
  }
44
47
  // A replaced directory leaves an inode watch silent on Linux.
45
48
  this.close();
46
- return identity === "" || this.failed ? "unwatched" : this.arm(identity);
49
+ return identity === "" || this.state.kind === "polling" ? "unwatched" : this.arm(identity);
47
50
  }
48
51
  private arm(identity: string): WatchState {
49
52
  try {
50
53
  const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
51
- if (watcher === this.watcher && this.relevant(file)) {
54
+ if (this.state.kind === "armed" && watcher === this.state.watcher && this.relevant(file)) {
52
55
  this.wake();
53
56
  }
54
57
  });
55
58
  watcher.on("error", () => {
56
- if (watcher === this.watcher) {
57
- this.failed = true;
58
- this.close();
59
+ if (this.state.kind === "armed" && watcher === this.state.watcher) {
60
+ watcher.close();
61
+ this.state = { kind: "polling" };
59
62
  this.wake();
60
63
  }
61
64
  });
62
- this.watcher = watcher;
63
- this.armed = identity;
65
+ this.state = { kind: "armed", watcher, identity };
64
66
  return "armed";
65
67
  } catch (error) {
66
68
  // Unsupported platforms and watch limits keep their owner's polling fallback.
67
- this.failed = !missing(error);
69
+ this.state = { kind: missing(error) ? "unarmed" : "polling" };
68
70
  return "unwatched";
69
71
  }
70
72
  }
71
73
  close(): void {
72
- this.watcher?.close();
73
- this.watcher = undefined;
74
- this.armed = "";
74
+ if (this.state.kind === "armed") {
75
+ this.state.watcher.close();
76
+ this.state = { kind: "unarmed" };
77
+ }
75
78
  }
76
79
  }
77
80
 
@@ -90,6 +93,7 @@ export class Wake {
90
93
  }
91
94
  /** Returns at once for a wake raised since the last take. */
92
95
  async sleep(ms: number): Promise<void> {
96
+ assert.equal(this.resolve, undefined, "A Wake belongs to one waiting owner loop.");
93
97
  if (this.raised) {
94
98
  return;
95
99
  }
@@ -29,6 +29,7 @@ import {
29
29
  snapshotFrames,
30
30
  TopicSnapshot,
31
31
  publicationMessage,
32
+ preparedPublication,
32
33
  deliveryAcknowledgement,
33
34
  shouldReplaceDelivery,
34
35
  } from "./snapshots.ts";
@@ -245,7 +246,7 @@ class IntercomBroker {
245
246
  ? session.info
246
247
  : session.topics.snapshot(session.info, prepared);
247
248
  const from = messageSender(nextInfo);
248
- const deliveries = this.planPublications(currentId, from, prepared.publication);
249
+ const deliveries = this.planPublications(currentId, from, preparedPublication(prepared));
249
250
  // Validate every frame before committing state or delivering publications.
250
251
  if (prepared.restore) {
251
252
  session.topics.validateRestore(session.info, prepared, requestId);
@@ -202,17 +202,52 @@ class SnapshotRecords<T extends { readonly topic: string }> {
202
202
  }
203
203
  }
204
204
 
205
- interface PreparedTopicChange {
206
- readonly topic?: TopicUpdate;
207
- readonly subscription?: TopicSubscription;
208
- readonly unsubscribe?: string;
209
- readonly publication?: TopicUpdate;
210
- readonly restore: boolean;
211
- readonly changed: boolean;
212
- readonly bytes: number;
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
- const UNCHANGED: PreparedTopicChange = { restore: false, changed: false, bytes: 0 };
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, base);
276
+ return this.preparePublication(mutation);
243
277
  case "subscription":
244
278
  return {
245
- ...base,
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 { ...base, unsubscribe: mutation.topic };
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 base;
295
+ return { kind: "normalize", restore: true };
262
296
  }
263
297
  return {
264
- ...base,
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 topics = this.topics.valuesAfter(prepared.topic?.topic, prepared.topic, prepared.changed);
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.subscription?.topic ?? prepared.unsubscribe,
275
- prepared.subscription,
276
- prepared.changed,
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.publication ? { lastIntercomActivity: Date.now() } : {}),
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 topicBytes = this.topics.maximumAfter(
289
- prepared.topic?.topic,
290
- prepared.topic ? prepared.bytes : 0,
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
- prepared.subscription?.topic,
294
- prepared.subscription ? prepared.bytes : 0,
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.changed) {
315
- this.topics.accept();
316
- this.subscriptions.accept();
351
+ if (prepared.kind === "unchanged") {
352
+ return;
317
353
  }
318
- if (prepared.topic) {
319
- this.topics.set(prepared.topic, prepared.bytes);
320
- } else if (prepared.subscription) {
321
- this.subscriptions.set(prepared.subscription, prepared.bytes);
322
- } else if (prepared.unsubscribe !== undefined) {
323
- this.subscriptions.delete(prepared.unsubscribe);
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 { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
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 ownsLock = acquireSpawnLock();
89
- if (!ownsLock) {
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
- releaseSpawnLock();
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
- function readLivePid(pidPath: string): number | null {
157
- if (!existsSync(pidPath)) {
158
- return null;
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 null;
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 pid = parseInt(raw.trim(), 10);
167
- if (!Number.isFinite(pid) || pid <= 0) {
168
- return null;
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, "EPERM") ? pid : null;
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) ? null : pid;
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 previousPid = readLivePid(join(getPiAgentDir(), "intercom", "broker.pid"));
187
- if (previousPid !== null) {
188
- return `Older Intercom broker PID ${previousPid} may still own accepted work. Its peers need to reload with the updated extension to join shared same-user discovery. Nothing was stopped or replaced; no legacy fallback is used.`;
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 pid = readLivePid(BROKER_PID);
198
- if (pid === null) {
199
- return;
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("Broker failed to start within timeout");
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
  }