@pinet/broker-core 0.2.2 → 0.2.6

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/dist/leader.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as crypto from "node:crypto";
1
3
  import * as fs from "node:fs";
2
4
  import * as path from "node:path";
3
5
  import * as os from "node:os";
@@ -5,17 +7,225 @@ export function defaultLockPath() {
5
7
  return path.join(os.homedir(), ".pi", "pinet-broker.lock");
6
8
  }
7
9
  /**
8
- * Leader election via PID lock file.
10
+ * Deterministically capture a process's start time for PID-reuse detection.
9
11
  *
10
- * Only one broker process should run at a time. The leader writes its
11
- * PID to the lock file. Stale locks (PID no longer running) are
12
- * automatically reclaimed.
12
+ * Uses `/proc/<pid>/stat` (field 22, clock ticks since boot) on Linux and
13
+ * `LC_ALL=C ps -p <pid> -o lstart=` elsewhere. Values are only ever compared
14
+ * for exact equality against a value captured by this same function, so the
15
+ * format does not need to be parseable — only stable for a given process.
16
+ *
17
+ * On Linux the tick count is only unique within a single boot, so it is
18
+ * scoped with the kernel boot id: a PID reused after a reboot can then never
19
+ * present the same identity as the pre-reboot owner. `ps lstart` has
20
+ * one-second resolution, so a PID reused within the same wall-clock second
21
+ * as the original process is indistinguishable — an accepted residual risk.
22
+ *
23
+ * Returns null when the start time cannot be determined; callers must treat
24
+ * null as "unknown" and never use it as evidence of staleness.
25
+ */
26
+ export function getProcessStartTime(pid) {
27
+ if (!Number.isInteger(pid) || pid <= 0)
28
+ return null;
29
+ if (process.platform === "linux") {
30
+ try {
31
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
32
+ // comm (field 2) may contain spaces/parens — start after the last ")".
33
+ const closeParen = stat.lastIndexOf(")");
34
+ if (closeParen !== -1) {
35
+ const fields = stat
36
+ .slice(closeParen + 1)
37
+ .trim()
38
+ .split(/\s+/);
39
+ // Fields after comm+state start at index 1 here; starttime is overall
40
+ // field 22, i.e. index 19 of the post-state remainder.
41
+ const startTime = fields[19];
42
+ if (startTime && /^\d+$/.test(startTime)) {
43
+ // starttime is measured in clock ticks since boot — scope it with
44
+ // the boot id so identities never collide across reboots.
45
+ let bootId = "";
46
+ try {
47
+ bootId = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim();
48
+ }
49
+ catch {
50
+ /* boot id unavailable — token stays valid within this boot */
51
+ }
52
+ return bootId ? `${bootId}:${startTime}` : startTime;
53
+ }
54
+ }
55
+ }
56
+ catch {
57
+ /* fall through to ps */
58
+ }
59
+ }
60
+ try {
61
+ const output = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
62
+ encoding: "utf-8",
63
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
64
+ timeout: 2000,
65
+ }).trim();
66
+ return output || null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ function readMetadataString(value) {
73
+ return typeof value === "string" && value.trim() ? value : null;
74
+ }
75
+ function parseLockContent(content) {
76
+ const trimmed = content.trim();
77
+ if (!trimmed)
78
+ return null;
79
+ const newlineIndex = trimmed.indexOf("\n");
80
+ const pidLine = (newlineIndex === -1 ? trimmed : trimmed.slice(0, newlineIndex)).trim();
81
+ if (!/^\d+$/.test(pidLine))
82
+ return null;
83
+ const pid = parseInt(pidLine, 10);
84
+ if (!Number.isInteger(pid) || pid <= 0)
85
+ return null;
86
+ if (newlineIndex === -1) {
87
+ return {
88
+ pid,
89
+ processStartTime: null,
90
+ instanceId: null,
91
+ hostname: null,
92
+ createdAt: null,
93
+ legacy: true,
94
+ };
95
+ }
96
+ let metadata = null;
97
+ try {
98
+ const raw = JSON.parse(trimmed.slice(newlineIndex + 1).trim());
99
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
100
+ metadata = {
101
+ processStartTime: readMetadataString(raw.processStartTime),
102
+ instanceId: readMetadataString(raw.instanceId),
103
+ hostname: readMetadataString(raw.hostname),
104
+ createdAt: readMetadataString(raw.createdAt),
105
+ };
106
+ }
107
+ }
108
+ catch {
109
+ metadata = null;
110
+ }
111
+ return {
112
+ pid,
113
+ processStartTime: metadata?.processStartTime ?? null,
114
+ instanceId: metadata?.instanceId ?? null,
115
+ hostname: metadata?.hostname ?? null,
116
+ createdAt: metadata?.createdAt ?? null,
117
+ legacy: metadata === null,
118
+ };
119
+ }
120
+ /**
121
+ * Read the current broker lock owner, or null when no lock file exists or it
122
+ * cannot be parsed.
123
+ */
124
+ export function readBrokerLockOwner(lockPath) {
125
+ const resolved = lockPath ?? defaultLockPath();
126
+ let content;
127
+ try {
128
+ content = fs.readFileSync(resolved, "utf-8");
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ return parseLockContent(content);
134
+ }
135
+ /**
136
+ * Inspect the broker leader lock and classify its owner.
137
+ *
138
+ * `stale-pid-reused` is only reported when both the recorded and current
139
+ * process start times are known and differ; unknown start times classify as
140
+ * `alive` so uncertainty never reclaims a live broker's lock.
141
+ */
142
+ export function inspectBrokerLock(lockPath, probes = {}) {
143
+ const resolved = lockPath ?? defaultLockPath();
144
+ let content;
145
+ try {
146
+ content = fs.readFileSync(resolved, "utf-8");
147
+ }
148
+ catch {
149
+ return { state: "none", owner: null };
150
+ }
151
+ const owner = parseLockContent(content);
152
+ if (!owner) {
153
+ return { state: "unreadable", owner: null };
154
+ }
155
+ const isRunning = probes.isProcessRunning ?? isProcessRunning;
156
+ if (!isRunning(owner.pid)) {
157
+ return { state: "stale-dead", owner };
158
+ }
159
+ if (owner.processStartTime) {
160
+ const startTimeOf = probes.getProcessStartTime ?? getProcessStartTime;
161
+ const currentStartTime = startTimeOf(owner.pid);
162
+ if (currentStartTime && currentStartTime !== owner.processStartTime) {
163
+ return { state: "stale-pid-reused", owner, currentStartTime };
164
+ }
165
+ }
166
+ return { state: "alive", owner };
167
+ }
168
+ /**
169
+ * Atomically create `filePath` with `content` already in place, failing when
170
+ * the path exists. A plain `writeFileSync("wx")` opens the file and then
171
+ * writes, so concurrent readers can observe an empty just-created file;
172
+ * writing a private temp file and `link()`ing it into place makes the path
173
+ * appear with its full content in one exclusive step.
174
+ */
175
+ function exclusiveCreateFile(filePath, content) {
176
+ const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
177
+ try {
178
+ fs.writeFileSync(tmpPath, content, "utf-8");
179
+ fs.linkSync(tmpPath, filePath);
180
+ return true;
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ finally {
186
+ try {
187
+ fs.unlinkSync(tmpPath);
188
+ }
189
+ catch {
190
+ /* best effort */
191
+ }
192
+ }
193
+ }
194
+ // ─── Leader lock ─────────────────────────────────────────
195
+ /**
196
+ * Leader election via lock file.
197
+ *
198
+ * Only one broker process should run at a time. The leader creates the lock
199
+ * file with an exclusive create (`O_CREAT | O_EXCL`), writing its PID on the
200
+ * first line (kept legacy-compatible so older builds still see a live owner)
201
+ * followed by a JSON metadata line recording process start time and a
202
+ * per-acquisition instance id.
203
+ *
204
+ * Exclusive creation is the only way the lock comes into existence, so
205
+ * simultaneous contenders on an empty path get exactly one winner from the
206
+ * kernel. Stale locks (dead PID, reused PID, unreadable content) are only
207
+ * ever unlinked while holding an exclusively-created reclaim mutex file,
208
+ * with staleness re-verified under that mutex — so a fresh lock can never be
209
+ * destroyed by a concurrent reclaimer, and the lock path never goes empty
210
+ * while a live owner's lock exists.
211
+ *
212
+ * Known mixed-version limitation: builds that predate the structured format
213
+ * replace a lock they consider stale with a plain rename over the lock path,
214
+ * which can overwrite a just-acquired v2 lock when an old and a new build
215
+ * race over the same stale lock. Exclusive acquisition is therefore only
216
+ * guaranteed among processes running this code; the window disappears once
217
+ * no pre-v2 sessions remain. A representation old builds cannot overwrite
218
+ * (such as a lock directory) would also break their ability to read the
219
+ * owner PID, which this format deliberately preserves.
13
220
  */
14
221
  export class LeaderLock {
15
222
  lockPath;
223
+ probes;
16
224
  acquired = false;
17
- constructor(lockPath) {
225
+ instanceId = null;
226
+ constructor(lockPath, probes = {}) {
18
227
  this.lockPath = lockPath ?? defaultLockPath();
228
+ this.probes = probes;
19
229
  }
20
230
  /**
21
231
  * Try to acquire the lock. Returns true if this process is now the leader.
@@ -24,30 +234,146 @@ export class LeaderLock {
24
234
  if (this.acquired)
25
235
  return true;
26
236
  fs.mkdirSync(path.dirname(this.lockPath), { recursive: true });
27
- // Check existing lock
28
- if (fs.existsSync(this.lockPath)) {
29
- const content = fs.readFileSync(this.lockPath, "utf-8").trim();
30
- const existingPid = parseInt(content, 10);
31
- if (!isNaN(existingPid) && isProcessRunning(existingPid)) {
32
- // Another live process holds the lock
237
+ // Two passes: exclusive create, and one stale-reclaim + retry.
238
+ for (let attempt = 0; attempt < 2; attempt++) {
239
+ if (this.tryExclusiveCreate()) {
240
+ return true;
241
+ }
242
+ const inspection = inspectBrokerLock(this.lockPath, this.probes);
243
+ if (inspection.state === "alive") {
244
+ // Another live process holds the lock.
245
+ return false;
246
+ }
247
+ if (inspection.state === "none") {
248
+ // The holder released between our create attempt and inspection.
249
+ continue;
250
+ }
251
+ if (!this.reclaimStaleLock()) {
252
+ // The lock is no longer stale, or another contender is reclaiming
253
+ // it. Back off conservatively.
33
254
  return false;
34
255
  }
35
- // Stale lock — remove it
36
- fs.unlinkSync(this.lockPath);
37
256
  }
38
- // Write our PID atomically (write to temp, rename)
257
+ return false;
258
+ }
259
+ /**
260
+ * Create the lock file exclusively. Returns true when this process now
261
+ * holds the lock; false when another lock file already exists.
262
+ */
263
+ tryExclusiveCreate() {
39
264
  const pid = process.pid;
40
- const tmpPath = `${this.lockPath}.${pid}.tmp`;
41
- fs.writeFileSync(tmpPath, String(pid), "utf-8");
42
- fs.renameSync(tmpPath, this.lockPath);
43
- // Verify we actually won (guard against race)
44
- const written = fs.readFileSync(this.lockPath, "utf-8").trim();
45
- if (written !== String(pid)) {
265
+ const instanceId = crypto.randomUUID();
266
+ const startTimeOf = this.probes.getProcessStartTime ?? getProcessStartTime;
267
+ const metadata = {
268
+ version: 2,
269
+ processStartTime: startTimeOf(pid),
270
+ instanceId,
271
+ hostname: os.hostname(),
272
+ createdAt: new Date().toISOString(),
273
+ };
274
+ const content = `${pid}\n${JSON.stringify(metadata)}\n`;
275
+ // Exclusive create with full content — the kernel picks exactly one
276
+ // winner among simultaneous contenders, and no reader can ever observe a
277
+ // partially-written lock.
278
+ if (!exclusiveCreateFile(this.lockPath, content)) {
279
+ return false;
280
+ }
281
+ // Paranoia read-back: if the file somehow no longer records our identity,
282
+ // treat the acquisition as lost and leave the file to its current owner.
283
+ const written = readBrokerLockOwner(this.lockPath);
284
+ if (!written || written.pid !== pid || written.instanceId !== instanceId) {
46
285
  return false;
47
286
  }
48
287
  this.acquired = true;
288
+ this.instanceId = instanceId;
49
289
  return true;
50
290
  }
291
+ /**
292
+ * Remove a stale lock under an exclusive reclaim mutex.
293
+ *
294
+ * The mutex file (`<lockPath>.reclaim`) is created with `O_EXCL` and
295
+ * records the reclaimer's PID plus start identity, so at most one
296
+ * reclaimer proceeds at a time, and staleness is re-verified while holding
297
+ * it. Because stale locks are only ever unlinked under this mutex, the
298
+ * lock path cannot go empty while a live owner's lock exists — which is
299
+ * what makes the exclusive create in `tryAcquire` a sound arbiter. A mutex
300
+ * left behind by a crashed reclaimer (dead PID, or a PID provably reused
301
+ * by an unrelated process) is itself reclaimed.
302
+ *
303
+ * Returns true when the caller may retry an exclusive create.
304
+ */
305
+ reclaimStaleLock() {
306
+ const mutexPath = `${this.lockPath}.reclaim`;
307
+ const isRunning = this.probes.isProcessRunning ?? isProcessRunning;
308
+ const startTimeOf = this.probes.getProcessStartTime ?? getProcessStartTime;
309
+ let holdsMutex = false;
310
+ for (let campaign = 0; campaign < 2 && !holdsMutex; campaign++) {
311
+ if (exclusiveCreateFile(mutexPath, `${process.pid}\n${startTimeOf(process.pid) ?? ""}\n`)) {
312
+ holdsMutex = true;
313
+ }
314
+ else {
315
+ // Mutex exists — held by a live reclaimer, or left by a crashed one.
316
+ let holderRaw;
317
+ try {
318
+ holderRaw = fs.readFileSync(mutexPath, "utf-8").trim();
319
+ }
320
+ catch {
321
+ continue; // vanished — retry the campaign
322
+ }
323
+ const [pidLine = "", startLine = ""] = holderRaw.split("\n");
324
+ const holderPid = /^\d+$/.test(pidLine.trim()) ? parseInt(pidLine.trim(), 10) : null;
325
+ const recordedStart = startLine.trim();
326
+ if (holderPid !== null && holderPid > 0 && isRunning(holderPid)) {
327
+ // The PID is live, but it may be an unrelated process that reused
328
+ // a crashed reclaimer's PID. Back off unless the recorded start
329
+ // identity provably belongs to a different process — uncertainty
330
+ // always means "assume the reclaimer is alive".
331
+ const currentStart = startTimeOf(holderPid);
332
+ if (!recordedStart || currentStart === null || currentStart === recordedStart) {
333
+ return false; // a live reclaimer is working — back off
334
+ }
335
+ }
336
+ // Crashed reclaimer. Remove its mutex only if it still records the
337
+ // dead holder we just read, then retry the campaign.
338
+ try {
339
+ if (fs.readFileSync(mutexPath, "utf-8").trim() === holderRaw) {
340
+ fs.unlinkSync(mutexPath);
341
+ }
342
+ }
343
+ catch {
344
+ /* already gone */
345
+ }
346
+ }
347
+ }
348
+ if (!holdsMutex)
349
+ return false;
350
+ try {
351
+ // Re-verify staleness while holding the mutex. We are now the only
352
+ // process allowed to unlink the lock, so no fresh lock can be created
353
+ // (the path stays occupied) until we decide.
354
+ const current = inspectBrokerLock(this.lockPath, this.probes);
355
+ if (current.state === "alive") {
356
+ return false;
357
+ }
358
+ if (current.state !== "none") {
359
+ try {
360
+ fs.unlinkSync(this.lockPath);
361
+ }
362
+ catch {
363
+ /* already gone */
364
+ }
365
+ }
366
+ return true;
367
+ }
368
+ finally {
369
+ try {
370
+ fs.unlinkSync(mutexPath);
371
+ }
372
+ catch {
373
+ /* best effort */
374
+ }
375
+ }
376
+ }
51
377
  /**
52
378
  * Release the lock if we hold it.
53
379
  */
@@ -55,18 +381,19 @@ export class LeaderLock {
55
381
  if (!this.acquired)
56
382
  return;
57
383
  try {
58
- // Only remove if it's still our PID
59
- if (fs.existsSync(this.lockPath)) {
60
- const content = fs.readFileSync(this.lockPath, "utf-8").trim();
61
- if (content === String(process.pid)) {
62
- fs.unlinkSync(this.lockPath);
63
- }
384
+ // Only remove the lock when it still records THIS acquisition — PID
385
+ // alone is not enough, because a later broker instance in the same
386
+ // process may have legitimately re-acquired with a new instance id.
387
+ const owner = readBrokerLockOwner(this.lockPath);
388
+ if (owner && owner.pid === process.pid && owner.instanceId === this.instanceId) {
389
+ fs.unlinkSync(this.lockPath);
64
390
  }
65
391
  }
66
392
  catch {
67
393
  // Best-effort cleanup
68
394
  }
69
395
  this.acquired = false;
396
+ this.instanceId = null;
70
397
  }
71
398
  /**
72
399
  * Check if this instance currently holds the lock.
@@ -80,6 +407,12 @@ export class LeaderLock {
80
407
  getLockPath() {
81
408
  return this.lockPath;
82
409
  }
410
+ /**
411
+ * Per-acquisition instance id, set while the lock is held.
412
+ */
413
+ getInstanceId() {
414
+ return this.instanceId;
415
+ }
83
416
  }
84
417
  /**
85
418
  * Check if a process with the given PID is currently running.
@@ -0,0 +1,5 @@
1
+ import type { AgentInfo, AgentLifecycleState, HibernateEligibility } from "./types.js";
2
+ export declare const AGENT_LIFECYCLE_STATES: readonly AgentLifecycleState[];
3
+ export declare function isLegalLifecycleTransition(from: AgentLifecycleState, to: AgentLifecycleState): boolean;
4
+ export declare function assertLegalLifecycleTransition(from: AgentLifecycleState, to: AgentLifecycleState): void;
5
+ export declare function evaluateHibernateEligibility(agent: AgentInfo): HibernateEligibility;
@@ -0,0 +1,59 @@
1
+ export const AGENT_LIFECYCLE_STATES = [
2
+ "live",
3
+ "active",
4
+ "grace",
5
+ "idle",
6
+ "hibernating",
7
+ "hibernated",
8
+ "waking",
9
+ "reap-candidate",
10
+ "terminated",
11
+ ];
12
+ const LEGAL_TRANSITIONS = {
13
+ live: ["active", "grace", "reap-candidate"],
14
+ active: ["grace", "reap-candidate"],
15
+ grace: ["active", "idle", "reap-candidate"],
16
+ idle: ["active", "hibernating", "reap-candidate"],
17
+ hibernating: ["active", "hibernated", "reap-candidate"],
18
+ hibernated: ["waking", "reap-candidate"],
19
+ waking: ["live", "reap-candidate"],
20
+ "reap-candidate": ["live", "hibernated", "terminated"],
21
+ terminated: [],
22
+ };
23
+ export function isLegalLifecycleTransition(from, to) {
24
+ return LEGAL_TRANSITIONS[from].includes(to);
25
+ }
26
+ export function assertLegalLifecycleTransition(from, to) {
27
+ if (!isLegalLifecycleTransition(from, to)) {
28
+ throw new Error(`Illegal agent lifecycle transition: ${from} -> ${to}`);
29
+ }
30
+ }
31
+ export function evaluateHibernateEligibility(agent) {
32
+ if (agent.hibernatePolicy === "never")
33
+ return { eligible: false, reason: "policy_never" };
34
+ if (!agent.stableId)
35
+ return { eligible: false, reason: "missing_stable_id" };
36
+ if (agent.parentAgentId || agent.supervisionState === "supervised") {
37
+ return { eligible: false, reason: "supervised_subtree_unsupported" };
38
+ }
39
+ if (agent.status !== "idle")
40
+ return { eligible: false, reason: "agent_working" };
41
+ if (agent.pendingInboxCount && agent.pendingInboxCount > 0) {
42
+ return { eligible: false, reason: "pending_inbox" };
43
+ }
44
+ const metadata = agent.metadata;
45
+ if (metadata?.brokerManaged !== true)
46
+ return { eligible: false, reason: "not_broker_managed" };
47
+ if (metadata.hibernateSafe !== true)
48
+ return { eligible: false, reason: "unsafe_or_unconfirmed" };
49
+ for (const key of ["cwd", "repoRoot", "worktreePath", "brokerManagedBy"]) {
50
+ if (typeof metadata[key] !== "string" || metadata[key].trim().length === 0) {
51
+ return { eligible: false, reason: `missing_${key}` };
52
+ }
53
+ }
54
+ const runtimeLocator = metadata.runtimeLocator ?? metadata.tmuxSession;
55
+ if (typeof runtimeLocator !== "string" || runtimeLocator.trim().length === 0) {
56
+ return { eligible: false, reason: "missing_runtime_locator" };
57
+ }
58
+ return { eligible: true, reason: "eligible" };
59
+ }
@@ -1,11 +1,16 @@
1
1
  export declare const PINET_MAIL_CLASSES: readonly ["steering", "fwup", "maintenance_context"];
2
2
  export type PinetMailClass = (typeof PINET_MAIL_CLASSES)[number];
3
+ export interface PinetMailMetadata extends Record<string, unknown> {
4
+ kind?: string;
5
+ type?: string;
6
+ event_type?: string;
7
+ }
3
8
  export interface PinetMailClassificationInput {
4
9
  source?: string | null;
5
10
  threadId?: string | null;
6
11
  sender?: string | null;
7
12
  body?: string | null;
8
- metadata?: Record<string, unknown> | null;
13
+ metadata?: PinetMailMetadata | null;
9
14
  }
10
15
  export interface PinetMailClassification {
11
16
  class: PinetMailClass;
@@ -1,10 +1,11 @@
1
+ import type { TransportJsonObject, TransportRichBlock } from "@pinet/transport-core";
1
2
  import type { BrokerMessage, MessageAdapter, NormalizedMessageContent, OutboundAttachmentFile, ThreadInfo } from "./types.js";
2
3
  export interface BrokerMessageSenderDb {
3
4
  getThread(threadId: string): ThreadInfo | null;
4
5
  createThread(threadId: string, source: string, channel: string, ownerAgent: string | null): ThreadInfo;
5
6
  updateThread(threadId: string, updates: Partial<ThreadInfo>): void;
6
7
  claimThread(threadId: string, agentId: string, source?: string, channel?: string): boolean;
7
- insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: Record<string, unknown>): BrokerMessage;
8
+ insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: TransportJsonObject): BrokerMessage;
8
9
  }
9
10
  export interface BrokerMessageSenderDeps {
10
11
  db: BrokerMessageSenderDb;
@@ -17,12 +18,12 @@ export interface SendBrokerMessageInput {
17
18
  source?: string;
18
19
  channel?: string;
19
20
  content?: NormalizedMessageContent;
20
- blocks?: ReadonlyArray<Record<string, unknown>>;
21
+ blocks?: ReadonlyArray<TransportRichBlock>;
21
22
  files?: ReadonlyArray<OutboundAttachmentFile>;
22
23
  agentName?: string;
23
24
  agentEmoji?: string;
24
25
  agentOwnerToken?: string;
25
- metadata?: Record<string, unknown>;
26
+ metadata?: TransportJsonObject;
26
27
  }
27
28
  export interface SendBrokerMessageResult {
28
29
  thread: ThreadInfo;
package/dist/router.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  import type { AgentInfo, BrokerDBInterface, InboundMessage, RoutingDecision } from "./types.js";
2
+ type BrokerRouterMetadata = Record<string, unknown>;
3
+ interface PiAgentMessageMetadata extends BrokerRouterMetadata {
4
+ event_type?: string;
5
+ event_payload?: {
6
+ agent?: string;
7
+ agent_owner?: string;
8
+ };
9
+ }
10
+ interface BrokerRouterReply extends BrokerRouterMetadata {
11
+ bot_id?: string;
12
+ metadata?: PiAgentMessageMetadata;
13
+ }
2
14
  export interface ThreadOwnerHint {
3
15
  agentId?: string;
4
16
  stableId?: string;
@@ -21,7 +33,7 @@ export type ExplicitThreadDirective = {
21
33
  * preferred over "Code" and similar-prefix collisions are avoided.
22
34
  */
23
35
  export declare function findAgentMention(text: string, agents: AgentInfo[]): AgentInfo | null;
24
- export declare function extractPiAgentThreadOwnerHint(replies: ReadonlyArray<Record<string, unknown>>): ThreadOwnerHint | null;
36
+ export declare function extractPiAgentThreadOwnerHint(replies: ReadonlyArray<BrokerRouterReply>): ThreadOwnerHint | null;
25
37
  export declare function findExplicitThreadDirective(text: string, agents: AgentInfo[]): ExplicitThreadDirective | null;
26
38
  export declare class MessageRouter {
27
39
  private readonly db;
@@ -58,3 +70,4 @@ export declare class MessageRouter {
58
70
  */
59
71
  getAvailableAgents(): AgentInfo[];
60
72
  }
73
+ export {};
package/dist/router.js CHANGED
@@ -178,7 +178,22 @@ function resolveRoutableThreadOwner(db, threadOwnerAgentId, now = new Date().toI
178
178
  function escapeRegExp(s) {
179
179
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
180
180
  }
181
+ /**
182
+ * Lifecycle states in which an agent has no live process/socket but is still a
183
+ * durable, affinity-bound routing target. A message to such an owner must be
184
+ * queued and cold-woken, never rerouted to an unrelated worker or dropped.
185
+ */
186
+ const DURABLE_HIBERNATION_OWNER_STATES = new Set([
187
+ "hibernating",
188
+ "hibernated",
189
+ "waking",
190
+ ]);
181
191
  function isRoutableOwner(agent, now = new Date().toISOString()) {
192
+ // Durable hibernation identities remain routable even without a live socket:
193
+ // the delivery layer queues to their inbox and triggers a fenced cold wake.
194
+ if (agent.lifecycleState && DURABLE_HIBERNATION_OWNER_STATES.has(agent.lifecycleState)) {
195
+ return true;
196
+ }
182
197
  if (!agent.disconnectedAt)
183
198
  return true;
184
199
  return agent.resumableUntil != null && agent.resumableUntil > now;