@pinet/broker-core 0.1.2 → 0.2.1

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.
@@ -17,6 +17,7 @@ export interface DirectAgentDispatchInput {
17
17
  target: string;
18
18
  body: string;
19
19
  metadata?: Record<string, unknown>;
20
+ trustedBrokerAgentId?: string;
20
21
  }
21
22
  export interface BroadcastAgentDispatchInput {
22
23
  senderAgentId: string;
@@ -128,17 +128,66 @@ export function resolveDirectAgentTarget(agents, target) {
128
128
  agents.find((agent) => agent.name === target) ??
129
129
  null);
130
130
  }
131
+ function isDescendantOf(agents, descendantId, ancestorId) {
132
+ let current = agents.find((agent) => agent.id === descendantId) ?? null;
133
+ const seen = new Set();
134
+ while (current?.parentAgentId) {
135
+ if (current.parentAgentId === ancestorId)
136
+ return true;
137
+ if (seen.has(current.parentAgentId))
138
+ return false;
139
+ seen.add(current.parentAgentId);
140
+ current = agents.find((agent) => agent.id === current?.parentAgentId) ?? null;
141
+ }
142
+ return false;
143
+ }
144
+ function canDispatchDirectAgentMessage(agents, sender, target, metadata) {
145
+ if (!sender)
146
+ return false;
147
+ if (!target.parentAgentId &&
148
+ target.supervisionState !== "supervised" &&
149
+ target.supervisionState !== "orphaned" &&
150
+ target.supervisionState !== "stopping") {
151
+ return true;
152
+ }
153
+ if (sender.id === metadata?.trustedBrokerAgentId) {
154
+ return metadata.emergency === true || metadata.targetScope === "subtree";
155
+ }
156
+ if (target.parentAgentId === sender.id)
157
+ return true;
158
+ if (sender.parentAgentId === target.id)
159
+ return true;
160
+ if (isDescendantOf(agents, target.id, sender.id))
161
+ return true;
162
+ if (isDescendantOf(agents, sender.id, target.id))
163
+ return true;
164
+ return false;
165
+ }
131
166
  export function resolveBroadcastTargets(agents, senderAgentId, channel) {
132
167
  return agents
133
168
  .filter((agent) => agent.id !== senderAgentId)
169
+ .filter((agent) => !agent.parentAgentId &&
170
+ agent.supervisionState !== "supervised" &&
171
+ agent.supervisionState !== "orphaned" &&
172
+ agent.supervisionState !== "stopping")
134
173
  .filter((agent) => agentSubscribesToBroadcastChannel(agent, channel))
135
174
  .sort((left, right) => left.name.localeCompare(right.name));
136
175
  }
137
176
  export function dispatchDirectAgentMessage(storage, input, onDispatch) {
138
- const target = resolveDirectAgentTarget(storage.getAgents(), input.target);
177
+ const agents = storage.getAgents();
178
+ const target = resolveDirectAgentTarget(agents, input.target);
139
179
  if (!target) {
140
180
  throw new Error(`Agent not found: ${input.target}`);
141
181
  }
182
+ const sender = agents.find((agent) => agent.id === input.senderAgentId) ?? null;
183
+ const policyMetadata = { ...(input.metadata ?? {}) };
184
+ delete policyMetadata.trustedBrokerAgentId;
185
+ if (input.trustedBrokerAgentId) {
186
+ policyMetadata.trustedBrokerAgentId = input.trustedBrokerAgentId;
187
+ }
188
+ if (!canDispatchDirectAgentMessage(agents, sender, target, policyMetadata)) {
189
+ throw new Error(`Agent ${input.senderAgentId} cannot message supervised agent ${target.id} without parent/subtree visibility or an explicit broker emergency override.`);
190
+ }
142
191
  const resolvedTarget = { id: target.id, name: target.name };
143
192
  const metadata = buildAgentMessageMetadata(input.senderAgentName, input.body, input.metadata);
144
193
  const { threadId, messageId } = deliverAgentMessage(storage, input.senderAgentId, resolvedTarget, input.body, metadata, onDispatch);
@@ -32,7 +32,11 @@ export function runBrokerMaintenancePass(db, options) {
32
32
  const agents = db
33
33
  .getAgents()
34
34
  .filter((agent) => agent.id !== brokerAgentId)
35
- .filter((agent) => agent.metadata?.role !== "broker");
35
+ .filter((agent) => agent.metadata?.role !== "broker")
36
+ .filter((agent) => !agent.parentAgentId &&
37
+ agent.supervisionState !== "supervised" &&
38
+ agent.supervisionState !== "orphaned" &&
39
+ agent.supervisionState !== "stopping");
36
40
  const agentLoads = agents.map((agent) => ({
37
41
  agent,
38
42
  pendingInboxCount: db.getPendingInboxCount(agent.id),
@@ -53,7 +57,7 @@ export function runBrokerMaintenancePass(db, options) {
53
57
  continue;
54
58
  }
55
59
  const preferredAgent = backlog.preferredAgentId
56
- ? (agents.find((agent) => agent.id === backlog.preferredAgentId) ?? null)
60
+ ? (db.getAgents().find((agent) => agent.id === backlog.preferredAgentId) ?? null)
57
61
  : null;
58
62
  if (backlog.preferredAgentId && !preferredAgent) {
59
63
  const knownPreferredAgent = db.getAgentById(backlog.preferredAgentId);
@@ -1,4 +1,4 @@
1
- import type { BrokerMessage, MessageAdapter, NormalizedMessageContent, ThreadInfo } from "./types.js";
1
+ import type { BrokerMessage, MessageAdapter, NormalizedMessageContent, OutboundAttachmentFile, ThreadInfo } from "./types.js";
2
2
  export interface BrokerMessageSenderDb {
3
3
  getThread(threadId: string): ThreadInfo | null;
4
4
  createThread(threadId: string, source: string, channel: string, ownerAgent: string | null): ThreadInfo;
@@ -18,6 +18,7 @@ export interface SendBrokerMessageInput {
18
18
  channel?: string;
19
19
  content?: NormalizedMessageContent;
20
20
  blocks?: ReadonlyArray<Record<string, unknown>>;
21
+ files?: ReadonlyArray<OutboundAttachmentFile>;
21
22
  agentName?: string;
22
23
  agentEmoji?: string;
23
24
  agentOwnerToken?: string;
@@ -60,6 +60,7 @@ export async function sendBrokerMessage(deps, input) {
60
60
  text: messageBody,
61
61
  ...(content ? { content } : {}),
62
62
  ...(input.blocks && input.blocks.length > 0 ? { blocks: input.blocks } : {}),
63
+ ...(input.files && input.files.length > 0 ? { files: input.files } : {}),
63
64
  ...(input.agentName ? { agentName: input.agentName } : {}),
64
65
  ...(input.agentEmoji ? { agentEmoji: input.agentEmoji } : {}),
65
66
  ...(input.agentOwnerToken ? { agentOwnerToken: input.agentOwnerToken } : {}),
package/dist/schema.d.ts CHANGED
@@ -12,7 +12,7 @@ export interface TaskAssignmentAwaitingReplyInfo {
12
12
  export declare function defaultDbPath(): string;
13
13
  export declare const DEFAULT_RESUMABLE_WINDOW_MS = 15000;
14
14
  export declare const DEFAULT_DISCONNECTED_PURGE_GRACE_MS: number;
15
- export declare const CURRENT_BROKER_SCHEMA_VERSION = 17;
15
+ export declare const CURRENT_BROKER_SCHEMA_VERSION = 18;
16
16
  export declare class BrokerDB implements BrokerDBInterface {
17
17
  private db;
18
18
  private readonly dbPath;
@@ -27,8 +27,14 @@ export declare class BrokerDB implements BrokerDBInterface {
27
27
  reconcileStartupAgents(resumableForMs?: number): void;
28
28
  close(): void;
29
29
  registerAgent(id: string, name: string, emoji: string, pid: number, metadata?: Record<string, unknown>, stableId?: string): AgentInfo;
30
+ private resolveAgentHierarchy;
30
31
  unregisterAgent(id: string): void;
31
32
  disconnectAgent(id: string, resumableForMs?: number): void;
33
+ private getDirectChildren;
34
+ getAgentDescendants(parentAgentId: string, includeDisconnected?: boolean): AgentInfo[];
35
+ isAgentAncestor(ancestorAgentId: string, descendantAgentId: string): boolean;
36
+ private notifyParentOfChildExit;
37
+ private markDescendantsOrphaned;
32
38
  getAgentById(id: string): AgentInfo | null;
33
39
  private getCurrentSessionOutboundCount;
34
40
  private rowToAgentWithCurrentSessionOutboundCount;
@@ -127,6 +133,7 @@ export declare class BrokerDB implements BrokerDBInterface {
127
133
  private getExistingMessageIdForIdentity;
128
134
  private getMessageByExternalId;
129
135
  private getMessageById;
136
+ private dropStaleSlackDeliveryRows;
130
137
  private dropStaleTransportInboxRows;
131
138
  getInbox(agentId: string, limit?: number): {
132
139
  entry: InboxEntry;
package/dist/schema.js CHANGED
@@ -28,6 +28,14 @@ function rowToAgent(row) {
28
28
  lastHeartbeat: row.last_heartbeat,
29
29
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
30
30
  status: row.status === "working" ? "working" : "idle",
31
+ parentAgentId: row.parent_agent_id,
32
+ rootAgentId: row.root_agent_id,
33
+ treeDepth: row.tree_depth ?? 0,
34
+ spawnedByAgentId: row.spawned_by_agent_id,
35
+ supervisionState: normalizeAgentSupervisionState(row.supervision_state),
36
+ launchId: row.launch_id,
37
+ subtreeRole: row.subtree_role,
38
+ laneId: row.lane_id,
31
39
  disconnectedAt: row.disconnected_at,
32
40
  resumableUntil: row.resumable_until,
33
41
  idleSince: row.idle_since,
@@ -78,6 +86,30 @@ function getStringMetadataValue(metadata, keys) {
78
86
  return null;
79
87
  }
80
88
  const INTERNAL_AGENT_SOURCE = "agent";
89
+ const STALE_SLACK_MESSAGE_MAX_AGE_MS = 15 * 60 * 1000;
90
+ // Slack ts values are epoch seconds. Small fixture/sentinel values are treated
91
+ // as ambiguous instead of stale so replay filtering only applies to plausible
92
+ // real Slack event timestamps.
93
+ const MIN_PLAUSIBLE_SLACK_TIMESTAMP_SECONDS = 1_000_000_000;
94
+ function parseSlackTimestampMs(timestamp) {
95
+ if (!timestamp)
96
+ return null;
97
+ const seconds = Number(timestamp);
98
+ if (!Number.isFinite(seconds) || seconds < MIN_PLAUSIBLE_SLACK_TIMESTAMP_SECONDS) {
99
+ return null;
100
+ }
101
+ return Math.trunc(seconds * 1000);
102
+ }
103
+ function getSlackMessageAgeMs(source, timestamp, nowMs = Date.now()) {
104
+ if (source !== "slack")
105
+ return null;
106
+ const timestampMs = parseSlackTimestampMs(timestamp);
107
+ return timestampMs === null ? null : nowMs - timestampMs;
108
+ }
109
+ function isStaleSlackMessageTimestamp(source, timestamp, nowMs = Date.now()) {
110
+ const ageMs = getSlackMessageAgeMs(source, timestamp, nowMs);
111
+ return ageMs !== null && ageMs > STALE_SLACK_MESSAGE_MAX_AGE_MS;
112
+ }
81
113
  function isExternalTransportSource(source) {
82
114
  return source.trim().length > 0 && source !== INTERNAL_AGENT_SOURCE;
83
115
  }
@@ -263,6 +295,26 @@ function parseMetadataJson(value) {
263
295
  return null;
264
296
  }
265
297
  }
298
+ const AGENT_SUPERVISION_STATES = new Set([
299
+ "root",
300
+ "supervised",
301
+ "orphaned",
302
+ "stopping",
303
+ ]);
304
+ function normalizeAgentSupervisionState(value) {
305
+ return typeof value === "string" && AGENT_SUPERVISION_STATES.has(value)
306
+ ? value
307
+ : "root";
308
+ }
309
+ function getOptionalMetadataString(metadata, keys) {
310
+ for (const key of keys) {
311
+ const value = metadata?.[key];
312
+ if (typeof value === "string" && value.trim().length > 0) {
313
+ return value.trim();
314
+ }
315
+ }
316
+ return null;
317
+ }
266
318
  function rowToPinetLaneParticipant(row) {
267
319
  return {
268
320
  laneId: row.lane_id,
@@ -406,7 +458,7 @@ export function defaultDbPath() {
406
458
  }
407
459
  export const DEFAULT_RESUMABLE_WINDOW_MS = 15_000;
408
460
  export const DEFAULT_DISCONNECTED_PURGE_GRACE_MS = 60 * 60_000;
409
- export const CURRENT_BROKER_SCHEMA_VERSION = 17;
461
+ export const CURRENT_BROKER_SCHEMA_VERSION = 18;
410
462
  const REQUIRED_AGENT_LIFECYCLE_COLUMNS = [
411
463
  "stable_id",
412
464
  "metadata",
@@ -575,6 +627,31 @@ function addObservabilityColumns(db) {
575
627
  WHERE status = 'idle' AND idle_since IS NULL;
576
628
  `);
577
629
  }
630
+ function addAgentHierarchyColumns(db) {
631
+ ensureColumn(db, "agents", "parent_agent_id", "ALTER TABLE agents ADD COLUMN parent_agent_id TEXT");
632
+ ensureColumn(db, "agents", "root_agent_id", "ALTER TABLE agents ADD COLUMN root_agent_id TEXT");
633
+ ensureColumn(db, "agents", "tree_depth", "ALTER TABLE agents ADD COLUMN tree_depth INTEGER NOT NULL DEFAULT 0");
634
+ ensureColumn(db, "agents", "spawned_by_agent_id", "ALTER TABLE agents ADD COLUMN spawned_by_agent_id TEXT");
635
+ ensureColumn(db, "agents", "supervision_state", "ALTER TABLE agents ADD COLUMN supervision_state TEXT NOT NULL DEFAULT 'root'");
636
+ ensureColumn(db, "agents", "launch_id", "ALTER TABLE agents ADD COLUMN launch_id TEXT");
637
+ ensureColumn(db, "agents", "subtree_role", "ALTER TABLE agents ADD COLUMN subtree_role TEXT");
638
+ ensureColumn(db, "agents", "lane_id", "ALTER TABLE agents ADD COLUMN lane_id TEXT");
639
+ db.exec(`
640
+ UPDATE agents
641
+ SET tree_depth = COALESCE(tree_depth, 0),
642
+ supervision_state = COALESCE(supervision_state, 'root')
643
+ WHERE tree_depth IS NULL OR supervision_state IS NULL;
644
+
645
+ CREATE INDEX IF NOT EXISTS idx_agents_parent_agent_id
646
+ ON agents(parent_agent_id);
647
+ CREATE INDEX IF NOT EXISTS idx_agents_root_agent_id
648
+ ON agents(root_agent_id);
649
+ CREATE INDEX IF NOT EXISTS idx_agents_supervision_state
650
+ ON agents(supervision_state, parent_agent_id);
651
+ CREATE INDEX IF NOT EXISTS idx_agents_lane_id
652
+ ON agents(lane_id);
653
+ `);
654
+ }
578
655
  function addThreadOwnershipBindingColumn(db) {
579
656
  createCoreTables(db);
580
657
  ensureColumn(db, "threads", "owner_binding", "ALTER TABLE threads ADD COLUMN owner_binding TEXT");
@@ -1058,6 +1135,9 @@ function runSchemaMigrations(db) {
1058
1135
  case 17:
1059
1136
  migrateTaskAssignmentsToRepoScopedTracking(db);
1060
1137
  break;
1138
+ case 18:
1139
+ addAgentHierarchyColumns(db);
1140
+ break;
1061
1141
  default:
1062
1142
  throw new Error(`Unsupported broker schema migration target: ${nextVersion}`);
1063
1143
  }
@@ -1153,13 +1233,16 @@ export class BrokerDB {
1153
1233
  ? JSON.parse(existingRow.metadata)
1154
1234
  : undefined);
1155
1235
  const meta = finalMetadata ? JSON.stringify(finalMetadata) : null;
1236
+ const hierarchy = this.resolveAgentHierarchy(agentId, finalMetadata, existingRow);
1156
1237
  db.prepare(`INSERT INTO agents (
1157
1238
  id, stable_id, name, emoji, pid,
1158
1239
  connected_at, last_seen, last_heartbeat,
1159
- metadata, status, disconnected_at, resumable_until,
1160
- idle_since, last_activity
1240
+ metadata, status,
1241
+ parent_agent_id, root_agent_id, tree_depth, spawned_by_agent_id,
1242
+ supervision_state, launch_id, subtree_role, lane_id,
1243
+ disconnected_at, resumable_until, idle_since, last_activity
1161
1244
  )
1162
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'idle', NULL, NULL, ?, NULL)
1245
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'idle', ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, NULL)
1163
1246
  ON CONFLICT(id) DO UPDATE SET
1164
1247
  stable_id = COALESCE(excluded.stable_id, agents.stable_id),
1165
1248
  name = excluded.name,
@@ -1170,12 +1253,21 @@ export class BrokerDB {
1170
1253
  last_heartbeat = excluded.last_heartbeat,
1171
1254
  metadata = excluded.metadata,
1172
1255
  status = 'idle',
1256
+ parent_agent_id = excluded.parent_agent_id,
1257
+ root_agent_id = excluded.root_agent_id,
1258
+ tree_depth = excluded.tree_depth,
1259
+ spawned_by_agent_id = excluded.spawned_by_agent_id,
1260
+ supervision_state = excluded.supervision_state,
1261
+ launch_id = excluded.launch_id,
1262
+ subtree_role = excluded.subtree_role,
1263
+ lane_id = excluded.lane_id,
1173
1264
  disconnected_at = NULL,
1174
1265
  resumable_until = NULL,
1175
1266
  idle_since = excluded.idle_since,
1176
- last_activity = NULL`).run(agentId, persistedStableId, finalName, finalEmoji, pid, now, now, now, meta, now);
1267
+ last_activity = NULL`).run(agentId, persistedStableId, finalName, finalEmoji, pid, now, now, now, meta, hierarchy.parentAgentId, hierarchy.rootAgentId, hierarchy.treeDepth, hierarchy.spawnedByAgentId, hierarchy.supervisionState, hierarchy.launchId, hierarchy.subtreeRole, hierarchy.laneId, now);
1177
1268
  return {
1178
1269
  id: agentId,
1270
+ stableId: persistedStableId,
1179
1271
  name: finalName,
1180
1272
  emoji: finalEmoji,
1181
1273
  pid,
@@ -1184,18 +1276,76 @@ export class BrokerDB {
1184
1276
  lastHeartbeat: now,
1185
1277
  metadata: finalMetadata ?? null,
1186
1278
  status: "idle",
1279
+ parentAgentId: hierarchy.parentAgentId,
1280
+ rootAgentId: hierarchy.rootAgentId,
1281
+ treeDepth: hierarchy.treeDepth,
1282
+ spawnedByAgentId: hierarchy.spawnedByAgentId,
1283
+ supervisionState: hierarchy.supervisionState,
1284
+ launchId: hierarchy.launchId,
1285
+ subtreeRole: hierarchy.subtreeRole,
1286
+ laneId: hierarchy.laneId,
1187
1287
  idleSince: now,
1188
1288
  lastActivity: null,
1189
1289
  };
1190
1290
  }
1291
+ resolveAgentHierarchy(agentId, metadata, existingRow) {
1292
+ const requestedParentId = getOptionalMetadataString(metadata, [
1293
+ "parentAgentId",
1294
+ "pinetParentAgentId",
1295
+ ]);
1296
+ const parentId = requestedParentId ?? existingRow?.parent_agent_id ?? null;
1297
+ const parent = parentId ? this.getAgentById(parentId) : null;
1298
+ if (parentId && (!parent || parent.disconnectedAt)) {
1299
+ throw new Error(`Cannot register supervised Pinet agent; parent ${parentId} is not live.`);
1300
+ }
1301
+ if (parent && parent.id === agentId) {
1302
+ throw new Error("Cannot register a Pinet agent as its own parent.");
1303
+ }
1304
+ if (parent && this.isAgentAncestor(agentId, parent.id)) {
1305
+ throw new Error("Cannot register a Pinet agent under one of its descendants.");
1306
+ }
1307
+ const supervisionState = parent
1308
+ ? "supervised"
1309
+ : normalizeAgentSupervisionState(getOptionalMetadataString(metadata, ["supervisionState", "pinetSupervisionState"]) ??
1310
+ existingRow?.supervision_state);
1311
+ const rootAgentId = parent
1312
+ ? (parent.rootAgentId ?? parent.id)
1313
+ : (getOptionalMetadataString(metadata, ["rootAgentId", "pinetRootAgentId"]) ??
1314
+ existingRow?.root_agent_id ??
1315
+ null);
1316
+ const treeDepth = parent ? (parent.treeDepth ?? 0) + 1 : (existingRow?.tree_depth ?? 0);
1317
+ const spawnedByAgentId = getOptionalMetadataString(metadata, ["spawnedByAgentId", "pinetSpawnedByAgentId"]) ??
1318
+ (parent ? parent.id : (existingRow?.spawned_by_agent_id ?? null));
1319
+ return {
1320
+ parentAgentId: parent?.id ?? null,
1321
+ rootAgentId,
1322
+ treeDepth,
1323
+ spawnedByAgentId,
1324
+ supervisionState,
1325
+ launchId: getOptionalMetadataString(metadata, ["launchId", "pinetLaunchId"]) ??
1326
+ existingRow?.launch_id ??
1327
+ null,
1328
+ subtreeRole: getOptionalMetadataString(metadata, ["subtreeRole", "pinetSubtreeRole"]) ??
1329
+ existingRow?.subtree_role ??
1330
+ null,
1331
+ laneId: getOptionalMetadataString(metadata, ["laneId", "pinetLaneId"]) ??
1332
+ existingRow?.lane_id ??
1333
+ null,
1334
+ };
1335
+ }
1191
1336
  unregisterAgent(id) {
1192
1337
  const db = this.getDb();
1193
1338
  const now = new Date().toISOString();
1194
1339
  this.withTransaction(() => {
1340
+ const agent = this.getAgentById(id);
1195
1341
  this.requeueUndeliveredMessagesInternal(id, "agent_disconnected");
1196
1342
  db.prepare("DELETE FROM inbox WHERE agent_id = ?").run(id);
1197
1343
  db.prepare("UPDATE agents SET disconnected_at = ?, resumable_until = NULL WHERE id = ?").run(now, id);
1198
1344
  db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?").run(id);
1345
+ if (agent?.parentAgentId) {
1346
+ this.notifyParentOfChildExit(agent, "unregistered");
1347
+ }
1348
+ this.markDescendantsOrphaned(id, "parent_unregistered");
1199
1349
  });
1200
1350
  }
1201
1351
  disconnectAgent(id, resumableForMs = DEFAULT_RESUMABLE_WINDOW_MS) {
@@ -1204,6 +1354,85 @@ export class BrokerDB {
1204
1354
  const resumableUntil = new Date(now.getTime() + resumableForMs).toISOString();
1205
1355
  db.prepare("UPDATE agents SET disconnected_at = ?, resumable_until = ? WHERE id = ?").run(now.toISOString(), resumableUntil, id);
1206
1356
  }
1357
+ getDirectChildren(parentAgentId) {
1358
+ const rows = this.getDb()
1359
+ .prepare("SELECT * FROM agents WHERE parent_agent_id = ? ORDER BY connected_at ASC")
1360
+ .all(parentAgentId);
1361
+ return rows.map(rowToAgent);
1362
+ }
1363
+ getAgentDescendants(parentAgentId, includeDisconnected = false) {
1364
+ const descendants = [];
1365
+ const seen = new Set();
1366
+ const queue = this.getDirectChildren(parentAgentId);
1367
+ while (queue.length > 0) {
1368
+ const child = queue.shift();
1369
+ if (!child || seen.has(child.id))
1370
+ continue;
1371
+ seen.add(child.id);
1372
+ if (includeDisconnected || !child.disconnectedAt) {
1373
+ descendants.push(child);
1374
+ }
1375
+ queue.push(...this.getDirectChildren(child.id));
1376
+ }
1377
+ return descendants;
1378
+ }
1379
+ isAgentAncestor(ancestorAgentId, descendantAgentId) {
1380
+ let current = this.getAgentById(descendantAgentId);
1381
+ const seen = new Set();
1382
+ while (current?.parentAgentId) {
1383
+ if (current.parentAgentId === ancestorAgentId)
1384
+ return true;
1385
+ if (seen.has(current.parentAgentId))
1386
+ return false;
1387
+ seen.add(current.parentAgentId);
1388
+ current = this.getAgentById(current.parentAgentId);
1389
+ }
1390
+ return false;
1391
+ }
1392
+ notifyParentOfChildExit(agent, reason) {
1393
+ const parentId = agent.parentAgentId;
1394
+ if (!parentId)
1395
+ return;
1396
+ const parent = this.getAgentById(parentId);
1397
+ if (!parent || parent.disconnectedAt)
1398
+ return;
1399
+ const threadId = `a2a:${agent.id}:${parentId}`;
1400
+ this.createThread(threadId, "agent", `agent:${parentId}`, parentId);
1401
+ this.insertMessage(threadId, "agent", "inbound", agent.id, `Child worker ${agent.name} (${agent.id}) exited: ${reason}.`, [parentId], {
1402
+ a2a: true,
1403
+ senderAgent: agent.name,
1404
+ pinetMailClass: "fwup",
1405
+ subtree: true,
1406
+ childAgentId: agent.id,
1407
+ parentAgentId: parentId,
1408
+ lifecycle: "child_exit",
1409
+ reason,
1410
+ });
1411
+ }
1412
+ markDescendantsOrphaned(parentAgentId, reason) {
1413
+ const descendants = this.getAgentDescendants(parentAgentId, true);
1414
+ if (descendants.length === 0)
1415
+ return;
1416
+ const db = this.getDb();
1417
+ const update = db.prepare("UPDATE agents SET supervision_state = 'orphaned', parent_agent_id = NULL WHERE id = ?");
1418
+ for (const child of descendants) {
1419
+ update.run(child.id);
1420
+ if (!child.disconnectedAt) {
1421
+ const threadId = `a2a:${parentAgentId}:${child.id}`;
1422
+ this.createThread(threadId, "agent", `agent:${child.id}`, child.id);
1423
+ this.insertMessage(threadId, "agent", "inbound", parentAgentId, `Parent worker ${parentAgentId} is no longer supervising this subtree (${reason}); this worker is now orphaned and should stop or await broker recovery instructions.`, [child.id], {
1424
+ a2a: true,
1425
+ senderAgent: "Pinet lifecycle",
1426
+ pinetMailClass: "steering",
1427
+ subtree: true,
1428
+ parentAgentId,
1429
+ childAgentId: child.id,
1430
+ lifecycle: "parent_orphaned_child",
1431
+ reason,
1432
+ });
1433
+ }
1434
+ }
1435
+ }
1207
1436
  getAgentById(id) {
1208
1437
  const row = this.getAgentRowById(id);
1209
1438
  return row ? rowToAgent(row) : null;
@@ -1469,7 +1698,7 @@ export class BrokerDB {
1469
1698
  const now = new Date().toISOString();
1470
1699
  return this.withTransaction(() => {
1471
1700
  const staleRows = db
1472
- .prepare(`SELECT id FROM agents
1701
+ .prepare(`SELECT * FROM agents
1473
1702
  WHERE (disconnected_at IS NULL AND last_heartbeat <= ?)
1474
1703
  OR (disconnected_at IS NOT NULL AND resumable_until IS NOT NULL AND resumable_until <= ?)`)
1475
1704
  .all(cutoff, now);
@@ -1479,9 +1708,14 @@ export class BrokerDB {
1479
1708
  const disconnectAgent = db.prepare("UPDATE agents SET disconnected_at = COALESCE(disconnected_at, ?), resumable_until = NULL WHERE id = ?");
1480
1709
  const releaseClaims = db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?");
1481
1710
  for (const row of staleRows) {
1711
+ const agent = rowToAgent(row);
1482
1712
  this.requeueUndeliveredMessagesInternal(row.id, "agent_disconnected");
1483
1713
  disconnectAgent.run(now, row.id);
1484
1714
  releaseClaims.run(row.id);
1715
+ if (agent.parentAgentId) {
1716
+ this.notifyParentOfChildExit(agent, "stale_heartbeat");
1717
+ }
1718
+ this.markDescendantsOrphaned(row.id, "parent_stale");
1485
1719
  }
1486
1720
  return staleRows.map((row) => row.id);
1487
1721
  });
@@ -1493,7 +1727,7 @@ export class BrokerDB {
1493
1727
  const cutoff = new Date(now - graceMs).toISOString();
1494
1728
  return this.withTransaction(() => {
1495
1729
  const rows = db
1496
- .prepare(`SELECT id FROM agents
1730
+ .prepare(`SELECT * FROM agents
1497
1731
  WHERE disconnected_at IS NOT NULL
1498
1732
  AND disconnected_at <= ?
1499
1733
  AND (resumable_until IS NULL OR resumable_until <= ?)`)
@@ -1504,12 +1738,17 @@ export class BrokerDB {
1504
1738
  const releaseThreads = db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?");
1505
1739
  const deleteInbox = db.prepare("DELETE FROM inbox WHERE agent_id = ?");
1506
1740
  for (const row of rows) {
1741
+ const agent = rowToAgent(row);
1507
1742
  // Requeue undelivered messages to the backlog
1508
1743
  this.requeueUndeliveredMessagesInternal(row.id, "agent_disconnected");
1509
1744
  // Release thread ownership for the purged agent
1510
1745
  releaseThreads.run(row.id);
1511
1746
  // Clean up all inbox entries (both delivered and undelivered) for the agent
1512
1747
  deleteInbox.run(row.id);
1748
+ if (agent.parentAgentId) {
1749
+ this.notifyParentOfChildExit(agent, "purged");
1750
+ }
1751
+ this.markDescendantsOrphaned(row.id, "parent_purged");
1513
1752
  }
1514
1753
  db.prepare(`DELETE FROM agents
1515
1754
  WHERE disconnected_at IS NOT NULL
@@ -1779,6 +2018,7 @@ export class BrokerDB {
1779
2018
  return rows.map(rowToThread);
1780
2019
  }
1781
2020
  getPendingBacklog(limit = 50) {
2021
+ this.dropStaleSlackDeliveryRows();
1782
2022
  const db = this.getDb();
1783
2023
  const rows = db
1784
2024
  .prepare(`SELECT * FROM unrouted_backlog
@@ -1789,6 +2029,9 @@ export class BrokerDB {
1789
2029
  return rows.map(rowToBacklog);
1790
2030
  }
1791
2031
  getBacklogCount(status = "pending") {
2032
+ if (status === "pending") {
2033
+ this.dropStaleSlackDeliveryRows();
2034
+ }
1792
2035
  const db = this.getDb();
1793
2036
  const row = db
1794
2037
  .prepare("SELECT COUNT(*) AS count FROM unrouted_backlog WHERE status = ?")
@@ -1825,6 +2068,7 @@ export class BrokerDB {
1825
2068
  assignBacklogEntry(id, agentId) {
1826
2069
  const db = this.getDb();
1827
2070
  return this.withTransaction(() => {
2071
+ this.dropStaleSlackDeliveryRows(agentId);
1828
2072
  const row = db
1829
2073
  .prepare("SELECT * FROM unrouted_backlog WHERE id = ? AND status = 'pending'")
1830
2074
  .get(id);
@@ -1861,6 +2105,7 @@ export class BrokerDB {
1861
2105
  });
1862
2106
  }
1863
2107
  recoverPendingTargetedBacklog(agentId) {
2108
+ this.dropStaleSlackDeliveryRows(agentId);
1864
2109
  const agent = this.getAgentRowById(agentId);
1865
2110
  if (!agent || agent.disconnected_at) {
1866
2111
  return 0;
@@ -2607,7 +2852,75 @@ export class BrokerDB {
2607
2852
  .get(messageId);
2608
2853
  return row ? rowToBrokerMessage(row) : null;
2609
2854
  }
2855
+ dropStaleSlackDeliveryRows(agentId) {
2856
+ const db = this.getDb();
2857
+ const nowMs = Date.now();
2858
+ const now = new Date(nowMs).toISOString();
2859
+ const agentClause = agentId ? " AND i.agent_id = ?" : "";
2860
+ const inboxRows = db
2861
+ .prepare(`SELECT i.id AS inbox_id,
2862
+ i.agent_id AS agent_id,
2863
+ i.message_id AS message_id,
2864
+ m.source AS source,
2865
+ m.metadata AS metadata,
2866
+ m.external_ts AS external_ts
2867
+ FROM inbox i
2868
+ JOIN messages m ON m.id = i.message_id
2869
+ WHERE (i.delivered = 0 OR i.read_at IS NULL)
2870
+ AND m.direction = 'inbound'
2871
+ AND m.source = 'slack'${agentClause}`)
2872
+ .all(...(agentId ? [agentId] : []));
2873
+ const staleInboxRows = inboxRows.filter((row) => {
2874
+ const metadata = parseJsonMetadata(row.metadata);
2875
+ const timestamp = getStringMetadataValue(metadata, ["timestamp", "ts", "externalTs", "external_ts"]) ??
2876
+ row.external_ts;
2877
+ return isStaleSlackMessageTimestamp(row.source, timestamp, nowMs);
2878
+ });
2879
+ const markInboxStale = db.prepare("UPDATE inbox SET delivered = 1, read_at = COALESCE(read_at, ?) WHERE id = ?");
2880
+ for (const row of staleInboxRows) {
2881
+ markInboxStale.run(now, row.inbox_id);
2882
+ this.completeTargetedBacklogAssignment(row.message_id, row.agent_id);
2883
+ }
2884
+ const backlogRows = tableExists(db, "unrouted_backlog")
2885
+ ? db
2886
+ .prepare(`SELECT b.id AS backlog_id,
2887
+ m.source AS source,
2888
+ m.metadata AS metadata,
2889
+ m.external_ts AS external_ts
2890
+ FROM unrouted_backlog b
2891
+ JOIN messages m ON m.id = b.message_id
2892
+ WHERE b.status = 'pending'
2893
+ AND m.direction = 'inbound'
2894
+ AND m.source = 'slack'`)
2895
+ .all()
2896
+ : [];
2897
+ const staleBacklogIds = backlogRows
2898
+ .filter((row) => {
2899
+ const metadata = parseJsonMetadata(row.metadata);
2900
+ const timestamp = getStringMetadataValue(metadata, ["timestamp", "ts", "externalTs", "external_ts"]) ??
2901
+ row.external_ts;
2902
+ return isStaleSlackMessageTimestamp(row.source, timestamp, nowMs);
2903
+ })
2904
+ .map((row) => row.backlog_id);
2905
+ if (staleBacklogIds.length > 0) {
2906
+ const dropBacklog = db.prepare(`UPDATE unrouted_backlog
2907
+ SET status = 'dropped',
2908
+ reason = 'stale_slack_message',
2909
+ assigned_agent_id = NULL,
2910
+ updated_at = ?
2911
+ WHERE id = ?
2912
+ AND status = 'pending'`);
2913
+ for (const id of staleBacklogIds) {
2914
+ dropBacklog.run(now, id);
2915
+ }
2916
+ }
2917
+ if (staleInboxRows.length > 0 || staleBacklogIds.length > 0) {
2918
+ console.info(`[broker-core] skipped stale Slack delivery rows older than 15m: inbox=${staleInboxRows.length} backlog=${staleBacklogIds.length}`);
2919
+ }
2920
+ return { inboxCount: staleInboxRows.length, backlogCount: staleBacklogIds.length };
2921
+ }
2610
2922
  dropStaleTransportInboxRows(agentId) {
2923
+ this.dropStaleSlackDeliveryRows(agentId);
2611
2924
  const db = this.getDb();
2612
2925
  const rows = db
2613
2926
  .prepare(`SELECT i.id AS inbox_id,
@@ -2952,7 +3265,8 @@ export class BrokerDB {
2952
3265
  m.id AS message_id,
2953
3266
  m.thread_id AS thread_id,
2954
3267
  m.source AS source,
2955
- m.metadata AS metadata
3268
+ m.metadata AS metadata,
3269
+ m.external_ts AS external_ts
2956
3270
  FROM inbox i
2957
3271
  JOIN messages m ON m.id = i.message_id
2958
3272
  WHERE i.agent_id = ?
@@ -2963,14 +3277,30 @@ export class BrokerDB {
2963
3277
  return 0;
2964
3278
  }
2965
3279
  const markDelivered = db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ?");
3280
+ const markStaleRead = db.prepare("UPDATE inbox SET delivered = 1, read_at = COALESCE(read_at, ?) WHERE id = ?");
3281
+ const nowMs = Date.now();
3282
+ const now = new Date(nowMs).toISOString();
3283
+ let requeuedCount = 0;
3284
+ let staleCount = 0;
2966
3285
  for (const row of rows) {
2967
3286
  const metadata = row.metadata ? JSON.parse(row.metadata) : {};
3287
+ const timestamp = getStringMetadataValue(metadata, ["timestamp", "ts", "externalTs", "external_ts"]) ??
3288
+ row.external_ts;
3289
+ if (isStaleSlackMessageTimestamp(row.source, timestamp, nowMs)) {
3290
+ markStaleRead.run(now, row.inbox_id);
3291
+ staleCount += 1;
3292
+ continue;
3293
+ }
2968
3294
  const channel = typeof metadata.channel === "string" ? metadata.channel : "";
2969
- const preferredAgentId = row.source === "agent" ? row.target_agent_id : null;
3295
+ const preferredAgentId = row.target_agent_id || null;
2970
3296
  this.upsertBacklogEntry(row.message_id, row.thread_id, channel, reason, "pending", preferredAgentId, null);
2971
3297
  markDelivered.run(row.inbox_id);
3298
+ requeuedCount += 1;
3299
+ }
3300
+ if (staleCount > 0) {
3301
+ console.info(`[broker-core] skipped stale Slack requeue rows older than 15m: inbox=${staleCount}`);
2972
3302
  }
2973
- return rows.length;
3303
+ return requeuedCount;
2974
3304
  }
2975
3305
  getBacklogById(id) {
2976
3306
  const db = this.getDb();
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PinetMailClass } from "./mail-classification.js";
2
2
  export declare const DEFAULT_EXTERNAL_THREAD_SOURCE = "external";
3
+ export type AgentSupervisionState = "root" | "supervised" | "orphaned" | "stopping";
3
4
  export interface AgentInfo {
4
5
  id: string;
5
6
  stableId?: string | null;
@@ -11,6 +12,14 @@ export interface AgentInfo {
11
12
  lastHeartbeat: string;
12
13
  metadata: Record<string, unknown> | null;
13
14
  status: "working" | "idle";
15
+ parentAgentId?: string | null;
16
+ rootAgentId?: string | null;
17
+ treeDepth?: number;
18
+ spawnedByAgentId?: string | null;
19
+ supervisionState?: AgentSupervisionState;
20
+ launchId?: string | null;
21
+ subtreeRole?: string | null;
22
+ laneId?: string | null;
14
23
  disconnectedAt?: string | null;
15
24
  resumableUntil?: string | null;
16
25
  idleSince?: string | null;
@@ -266,9 +275,10 @@ export declare const RPC_AUTH_REQUIRED = -32001;
266
275
  export declare const RPC_AGENT_NAME_CONFLICT = -32002;
267
276
  export declare const RPC_AGENT_STABLE_ID_CONFLICT = -32003;
268
277
  import { buildCompatibilityInstanceScope as _buildCompatibilityInstanceScope, buildCompatibilityWorkspaceScope as _buildCompatibilityWorkspaceScope, buildRuntimeScopeCarrier as _buildRuntimeScopeCarrier } from "@pinet/transport-core";
269
- import type { InboundMessage as _InboundMessage, NormalizedMessageContent as _NormalizedMessageContent, OutboundMessage as _OutboundMessage, AdapterCapabilityRequest as _AdapterCapabilityRequest, AdapterCapabilityResult as _AdapterCapabilityResult, AdapterCapabilityEffects as _AdapterCapabilityEffects, AdapterThreadClaimEffect as _AdapterThreadClaimEffect, MessageAdapter as _MessageAdapter, RuntimeScopeCarrier as _RuntimeScopeCarrier, WorkspaceInstallScopeCarrier as _WorkspaceInstallScopeCarrier, InstanceScopeCarrier as _InstanceScopeCarrier } from "@pinet/transport-core";
278
+ import type { InboundMessage as _InboundMessage, NormalizedMessageContent as _NormalizedMessageContent, OutboundAttachmentFile as _OutboundAttachmentFile, OutboundMessage as _OutboundMessage, AdapterCapabilityRequest as _AdapterCapabilityRequest, AdapterCapabilityResult as _AdapterCapabilityResult, AdapterCapabilityEffects as _AdapterCapabilityEffects, AdapterThreadClaimEffect as _AdapterThreadClaimEffect, MessageAdapter as _MessageAdapter, RuntimeScopeCarrier as _RuntimeScopeCarrier, WorkspaceInstallScopeCarrier as _WorkspaceInstallScopeCarrier, InstanceScopeCarrier as _InstanceScopeCarrier } from "@pinet/transport-core";
270
279
  export type InboundMessage = _InboundMessage;
271
280
  export type NormalizedMessageContent = _NormalizedMessageContent;
281
+ export type OutboundAttachmentFile = _OutboundAttachmentFile;
272
282
  export type OutboundMessage = _OutboundMessage;
273
283
  export type AdapterCapabilityRequest = _AdapterCapabilityRequest;
274
284
  export type AdapterCapabilityResult = _AdapterCapabilityResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinet/broker-core",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Transport-neutral broker kernel primitives for pi transports",
6
6
  "author": "Will Porcellini <5994936+gugu91@users.noreply.github.com>",
@@ -43,7 +43,7 @@
43
43
  "test": "vitest run *.test.ts"
44
44
  },
45
45
  "dependencies": {
46
- "@pinet/transport-core": "0.1.2"
46
+ "@pinet/transport-core": "0.2.1"
47
47
  },
48
48
  "types": "./dist/index.d.ts"
49
49
  }