@pinet/broker-core 0.1.0

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/schema.js ADDED
@@ -0,0 +1,3076 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import * as crypto from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { classifyPinetMail } from "./mail-classification.js";
6
+ import { getDefaultDbPath } from "./paths.js";
7
+ import { DEFAULT_EXTERNAL_THREAD_SOURCE } from "./types.js";
8
+ function getSqliteJournalMode(result) {
9
+ const mode = result?.journal_mode?.trim().toLowerCase();
10
+ return mode && mode.length > 0 ? mode : "unknown";
11
+ }
12
+ function isSqliteWalEnabled(result) {
13
+ return getSqliteJournalMode(result) === "wal";
14
+ }
15
+ function buildSqliteWalFallbackWarning(component, result) {
16
+ return `[${component}] SQLite WAL mode not available, using ${getSqliteJournalMode(result)} journal mode fallback`;
17
+ }
18
+ // ─── Mappers ─────────────────────────────────────────────
19
+ function rowToAgent(row) {
20
+ return {
21
+ id: row.id,
22
+ stableId: row.stable_id,
23
+ name: row.name,
24
+ emoji: row.emoji,
25
+ pid: row.pid,
26
+ connectedAt: row.connected_at,
27
+ lastSeen: row.last_seen,
28
+ lastHeartbeat: row.last_heartbeat,
29
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
30
+ status: row.status === "working" ? "working" : "idle",
31
+ disconnectedAt: row.disconnected_at,
32
+ resumableUntil: row.resumable_until,
33
+ idleSince: row.idle_since,
34
+ lastActivity: row.last_activity,
35
+ };
36
+ }
37
+ function rowToThread(row) {
38
+ return {
39
+ threadId: row.thread_id,
40
+ source: row.source,
41
+ channel: row.channel,
42
+ ownerAgent: row.owner_agent,
43
+ ownerBinding: row.owner_binding === "explicit" ? "explicit" : null,
44
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
45
+ createdAt: row.created_at,
46
+ updatedAt: row.updated_at,
47
+ };
48
+ }
49
+ function rowToPortLease(row) {
50
+ return {
51
+ id: row.id,
52
+ purpose: row.purpose,
53
+ port: row.port,
54
+ host: row.host,
55
+ ownerAgentId: row.owner_agent_id,
56
+ pid: row.pid,
57
+ status: normalizePortLeaseStatus(row.status),
58
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
59
+ acquiredAt: row.acquired_at,
60
+ renewedAt: row.renewed_at,
61
+ expiresAt: row.expires_at,
62
+ releasedAt: row.released_at,
63
+ };
64
+ }
65
+ function normalizePortLeaseStatus(value) {
66
+ if (value === "released" || value === "expired") {
67
+ return value;
68
+ }
69
+ return "active";
70
+ }
71
+ function getStringMetadataValue(metadata, keys) {
72
+ for (const key of keys) {
73
+ const value = metadata?.[key];
74
+ if (typeof value === "string" && value.trim().length > 0) {
75
+ return value.trim();
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ const INTERNAL_AGENT_SOURCE = "agent";
81
+ function isExternalTransportSource(source) {
82
+ return source.trim().length > 0 && source !== INTERNAL_AGENT_SOURCE;
83
+ }
84
+ function deriveMessageSyncIdentity(threadId, source, metadata) {
85
+ const explicitExternalId = getStringMetadataValue(metadata, [
86
+ "externalId",
87
+ "external_id",
88
+ "transportMessageId",
89
+ "transport_message_id",
90
+ ]);
91
+ const explicitExternalTs = getStringMetadataValue(metadata, [
92
+ "externalTs",
93
+ "external_ts",
94
+ "timestamp",
95
+ "ts",
96
+ ]);
97
+ if (explicitExternalId) {
98
+ return { externalId: explicitExternalId, externalTs: explicitExternalTs };
99
+ }
100
+ if (!isExternalTransportSource(source)) {
101
+ return { externalId: null, externalTs: explicitExternalTs };
102
+ }
103
+ const channel = getStringMetadataValue(metadata, [
104
+ "transportChannelId",
105
+ "transport_channel_id",
106
+ "conversationId",
107
+ "conversation_id",
108
+ "channel",
109
+ "channelId",
110
+ "channel_id",
111
+ ]);
112
+ const timestamp = getStringMetadataValue(metadata, [
113
+ "transportTimestamp",
114
+ "transport_timestamp",
115
+ "timestamp",
116
+ "ts",
117
+ ]);
118
+ if (timestamp && channel) {
119
+ return { externalId: `${channel}:${timestamp}`, externalTs: timestamp };
120
+ }
121
+ if (timestamp && threadId.trim().length > 0) {
122
+ return { externalId: `${threadId}:${timestamp}`, externalTs: timestamp };
123
+ }
124
+ return { externalId: null, externalTs: explicitExternalTs };
125
+ }
126
+ function parseJsonMetadata(value) {
127
+ if (!value)
128
+ return {};
129
+ try {
130
+ const parsed = JSON.parse(value);
131
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
132
+ ? parsed
133
+ : {};
134
+ }
135
+ catch {
136
+ return {};
137
+ }
138
+ }
139
+ function appendMetadataAudit(metadata, key, audit) {
140
+ const existing = Array.isArray(metadata[key])
141
+ ? metadata[key].filter((item) => item !== null && typeof item === "object" && !Array.isArray(item))
142
+ : [];
143
+ metadata[key] = [...existing.slice(-19), audit];
144
+ }
145
+ const PINET_MAIL_CLASS_PRIORITY = {
146
+ steering: 0,
147
+ fwup: 1,
148
+ maintenance_context: 2,
149
+ };
150
+ function comparePinetMailClassPriority(a, b) {
151
+ return PINET_MAIL_CLASS_PRIORITY[a] - PINET_MAIL_CLASS_PRIORITY[b];
152
+ }
153
+ function emptyMailClassCounts() {
154
+ return { steering: 0, fwup: 0, maintenance_context: 0 };
155
+ }
156
+ function rowToBrokerMessage(row) {
157
+ return {
158
+ id: row.id,
159
+ threadId: row.thread_id,
160
+ source: row.source,
161
+ direction: row.direction,
162
+ sender: row.sender,
163
+ body: row.body,
164
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
165
+ ...(row.external_id ? { externalId: row.external_id } : {}),
166
+ ...(row.external_ts ? { externalTs: row.external_ts } : {}),
167
+ createdAt: row.created_at,
168
+ };
169
+ }
170
+ function rowToBacklog(row) {
171
+ return {
172
+ id: row.id,
173
+ threadId: row.thread_id,
174
+ channel: row.channel,
175
+ messageId: row.message_id,
176
+ reason: row.reason,
177
+ status: row.status === "assigned" ? "assigned" : row.status === "dropped" ? "dropped" : "pending",
178
+ preferredAgentId: row.preferred_agent_id,
179
+ assignedAgentId: row.assigned_agent_id,
180
+ attemptCount: row.attempt_count,
181
+ lastAttemptAt: row.last_attempt_at,
182
+ createdAt: row.created_at,
183
+ updatedAt: row.updated_at,
184
+ };
185
+ }
186
+ function normalizeTaskAssignmentKind(value) {
187
+ switch (value) {
188
+ case "implementation":
189
+ case "review":
190
+ case "qa":
191
+ case "merge":
192
+ case "interactive":
193
+ case "unknown":
194
+ return value;
195
+ default:
196
+ return "unknown";
197
+ }
198
+ }
199
+ function buildTaskAssignmentRepoKey(input) {
200
+ const owner = input.repoOwner?.trim().toLowerCase();
201
+ const name = input.repoName?.trim().toLowerCase();
202
+ if (owner && name) {
203
+ return `${owner}/${name}`;
204
+ }
205
+ const repoRoot = input.repoRoot?.trim();
206
+ return repoRoot ? `root:${repoRoot}` : "repo_unknown";
207
+ }
208
+ function rowToTaskAssignment(row) {
209
+ return {
210
+ id: row.id,
211
+ agentId: row.agent_id,
212
+ issueNumber: row.issue_number,
213
+ branch: row.branch,
214
+ prNumber: row.pr_number,
215
+ status: row.status,
216
+ threadId: row.thread_id,
217
+ sourceMessageId: row.source_message_id,
218
+ repoOwner: row.repo_owner,
219
+ repoName: row.repo_name,
220
+ repoRoot: row.repo_root,
221
+ taskKind: normalizeTaskAssignmentKind(row.task_kind),
222
+ createdAt: row.created_at,
223
+ updatedAt: row.updated_at,
224
+ };
225
+ }
226
+ function rowToScheduledWakeup(row) {
227
+ return {
228
+ id: row.id,
229
+ agentId: row.agent_id,
230
+ threadId: row.thread_id,
231
+ body: row.body,
232
+ fireAt: row.fire_at,
233
+ createdAt: row.created_at,
234
+ };
235
+ }
236
+ const PINET_LANE_STATES = new Set([
237
+ "planned",
238
+ "active",
239
+ "blocked",
240
+ "review",
241
+ "ready",
242
+ "done",
243
+ "cancelled",
244
+ "detached",
245
+ ]);
246
+ const PINET_LANE_ROLES = new Set([
247
+ "broker",
248
+ "coordinator",
249
+ "pm",
250
+ "lead",
251
+ "implementer",
252
+ "reviewer",
253
+ "second_pass_reviewer",
254
+ "observer",
255
+ ]);
256
+ function parseMetadataJson(value) {
257
+ if (!value)
258
+ return null;
259
+ try {
260
+ return JSON.parse(value);
261
+ }
262
+ catch {
263
+ return null;
264
+ }
265
+ }
266
+ function rowToPinetLaneParticipant(row) {
267
+ return {
268
+ laneId: row.lane_id,
269
+ agentId: row.agent_id,
270
+ role: normalizePinetLaneRole(row.lane_role),
271
+ status: row.status,
272
+ summary: row.summary,
273
+ metadata: parseMetadataJson(row.metadata),
274
+ createdAt: row.created_at,
275
+ updatedAt: row.updated_at,
276
+ lastActivityAt: row.last_activity_at,
277
+ };
278
+ }
279
+ function rowToPinetLane(row, participants = []) {
280
+ return {
281
+ laneId: row.lane_id,
282
+ name: row.name,
283
+ task: row.task,
284
+ issueNumber: row.issue_number,
285
+ prNumber: row.pr_number,
286
+ threadId: row.thread_id,
287
+ ownerAgentId: row.owner_agent_id,
288
+ implementationLeadAgentId: row.implementation_lead_agent_id,
289
+ pmMode: row.pm_mode === 1,
290
+ state: normalizePinetLaneState(row.state),
291
+ summary: row.summary,
292
+ metadata: parseMetadataJson(row.metadata),
293
+ createdAt: row.created_at,
294
+ updatedAt: row.updated_at,
295
+ lastActivityAt: row.last_activity_at,
296
+ participants,
297
+ };
298
+ }
299
+ function normalizePinetLaneState(value, fallback = "active") {
300
+ return typeof value === "string" && PINET_LANE_STATES.has(value)
301
+ ? value
302
+ : fallback;
303
+ }
304
+ function requirePinetLaneState(value) {
305
+ if (typeof value === "string" && PINET_LANE_STATES.has(value)) {
306
+ return value;
307
+ }
308
+ throw new Error(`Invalid Pinet lane state: ${String(value)}`);
309
+ }
310
+ function normalizePinetLaneRole(value, fallback = "observer") {
311
+ return typeof value === "string" && PINET_LANE_ROLES.has(value)
312
+ ? value
313
+ : fallback;
314
+ }
315
+ function requirePinetLaneRole(value) {
316
+ if (typeof value === "string" && PINET_LANE_ROLES.has(value)) {
317
+ return value;
318
+ }
319
+ throw new Error(`Invalid Pinet lane role: ${String(value)}`);
320
+ }
321
+ function normalizeLaneId(value) {
322
+ const trimmed = value.trim();
323
+ if (!trimmed) {
324
+ throw new Error("laneId must be a non-empty string");
325
+ }
326
+ return trimmed;
327
+ }
328
+ function normalizeOptionalText(value) {
329
+ if (value === undefined)
330
+ return undefined;
331
+ if (value === null)
332
+ return null;
333
+ const trimmed = value.trim();
334
+ return trimmed.length > 0 ? trimmed : null;
335
+ }
336
+ function normalizeOptionalInteger(value) {
337
+ if (value === undefined || value === null)
338
+ return value;
339
+ if (!Number.isInteger(value) || value < 0) {
340
+ throw new Error("lane issue/PR numbers must be non-negative integers");
341
+ }
342
+ return value;
343
+ }
344
+ function serializeOptionalMetadata(value) {
345
+ if (value === undefined)
346
+ return undefined;
347
+ return value === null ? null : JSON.stringify(value);
348
+ }
349
+ function normalizePortLeasePurpose(value) {
350
+ const trimmed = value.trim();
351
+ if (!trimmed) {
352
+ throw new Error("purpose must be a non-empty string");
353
+ }
354
+ return trimmed;
355
+ }
356
+ function normalizePortLeaseId(value) {
357
+ const trimmed = value.trim();
358
+ if (!trimmed) {
359
+ throw new Error("leaseId must be a non-empty string");
360
+ }
361
+ return trimmed;
362
+ }
363
+ function normalizeOptionalPortLeaseOwner(value) {
364
+ if (value === undefined)
365
+ return undefined;
366
+ if (value === null)
367
+ return null;
368
+ const trimmed = value.trim();
369
+ return trimmed.length > 0 ? trimmed : null;
370
+ }
371
+ function normalizePortLeaseHost(value) {
372
+ const trimmed = value?.trim();
373
+ return trimmed && trimmed.length > 0 ? trimmed : "127.0.0.1";
374
+ }
375
+ function normalizePortLeasePort(value, label = "port") {
376
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
377
+ throw new Error(`${label} must be an integer between 1 and 65535`);
378
+ }
379
+ return value;
380
+ }
381
+ function normalizePortLeaseTtlMs(value) {
382
+ if (!Number.isFinite(value) || value <= 0) {
383
+ throw new Error("ttlMs must be a positive finite number");
384
+ }
385
+ return Math.max(1, Math.round(value));
386
+ }
387
+ function normalizePortLeasePid(value) {
388
+ if (value === undefined || value === null)
389
+ return null;
390
+ if (!Number.isInteger(value) || value < 1) {
391
+ throw new Error("pid must be a positive integer");
392
+ }
393
+ return value;
394
+ }
395
+ function normalizePortLeaseRange(minPort, maxPort) {
396
+ const min = normalizePortLeasePort(minPort ?? 49152, "minPort");
397
+ const max = normalizePortLeasePort(maxPort ?? 65535, "maxPort");
398
+ if (min > max) {
399
+ throw new Error("minPort must be less than or equal to maxPort");
400
+ }
401
+ return { minPort: min, maxPort: max };
402
+ }
403
+ // ─── Default DB path ─────────────────────────────────────
404
+ export function defaultDbPath() {
405
+ return getDefaultDbPath();
406
+ }
407
+ export const DEFAULT_RESUMABLE_WINDOW_MS = 15_000;
408
+ export const DEFAULT_DISCONNECTED_PURGE_GRACE_MS = 60 * 60_000;
409
+ export const CURRENT_BROKER_SCHEMA_VERSION = 17;
410
+ const REQUIRED_AGENT_LIFECYCLE_COLUMNS = [
411
+ "stable_id",
412
+ "metadata",
413
+ "status",
414
+ "last_heartbeat",
415
+ "disconnected_at",
416
+ "resumable_until",
417
+ ];
418
+ function getUserVersion(db) {
419
+ const row = db.prepare("PRAGMA user_version").get();
420
+ return Number(row?.user_version ?? 0);
421
+ }
422
+ function setUserVersion(db, version) {
423
+ db.exec(`PRAGMA user_version = ${version}`);
424
+ }
425
+ function getTableColumns(db, tableName) {
426
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
427
+ return new Set(rows.map((row) => row.name));
428
+ }
429
+ function tableExists(db, tableName) {
430
+ const row = db
431
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
432
+ .get(tableName);
433
+ return row !== undefined;
434
+ }
435
+ function ensureColumn(db, tableName, columnName, sql) {
436
+ if (!getTableColumns(db, tableName).has(columnName)) {
437
+ db.exec(sql);
438
+ }
439
+ }
440
+ function createCoreTables(db) {
441
+ db.exec(`
442
+ CREATE TABLE IF NOT EXISTS agents (
443
+ id TEXT PRIMARY KEY NOT NULL,
444
+ name TEXT NOT NULL,
445
+ emoji TEXT NOT NULL,
446
+ pid INTEGER NOT NULL,
447
+ connected_at TEXT NOT NULL,
448
+ last_seen TEXT NOT NULL
449
+ );
450
+
451
+ CREATE TABLE IF NOT EXISTS threads (
452
+ thread_id TEXT PRIMARY KEY NOT NULL,
453
+ source TEXT NOT NULL,
454
+ channel TEXT NOT NULL,
455
+ owner_agent TEXT,
456
+ owner_binding TEXT,
457
+ metadata TEXT,
458
+ created_at TEXT NOT NULL,
459
+ updated_at TEXT NOT NULL
460
+ );
461
+
462
+ CREATE TABLE IF NOT EXISTS messages (
463
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
464
+ thread_id TEXT NOT NULL,
465
+ source TEXT NOT NULL,
466
+ direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
467
+ sender TEXT NOT NULL,
468
+ body TEXT NOT NULL,
469
+ metadata TEXT,
470
+ external_id TEXT,
471
+ external_ts TEXT,
472
+ created_at TEXT NOT NULL
473
+ );
474
+
475
+ CREATE TABLE IF NOT EXISTS inbox (
476
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
477
+ agent_id TEXT NOT NULL,
478
+ message_id INTEGER NOT NULL,
479
+ delivered INTEGER NOT NULL DEFAULT 0,
480
+ read_at TEXT,
481
+ created_at TEXT NOT NULL
482
+ );
483
+
484
+ CREATE INDEX IF NOT EXISTS idx_messages_thread
485
+ ON messages(thread_id, created_at);
486
+ CREATE INDEX IF NOT EXISTS idx_inbox_agent_delivered
487
+ ON inbox(agent_id, delivered, created_at);
488
+ CREATE INDEX IF NOT EXISTS idx_inbox_message
489
+ ON inbox(message_id);
490
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_agent_message_pending_unique
491
+ ON inbox(agent_id, message_id)
492
+ WHERE delivered = 0;
493
+ `);
494
+ const messageColumns = getTableColumns(db, "messages");
495
+ if (messageColumns.has("external_id")) {
496
+ db.exec(`
497
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_source_external_id
498
+ ON messages(source, external_id)
499
+ WHERE external_id IS NOT NULL;
500
+ `);
501
+ }
502
+ if (messageColumns.has("external_ts")) {
503
+ db.exec(`
504
+ CREATE INDEX IF NOT EXISTS idx_messages_source_external_ts
505
+ ON messages(source, external_ts);
506
+ `);
507
+ }
508
+ if (getTableColumns(db, "inbox").has("read_at")) {
509
+ db.exec(`
510
+ CREATE INDEX IF NOT EXISTS idx_inbox_agent_read
511
+ ON inbox(agent_id, read_at, created_at);
512
+ `);
513
+ }
514
+ }
515
+ function createBacklogTable(db) {
516
+ db.exec(`
517
+ CREATE TABLE IF NOT EXISTS unrouted_backlog (
518
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
519
+ thread_id TEXT NOT NULL,
520
+ channel TEXT NOT NULL,
521
+ message_id INTEGER NOT NULL UNIQUE,
522
+ reason TEXT NOT NULL,
523
+ status TEXT NOT NULL CHECK(status IN ('pending', 'assigned', 'dropped')),
524
+ preferred_agent_id TEXT,
525
+ assigned_agent_id TEXT,
526
+ attempt_count INTEGER NOT NULL DEFAULT 0,
527
+ last_attempt_at TEXT,
528
+ created_at TEXT NOT NULL,
529
+ updated_at TEXT NOT NULL
530
+ );
531
+
532
+ CREATE INDEX IF NOT EXISTS idx_backlog_status_created
533
+ ON unrouted_backlog(status, created_at);
534
+ CREATE INDEX IF NOT EXISTS idx_backlog_thread_status
535
+ ON unrouted_backlog(thread_id, status);
536
+ CREATE INDEX IF NOT EXISTS idx_backlog_preferred_agent_status
537
+ ON unrouted_backlog(preferred_agent_id, status);
538
+ `);
539
+ }
540
+ function createSettingsTable(db) {
541
+ db.exec(`
542
+ CREATE TABLE IF NOT EXISTS settings (
543
+ key TEXT PRIMARY KEY NOT NULL,
544
+ value TEXT NOT NULL,
545
+ updated_at TEXT NOT NULL
546
+ );
547
+ `);
548
+ }
549
+ function addAgentLifecycleColumns(db) {
550
+ ensureColumn(db, "agents", "stable_id", "ALTER TABLE agents ADD COLUMN stable_id TEXT");
551
+ ensureColumn(db, "agents", "metadata", "ALTER TABLE agents ADD COLUMN metadata TEXT");
552
+ ensureColumn(db, "agents", "status", "ALTER TABLE agents ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'");
553
+ ensureColumn(db, "agents", "last_heartbeat", "ALTER TABLE agents ADD COLUMN last_heartbeat TEXT");
554
+ ensureColumn(db, "agents", "disconnected_at", "ALTER TABLE agents ADD COLUMN disconnected_at TEXT");
555
+ ensureColumn(db, "agents", "resumable_until", "ALTER TABLE agents ADD COLUMN resumable_until TEXT");
556
+ db.exec(`
557
+ UPDATE agents
558
+ SET last_heartbeat = COALESCE(last_heartbeat, last_seen)
559
+ WHERE last_heartbeat IS NULL;
560
+
561
+ CREATE INDEX IF NOT EXISTS idx_agents_last_heartbeat
562
+ ON agents(last_heartbeat);
563
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_stable_id
564
+ ON agents(stable_id)
565
+ WHERE stable_id IS NOT NULL;
566
+ `);
567
+ }
568
+ function addObservabilityColumns(db) {
569
+ ensureColumn(db, "agents", "idle_since", "ALTER TABLE agents ADD COLUMN idle_since TEXT");
570
+ ensureColumn(db, "agents", "last_activity", "ALTER TABLE agents ADD COLUMN last_activity TEXT");
571
+ // Set idle_since for currently idle agents that lack it
572
+ db.exec(`
573
+ UPDATE agents
574
+ SET idle_since = COALESCE(idle_since, last_seen)
575
+ WHERE status = 'idle' AND idle_since IS NULL;
576
+ `);
577
+ }
578
+ function addThreadOwnershipBindingColumn(db) {
579
+ createCoreTables(db);
580
+ ensureColumn(db, "threads", "owner_binding", "ALTER TABLE threads ADD COLUMN owner_binding TEXT");
581
+ }
582
+ function addInboxReadCursorColumn(db) {
583
+ createCoreTables(db);
584
+ ensureColumn(db, "inbox", "read_at", "ALTER TABLE inbox ADD COLUMN read_at TEXT");
585
+ db.exec(`
586
+ CREATE INDEX IF NOT EXISTS idx_inbox_agent_read
587
+ ON inbox(agent_id, read_at, created_at);
588
+ `);
589
+ }
590
+ function addThreadMetadataColumn(db) {
591
+ createCoreTables(db);
592
+ ensureColumn(db, "threads", "metadata", "ALTER TABLE threads ADD COLUMN metadata TEXT");
593
+ }
594
+ function backfillMessageSyncIdentities(db) {
595
+ const rows = db
596
+ .prepare("SELECT id, thread_id, source, metadata FROM messages WHERE metadata IS NOT NULL")
597
+ .all();
598
+ const update = db.prepare("UPDATE messages SET external_id = ?, external_ts = ? WHERE id = ?");
599
+ for (const row of rows) {
600
+ if (!row.metadata)
601
+ continue;
602
+ try {
603
+ const metadata = JSON.parse(row.metadata);
604
+ const identity = deriveMessageSyncIdentity(row.thread_id, row.source, metadata);
605
+ if (identity.externalId || identity.externalTs) {
606
+ update.run(identity.externalId, identity.externalTs, row.id);
607
+ }
608
+ }
609
+ catch {
610
+ // Keep corrupt legacy metadata readable; it simply cannot receive a sync identity.
611
+ }
612
+ }
613
+ }
614
+ function pickBacklogStatus(left, right) {
615
+ if (left === "assigned" || right === "assigned")
616
+ return "assigned";
617
+ if (left === "pending" || right === "pending")
618
+ return "pending";
619
+ return "dropped";
620
+ }
621
+ function maxNullableIso(left, right) {
622
+ if (!left)
623
+ return right;
624
+ if (!right)
625
+ return left;
626
+ return left >= right ? left : right;
627
+ }
628
+ function minIso(left, right) {
629
+ return left <= right ? left : right;
630
+ }
631
+ function mergeBacklogMessageId(db, keepMessageId, duplicateMessageId) {
632
+ const getBacklog = db.prepare("SELECT * FROM unrouted_backlog WHERE message_id = ?");
633
+ const keep = getBacklog.get(keepMessageId);
634
+ const duplicate = getBacklog.get(duplicateMessageId);
635
+ if (!duplicate)
636
+ return;
637
+ if (!keep) {
638
+ db.prepare("UPDATE unrouted_backlog SET message_id = ? WHERE id = ?").run(keepMessageId, duplicate.id);
639
+ return;
640
+ }
641
+ const mergedStatus = pickBacklogStatus(keep.status, duplicate.status);
642
+ db.prepare(`UPDATE unrouted_backlog
643
+ SET reason = ?,
644
+ status = ?,
645
+ preferred_agent_id = ?,
646
+ assigned_agent_id = ?,
647
+ attempt_count = ?,
648
+ last_attempt_at = ?,
649
+ created_at = ?,
650
+ updated_at = ?
651
+ WHERE id = ?`).run(keep.reason || duplicate.reason, mergedStatus, keep.preferred_agent_id ?? duplicate.preferred_agent_id, mergedStatus === "assigned" ? (keep.assigned_agent_id ?? duplicate.assigned_agent_id) : null, Math.max(keep.attempt_count, duplicate.attempt_count), maxNullableIso(keep.last_attempt_at, duplicate.last_attempt_at), minIso(keep.created_at, duplicate.created_at), maxNullableIso(keep.updated_at, duplicate.updated_at) ?? keep.updated_at, keep.id);
652
+ db.prepare("DELETE FROM unrouted_backlog WHERE id = ?").run(duplicate.id);
653
+ }
654
+ function consolidateDuplicateMessageSyncIdentities(db) {
655
+ const duplicateRows = db
656
+ .prepare(`SELECT source, external_id, MIN(id) AS keep_id
657
+ FROM messages
658
+ WHERE external_id IS NOT NULL
659
+ GROUP BY source, external_id
660
+ HAVING COUNT(*) > 1`)
661
+ .all();
662
+ const findDuplicates = db.prepare(`SELECT id
663
+ FROM messages
664
+ WHERE source = ?
665
+ AND external_id = ?
666
+ AND id <> ?`);
667
+ const repointInbox = db.prepare("UPDATE inbox SET message_id = ? WHERE message_id = ?");
668
+ const repointTaskAssignments = tableExists(db, "task_assignments")
669
+ ? db.prepare("UPDATE task_assignments SET source_message_id = ? WHERE source_message_id = ?")
670
+ : null;
671
+ const hasBacklog = tableExists(db, "unrouted_backlog");
672
+ const clearDuplicate = db.prepare(`UPDATE messages
673
+ SET external_id = NULL,
674
+ external_ts = NULL
675
+ WHERE id = ?`);
676
+ for (const row of duplicateRows) {
677
+ const duplicates = findDuplicates.all(row.source, row.external_id, row.keep_id);
678
+ for (const duplicate of duplicates) {
679
+ repointInbox.run(row.keep_id, duplicate.id);
680
+ repointTaskAssignments?.run(row.keep_id, duplicate.id);
681
+ if (hasBacklog) {
682
+ mergeBacklogMessageId(db, row.keep_id, duplicate.id);
683
+ }
684
+ clearDuplicate.run(duplicate.id);
685
+ }
686
+ }
687
+ }
688
+ function deleteDuplicateInboxRows(db) {
689
+ db.exec(`
690
+ UPDATE inbox
691
+ SET read_at = (
692
+ SELECT MAX(duplicate.read_at)
693
+ FROM inbox AS duplicate
694
+ WHERE duplicate.agent_id = inbox.agent_id
695
+ AND duplicate.message_id = inbox.message_id
696
+ AND duplicate.read_at IS NOT NULL
697
+ )
698
+ WHERE EXISTS (
699
+ SELECT 1
700
+ FROM inbox AS duplicate
701
+ WHERE duplicate.agent_id = inbox.agent_id
702
+ AND duplicate.message_id = inbox.message_id
703
+ AND duplicate.read_at IS NOT NULL
704
+ );
705
+
706
+ DELETE FROM inbox
707
+ WHERE id NOT IN (
708
+ SELECT COALESCE(
709
+ MIN(CASE WHEN delivered = 1 THEN id END),
710
+ MIN(id)
711
+ )
712
+ FROM inbox
713
+ GROUP BY agent_id, message_id
714
+ );
715
+ `);
716
+ }
717
+ function addMessageSyncIdentityColumns(db) {
718
+ ensureColumn(db, "messages", "external_id", "ALTER TABLE messages ADD COLUMN external_id TEXT");
719
+ ensureColumn(db, "messages", "external_ts", "ALTER TABLE messages ADD COLUMN external_ts TEXT");
720
+ backfillMessageSyncIdentities(db);
721
+ consolidateDuplicateMessageSyncIdentities(db);
722
+ deleteDuplicateInboxRows(db);
723
+ db.exec(`
724
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_source_external_id
725
+ ON messages(source, external_id)
726
+ WHERE external_id IS NOT NULL;
727
+ CREATE INDEX IF NOT EXISTS idx_messages_source_external_ts
728
+ ON messages(source, external_ts);
729
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_agent_message_pending_unique
730
+ ON inbox(agent_id, message_id)
731
+ WHERE delivered = 0;
732
+ `);
733
+ }
734
+ function addBacklogAffinityColumns(db) {
735
+ ensureColumn(db, "unrouted_backlog", "preferred_agent_id", "ALTER TABLE unrouted_backlog ADD COLUMN preferred_agent_id TEXT");
736
+ db.exec(`
737
+ CREATE INDEX IF NOT EXISTS idx_backlog_preferred_agent_status
738
+ ON unrouted_backlog(preferred_agent_id, status);
739
+ `);
740
+ }
741
+ function createTaskAssignmentTable(db) {
742
+ db.exec(`
743
+ CREATE TABLE IF NOT EXISTS task_assignments (
744
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
745
+ agent_id TEXT NOT NULL,
746
+ issue_number INTEGER NOT NULL,
747
+ branch TEXT,
748
+ pr_number INTEGER,
749
+ status TEXT NOT NULL DEFAULT 'assigned'
750
+ CHECK(status IN ('assigned', 'branch_pushed', 'pr_open', 'pr_merged', 'pr_closed')),
751
+ thread_id TEXT NOT NULL,
752
+ source_message_id INTEGER,
753
+ repo_key TEXT NOT NULL DEFAULT 'repo_unknown',
754
+ repo_owner TEXT,
755
+ repo_name TEXT,
756
+ repo_root TEXT,
757
+ task_kind TEXT NOT NULL DEFAULT 'unknown'
758
+ CHECK(task_kind IN ('implementation', 'review', 'qa', 'merge', 'interactive', 'unknown')),
759
+ created_at TEXT NOT NULL,
760
+ updated_at TEXT NOT NULL,
761
+ UNIQUE(repo_key, issue_number)
762
+ );
763
+
764
+ CREATE INDEX IF NOT EXISTS idx_task_assignments_agent_status
765
+ ON task_assignments(agent_id, status, updated_at DESC);
766
+ CREATE INDEX IF NOT EXISTS idx_task_assignments_branch
767
+ ON task_assignments(branch);
768
+ `);
769
+ }
770
+ function migrateTaskAssignmentsToIssueOwnership(db) {
771
+ const existingTable = db
772
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'task_assignments'")
773
+ .get();
774
+ if (!existingTable) {
775
+ createTaskAssignmentTable(db);
776
+ return;
777
+ }
778
+ db.exec(`
779
+ ALTER TABLE task_assignments RENAME TO task_assignments_legacy;
780
+ DROP INDEX IF EXISTS idx_task_assignments_agent_status;
781
+ DROP INDEX IF EXISTS idx_task_assignments_branch;
782
+ `);
783
+ createTaskAssignmentTable(db);
784
+ db.exec(`
785
+ INSERT INTO task_assignments (
786
+ agent_id,
787
+ issue_number,
788
+ branch,
789
+ pr_number,
790
+ status,
791
+ thread_id,
792
+ source_message_id,
793
+ repo_key,
794
+ repo_owner,
795
+ repo_name,
796
+ repo_root,
797
+ task_kind,
798
+ created_at,
799
+ updated_at
800
+ )
801
+ SELECT
802
+ legacy.agent_id,
803
+ legacy.issue_number,
804
+ legacy.branch,
805
+ legacy.pr_number,
806
+ legacy.status,
807
+ legacy.thread_id,
808
+ legacy.source_message_id,
809
+ 'repo_unknown',
810
+ NULL,
811
+ NULL,
812
+ NULL,
813
+ 'unknown',
814
+ legacy.created_at,
815
+ legacy.updated_at
816
+ FROM task_assignments_legacy AS legacy
817
+ WHERE legacy.id = (
818
+ SELECT latest.id
819
+ FROM task_assignments_legacy AS latest
820
+ WHERE latest.issue_number = legacy.issue_number
821
+ ORDER BY latest.updated_at DESC, latest.created_at DESC, latest.id DESC
822
+ LIMIT 1
823
+ );
824
+
825
+ DROP TABLE task_assignments_legacy;
826
+ `);
827
+ }
828
+ function migrateTaskAssignmentsToRepoScopedTracking(db) {
829
+ if (!tableExists(db, "task_assignments")) {
830
+ createTaskAssignmentTable(db);
831
+ return;
832
+ }
833
+ const existingColumns = getTableColumns(db, "task_assignments");
834
+ if (!existingColumns.has("agent_id") || !existingColumns.has("issue_number")) {
835
+ db.exec("DROP TABLE task_assignments");
836
+ createTaskAssignmentTable(db);
837
+ return;
838
+ }
839
+ db.exec(`
840
+ ALTER TABLE task_assignments RENAME TO task_assignments_legacy;
841
+ DROP INDEX IF EXISTS idx_task_assignments_agent_status;
842
+ DROP INDEX IF EXISTS idx_task_assignments_branch;
843
+ `);
844
+ createTaskAssignmentTable(db);
845
+ const legacyColumns = getTableColumns(db, "task_assignments_legacy");
846
+ const repoKeyExpr = legacyColumns.has("repo_key")
847
+ ? "COALESCE(repo_key, 'repo_unknown')"
848
+ : "'repo_unknown'";
849
+ const repoOwnerExpr = legacyColumns.has("repo_owner") ? "repo_owner" : "NULL";
850
+ const repoNameExpr = legacyColumns.has("repo_name") ? "repo_name" : "NULL";
851
+ const repoRootExpr = legacyColumns.has("repo_root") ? "repo_root" : "NULL";
852
+ const taskKindExpr = legacyColumns.has("task_kind")
853
+ ? "COALESCE(task_kind, 'unknown')"
854
+ : "'unknown'";
855
+ db.exec(`
856
+ INSERT INTO task_assignments (
857
+ agent_id,
858
+ issue_number,
859
+ branch,
860
+ pr_number,
861
+ status,
862
+ thread_id,
863
+ source_message_id,
864
+ repo_key,
865
+ repo_owner,
866
+ repo_name,
867
+ repo_root,
868
+ task_kind,
869
+ created_at,
870
+ updated_at
871
+ )
872
+ SELECT
873
+ legacy.agent_id,
874
+ legacy.issue_number,
875
+ legacy.branch,
876
+ legacy.pr_number,
877
+ legacy.status,
878
+ legacy.thread_id,
879
+ legacy.source_message_id,
880
+ ${repoKeyExpr},
881
+ ${repoOwnerExpr},
882
+ ${repoNameExpr},
883
+ ${repoRootExpr},
884
+ ${taskKindExpr},
885
+ legacy.created_at,
886
+ legacy.updated_at
887
+ FROM task_assignments_legacy AS legacy
888
+ WHERE legacy.id = (
889
+ SELECT latest.id
890
+ FROM task_assignments_legacy AS latest
891
+ WHERE latest.issue_number = legacy.issue_number
892
+ ORDER BY latest.updated_at DESC, latest.created_at DESC, latest.id DESC
893
+ LIMIT 1
894
+ );
895
+
896
+ DROP TABLE task_assignments_legacy;
897
+ `);
898
+ }
899
+ function createScheduledWakeupsTable(db) {
900
+ db.exec(`
901
+ CREATE TABLE IF NOT EXISTS scheduled_wakeups (
902
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
903
+ agent_id TEXT NOT NULL,
904
+ agent_stable_id TEXT,
905
+ thread_id TEXT NOT NULL,
906
+ body TEXT NOT NULL,
907
+ fire_at TEXT NOT NULL,
908
+ created_at TEXT NOT NULL
909
+ );
910
+
911
+ CREATE INDEX IF NOT EXISTS idx_scheduled_wakeups_fire_target
912
+ ON scheduled_wakeups(fire_at, agent_stable_id, agent_id);
913
+ `);
914
+ }
915
+ function addScheduledWakeupStableIdColumn(db) {
916
+ ensureColumn(db, "scheduled_wakeups", "agent_stable_id", "ALTER TABLE scheduled_wakeups ADD COLUMN agent_stable_id TEXT");
917
+ db.exec(`
918
+ CREATE INDEX IF NOT EXISTS idx_scheduled_wakeups_fire_target
919
+ ON scheduled_wakeups(fire_at, agent_stable_id, agent_id);
920
+ `);
921
+ db.prepare(`UPDATE scheduled_wakeups
922
+ SET agent_stable_id = (
923
+ SELECT stable_id FROM agents WHERE agents.id = scheduled_wakeups.agent_id
924
+ )
925
+ WHERE agent_stable_id IS NULL`).run();
926
+ }
927
+ function createPortLeaseTable(db) {
928
+ db.exec(`
929
+ CREATE TABLE IF NOT EXISTS port_leases (
930
+ id TEXT PRIMARY KEY NOT NULL,
931
+ purpose TEXT NOT NULL,
932
+ port INTEGER NOT NULL,
933
+ host TEXT NOT NULL DEFAULT '127.0.0.1',
934
+ owner_agent_id TEXT,
935
+ pid INTEGER,
936
+ status TEXT NOT NULL DEFAULT 'active'
937
+ CHECK(status IN ('active', 'released', 'expired')),
938
+ metadata TEXT,
939
+ acquired_at TEXT NOT NULL,
940
+ renewed_at TEXT NOT NULL,
941
+ expires_at TEXT NOT NULL,
942
+ released_at TEXT
943
+ );
944
+
945
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_port_leases_active_port
946
+ ON port_leases(host, port)
947
+ WHERE status = 'active';
948
+ CREATE INDEX IF NOT EXISTS idx_port_leases_expiry
949
+ ON port_leases(status, expires_at);
950
+ CREATE INDEX IF NOT EXISTS idx_port_leases_owner
951
+ ON port_leases(owner_agent_id, status, expires_at);
952
+ CREATE INDEX IF NOT EXISTS idx_port_leases_purpose
953
+ ON port_leases(purpose, status, expires_at);
954
+ `);
955
+ }
956
+ function createPinetLaneTables(db) {
957
+ db.exec(`
958
+ CREATE TABLE IF NOT EXISTS pinet_lanes (
959
+ lane_id TEXT PRIMARY KEY NOT NULL,
960
+ name TEXT,
961
+ task TEXT,
962
+ issue_number INTEGER,
963
+ pr_number INTEGER,
964
+ thread_id TEXT,
965
+ owner_agent_id TEXT,
966
+ implementation_lead_agent_id TEXT,
967
+ pm_mode INTEGER NOT NULL DEFAULT 0 CHECK(pm_mode IN (0, 1)),
968
+ state TEXT NOT NULL DEFAULT 'active'
969
+ CHECK(state IN ('planned', 'active', 'blocked', 'review', 'ready', 'done', 'cancelled', 'detached')),
970
+ summary TEXT,
971
+ metadata TEXT,
972
+ created_at TEXT NOT NULL,
973
+ updated_at TEXT NOT NULL,
974
+ last_activity_at TEXT NOT NULL
975
+ );
976
+
977
+ CREATE TABLE IF NOT EXISTS pinet_lane_participants (
978
+ lane_id TEXT NOT NULL,
979
+ agent_id TEXT NOT NULL,
980
+ lane_role TEXT NOT NULL
981
+ CHECK(lane_role IN ('broker', 'coordinator', 'pm', 'lead', 'implementer', 'reviewer', 'second_pass_reviewer', 'observer')),
982
+ status TEXT,
983
+ summary TEXT,
984
+ metadata TEXT,
985
+ created_at TEXT NOT NULL,
986
+ updated_at TEXT NOT NULL,
987
+ last_activity_at TEXT NOT NULL,
988
+ PRIMARY KEY(lane_id, agent_id)
989
+ );
990
+
991
+ CREATE INDEX IF NOT EXISTS idx_pinet_lanes_state_updated
992
+ ON pinet_lanes(state, updated_at DESC);
993
+ CREATE INDEX IF NOT EXISTS idx_pinet_lanes_owner_state
994
+ ON pinet_lanes(owner_agent_id, state, updated_at DESC);
995
+ CREATE INDEX IF NOT EXISTS idx_pinet_lanes_issue
996
+ ON pinet_lanes(issue_number);
997
+ CREATE INDEX IF NOT EXISTS idx_pinet_lane_participants_agent
998
+ ON pinet_lane_participants(agent_id, lane_role, updated_at DESC);
999
+ `);
1000
+ }
1001
+ function runSchemaMigrations(db) {
1002
+ const currentVersion = getUserVersion(db);
1003
+ if (currentVersion >= CURRENT_BROKER_SCHEMA_VERSION) {
1004
+ return;
1005
+ }
1006
+ for (let nextVersion = currentVersion + 1; nextVersion <= CURRENT_BROKER_SCHEMA_VERSION; nextVersion += 1) {
1007
+ db.exec("BEGIN IMMEDIATE");
1008
+ try {
1009
+ switch (nextVersion) {
1010
+ case 1:
1011
+ createCoreTables(db);
1012
+ break;
1013
+ case 2:
1014
+ createBacklogTable(db);
1015
+ break;
1016
+ case 3:
1017
+ addAgentLifecycleColumns(db);
1018
+ break;
1019
+ case 4:
1020
+ addObservabilityColumns(db);
1021
+ break;
1022
+ case 5:
1023
+ addBacklogAffinityColumns(db);
1024
+ break;
1025
+ case 6:
1026
+ createTaskAssignmentTable(db);
1027
+ break;
1028
+ case 7:
1029
+ migrateTaskAssignmentsToIssueOwnership(db);
1030
+ break;
1031
+ case 8:
1032
+ createScheduledWakeupsTable(db);
1033
+ break;
1034
+ case 9:
1035
+ addScheduledWakeupStableIdColumn(db);
1036
+ break;
1037
+ case 10:
1038
+ createSettingsTable(db);
1039
+ break;
1040
+ case 11:
1041
+ addThreadOwnershipBindingColumn(db);
1042
+ break;
1043
+ case 12:
1044
+ addInboxReadCursorColumn(db);
1045
+ break;
1046
+ case 13:
1047
+ addMessageSyncIdentityColumns(db);
1048
+ break;
1049
+ case 14:
1050
+ addThreadMetadataColumn(db);
1051
+ break;
1052
+ case 15:
1053
+ createPinetLaneTables(db);
1054
+ break;
1055
+ case 16:
1056
+ createPortLeaseTable(db);
1057
+ break;
1058
+ case 17:
1059
+ migrateTaskAssignmentsToRepoScopedTracking(db);
1060
+ break;
1061
+ default:
1062
+ throw new Error(`Unsupported broker schema migration target: ${nextVersion}`);
1063
+ }
1064
+ setUserVersion(db, nextVersion);
1065
+ db.exec("COMMIT");
1066
+ }
1067
+ catch (error) {
1068
+ try {
1069
+ db.exec("ROLLBACK");
1070
+ }
1071
+ catch {
1072
+ /* best effort */
1073
+ }
1074
+ throw new Error(`Broker schema migration v${nextVersion} failed`, { cause: error });
1075
+ }
1076
+ }
1077
+ }
1078
+ // ─── BrokerDB ────────────────────────────────────────────
1079
+ export class BrokerDB {
1080
+ db = null;
1081
+ dbPath;
1082
+ allowedUsers = new Set();
1083
+ constructor(dbPath) {
1084
+ this.dbPath = dbPath ?? defaultDbPath();
1085
+ }
1086
+ initialize() {
1087
+ if (this.db)
1088
+ return;
1089
+ const dir = path.dirname(this.dbPath);
1090
+ fs.mkdirSync(dir, { recursive: true });
1091
+ try {
1092
+ this.openAndMigrate();
1093
+ }
1094
+ catch (error) {
1095
+ console.error(`[BrokerDB] Failed to open or migrate ${this.dbPath}; recreating from scratch`, error);
1096
+ this.resetDatabaseFiles();
1097
+ try {
1098
+ this.openAndMigrate();
1099
+ }
1100
+ catch (recreateError) {
1101
+ console.error(`[BrokerDB] Failed to recreate ${this.dbPath} from scratch`, recreateError);
1102
+ this.close();
1103
+ throw recreateError;
1104
+ }
1105
+ }
1106
+ // Broker startup reconciliation: any connected rows belong to a previous
1107
+ // broker session, so mark them resumably disconnected and wait for workers
1108
+ // to reconnect by stableId.
1109
+ this.reconcileStartupAgents();
1110
+ }
1111
+ /**
1112
+ * Mark all previously connected agents as resumably disconnected on broker
1113
+ * startup. Their inbox/thread ownership stays intact during the lease window
1114
+ * so reconnecting workers can resume by stableId.
1115
+ */
1116
+ reconcileStartupAgents(resumableForMs = DEFAULT_RESUMABLE_WINDOW_MS) {
1117
+ const db = this.getDb();
1118
+ const missingColumns = this.getMissingRequiredAgentLifecycleColumns(db);
1119
+ if (missingColumns.length > 0) {
1120
+ console.error(`[BrokerDB] Skipping startup reconciliation; agents table is missing columns: ${missingColumns.join(", ")}`);
1121
+ return;
1122
+ }
1123
+ const now = new Date();
1124
+ const disconnectedAt = now.toISOString();
1125
+ const resumableUntil = new Date(now.getTime() + resumableForMs).toISOString();
1126
+ db.prepare(`UPDATE agents
1127
+ SET disconnected_at = ?,
1128
+ resumable_until = COALESCE(resumable_until, ?)
1129
+ WHERE disconnected_at IS NULL`).run(disconnectedAt, resumableUntil);
1130
+ }
1131
+ close() {
1132
+ if (this.db) {
1133
+ this.db.close();
1134
+ this.db = null;
1135
+ }
1136
+ }
1137
+ // ─── Agents ──────────────────────────────────────────
1138
+ registerAgent(id, name, emoji, pid, metadata, stableId) {
1139
+ const db = this.getDb();
1140
+ const now = new Date().toISOString();
1141
+ const existing = stableId ? this.getAgentRowByStableId(stableId) : null;
1142
+ const existingById = this.getAgentRowById(existing?.id ?? id);
1143
+ const existingRow = existingById ?? existing;
1144
+ const agentId = existing?.id ?? id;
1145
+ const finalName = this.ensureUniqueAgentName(name, agentId);
1146
+ const finalEmoji = emoji.trim() || existingRow?.emoji || "";
1147
+ const persistedStableId = stableId ?? existing?.stable_id ?? existingById?.stable_id ?? null;
1148
+ // Reconnecting agents are authoritative for their current runtime identity. If a
1149
+ // stable session comes back with a new name/emoji, refresh the broker roster
1150
+ // instead of replaying stale values from the previous broker DB row.
1151
+ const finalMetadata = metadata ??
1152
+ (existingRow?.metadata
1153
+ ? JSON.parse(existingRow.metadata)
1154
+ : undefined);
1155
+ const meta = finalMetadata ? JSON.stringify(finalMetadata) : null;
1156
+ db.prepare(`INSERT INTO agents (
1157
+ id, stable_id, name, emoji, pid,
1158
+ connected_at, last_seen, last_heartbeat,
1159
+ metadata, status, disconnected_at, resumable_until,
1160
+ idle_since, last_activity
1161
+ )
1162
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'idle', NULL, NULL, ?, NULL)
1163
+ ON CONFLICT(id) DO UPDATE SET
1164
+ stable_id = COALESCE(excluded.stable_id, agents.stable_id),
1165
+ name = excluded.name,
1166
+ emoji = excluded.emoji,
1167
+ pid = excluded.pid,
1168
+ connected_at = excluded.connected_at,
1169
+ last_seen = excluded.last_seen,
1170
+ last_heartbeat = excluded.last_heartbeat,
1171
+ metadata = excluded.metadata,
1172
+ status = 'idle',
1173
+ disconnected_at = NULL,
1174
+ resumable_until = NULL,
1175
+ idle_since = excluded.idle_since,
1176
+ last_activity = NULL`).run(agentId, persistedStableId, finalName, finalEmoji, pid, now, now, now, meta, now);
1177
+ return {
1178
+ id: agentId,
1179
+ name: finalName,
1180
+ emoji: finalEmoji,
1181
+ pid,
1182
+ connectedAt: now,
1183
+ lastSeen: now,
1184
+ lastHeartbeat: now,
1185
+ metadata: finalMetadata ?? null,
1186
+ status: "idle",
1187
+ idleSince: now,
1188
+ lastActivity: null,
1189
+ };
1190
+ }
1191
+ unregisterAgent(id) {
1192
+ const db = this.getDb();
1193
+ const now = new Date().toISOString();
1194
+ this.withTransaction(() => {
1195
+ this.requeueUndeliveredMessagesInternal(id, "agent_disconnected");
1196
+ db.prepare("DELETE FROM inbox WHERE agent_id = ?").run(id);
1197
+ db.prepare("UPDATE agents SET disconnected_at = ?, resumable_until = NULL WHERE id = ?").run(now, id);
1198
+ db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?").run(id);
1199
+ });
1200
+ }
1201
+ disconnectAgent(id, resumableForMs = DEFAULT_RESUMABLE_WINDOW_MS) {
1202
+ const db = this.getDb();
1203
+ const now = new Date();
1204
+ const resumableUntil = new Date(now.getTime() + resumableForMs).toISOString();
1205
+ db.prepare("UPDATE agents SET disconnected_at = ?, resumable_until = ? WHERE id = ?").run(now.toISOString(), resumableUntil, id);
1206
+ }
1207
+ getAgentById(id) {
1208
+ const row = this.getAgentRowById(id);
1209
+ return row ? rowToAgent(row) : null;
1210
+ }
1211
+ getCurrentSessionOutboundCount(agentId, connectedAt) {
1212
+ const db = this.getDb();
1213
+ const row = db
1214
+ .prepare(`SELECT COUNT(*) AS count
1215
+ FROM messages
1216
+ WHERE sender = ?
1217
+ AND source = 'agent'
1218
+ AND created_at >= ?`)
1219
+ .get(agentId, connectedAt);
1220
+ return Number(row?.count ?? 0);
1221
+ }
1222
+ rowToAgentWithCurrentSessionOutboundCount(row) {
1223
+ const agent = rowToAgent(row);
1224
+ return {
1225
+ ...agent,
1226
+ outboundCount: this.getCurrentSessionOutboundCount(agent.id, agent.connectedAt),
1227
+ };
1228
+ }
1229
+ getAgents() {
1230
+ const db = this.getDb();
1231
+ const rows = db
1232
+ .prepare("SELECT * FROM agents WHERE disconnected_at IS NULL ORDER BY connected_at ASC")
1233
+ .all();
1234
+ return rows.map((row) => this.rowToAgentWithCurrentSessionOutboundCount(row));
1235
+ }
1236
+ getAllAgents() {
1237
+ const db = this.getDb();
1238
+ const rows = db
1239
+ .prepare(`SELECT * FROM agents
1240
+ ORDER BY CASE WHEN disconnected_at IS NULL THEN 0 ELSE 1 END, connected_at ASC`)
1241
+ .all();
1242
+ return rows.map((row) => this.rowToAgentWithCurrentSessionOutboundCount(row));
1243
+ }
1244
+ getSetting(key) {
1245
+ const db = this.getDb();
1246
+ const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
1247
+ if (!row)
1248
+ return null;
1249
+ return JSON.parse(row.value);
1250
+ }
1251
+ setSetting(key, value) {
1252
+ const db = this.getDb();
1253
+ const now = new Date().toISOString();
1254
+ db.prepare(`INSERT INTO settings (key, value, updated_at)
1255
+ VALUES (?, ?, ?)
1256
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).run(key, JSON.stringify(value), now);
1257
+ }
1258
+ deleteSetting(key) {
1259
+ const db = this.getDb();
1260
+ db.prepare("DELETE FROM settings WHERE key = ?").run(key);
1261
+ }
1262
+ // ─── Port leases ─────────────────────────────────────
1263
+ acquirePortLease(input) {
1264
+ const db = this.getDb();
1265
+ const purpose = normalizePortLeasePurpose(input.purpose);
1266
+ const ttlMs = normalizePortLeaseTtlMs(input.ttlMs);
1267
+ const host = normalizePortLeaseHost(input.host);
1268
+ const requestedPort = input.port === undefined ? undefined : normalizePortLeasePort(input.port);
1269
+ const explicitRange = input.minPort !== undefined || input.maxPort !== undefined;
1270
+ const { minPort, maxPort } = explicitRange
1271
+ ? normalizePortLeaseRange(input.minPort, input.maxPort)
1272
+ : requestedPort === undefined
1273
+ ? normalizePortLeaseRange(input.minPort, input.maxPort)
1274
+ : { minPort: requestedPort, maxPort: requestedPort };
1275
+ const ownerAgentId = normalizeOptionalPortLeaseOwner(input.ownerAgentId) ?? null;
1276
+ const pid = normalizePortLeasePid(input.pid);
1277
+ const metadata = serializeOptionalMetadata(input.metadata) ?? null;
1278
+ if (requestedPort !== undefined && (requestedPort < minPort || requestedPort > maxPort)) {
1279
+ throw new Error("port must be within minPort and maxPort");
1280
+ }
1281
+ return this.withTransaction(() => {
1282
+ const now = new Date();
1283
+ const nowIso = now.toISOString();
1284
+ this.expirePortLeasesInternal(nowIso);
1285
+ const port = requestedPort ?? this.findAvailablePortLeasePort(host, minPort, maxPort);
1286
+ if (port === null) {
1287
+ throw new Error(`No available port lease in range ${minPort}-${maxPort} for ${host}`);
1288
+ }
1289
+ const leaseId = crypto.randomUUID();
1290
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
1291
+ try {
1292
+ db.prepare(`INSERT INTO port_leases (
1293
+ id, purpose, port, host, owner_agent_id, pid, status, metadata,
1294
+ acquired_at, renewed_at, expires_at, released_at
1295
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, NULL)`).run(leaseId, purpose, port, host, ownerAgentId, pid, metadata, nowIso, nowIso, expiresAt);
1296
+ }
1297
+ catch (error) {
1298
+ if (requestedPort !== undefined) {
1299
+ throw new Error(`Port ${host}:${requestedPort} already has an active lease`, {
1300
+ cause: error,
1301
+ });
1302
+ }
1303
+ throw error;
1304
+ }
1305
+ const lease = this.getPortLeaseRowById(leaseId);
1306
+ if (!lease) {
1307
+ throw new Error(`Failed to create port lease ${leaseId}`);
1308
+ }
1309
+ return rowToPortLease(lease);
1310
+ });
1311
+ }
1312
+ renewPortLease(input) {
1313
+ const db = this.getDb();
1314
+ const leaseId = normalizePortLeaseId(input.leaseId);
1315
+ const ttlMs = normalizePortLeaseTtlMs(input.ttlMs);
1316
+ const ownerAgentId = normalizeOptionalPortLeaseOwner(input.ownerAgentId);
1317
+ return this.withTransaction(() => {
1318
+ const now = new Date();
1319
+ const nowIso = now.toISOString();
1320
+ this.expirePortLeasesInternal(nowIso);
1321
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
1322
+ const result = ownerAgentId === undefined
1323
+ ? db
1324
+ .prepare(`UPDATE port_leases
1325
+ SET renewed_at = ?, expires_at = ?
1326
+ WHERE id = ? AND status = 'active'`)
1327
+ .run(nowIso, expiresAt, leaseId)
1328
+ : db
1329
+ .prepare(`UPDATE port_leases
1330
+ SET renewed_at = ?, expires_at = ?
1331
+ WHERE id = ? AND status = 'active' AND owner_agent_id IS ?`)
1332
+ .run(nowIso, expiresAt, leaseId, ownerAgentId);
1333
+ if (Number(result.changes ?? 0) === 0) {
1334
+ throw new Error("No active port lease matched leaseId/ownerAgentId");
1335
+ }
1336
+ const lease = this.getPortLeaseRowById(leaseId);
1337
+ if (!lease) {
1338
+ throw new Error(`Port lease ${leaseId} disappeared after renew`);
1339
+ }
1340
+ return rowToPortLease(lease);
1341
+ });
1342
+ }
1343
+ releasePortLease(input) {
1344
+ const db = this.getDb();
1345
+ const leaseId = normalizePortLeaseId(input.leaseId);
1346
+ const ownerAgentId = normalizeOptionalPortLeaseOwner(input.ownerAgentId);
1347
+ return this.withTransaction(() => {
1348
+ const nowIso = new Date().toISOString();
1349
+ this.expirePortLeasesInternal(nowIso);
1350
+ const result = ownerAgentId === undefined
1351
+ ? db
1352
+ .prepare(`UPDATE port_leases
1353
+ SET status = 'released', released_at = ?
1354
+ WHERE id = ? AND status = 'active'`)
1355
+ .run(nowIso, leaseId)
1356
+ : db
1357
+ .prepare(`UPDATE port_leases
1358
+ SET status = 'released', released_at = ?
1359
+ WHERE id = ? AND status = 'active' AND owner_agent_id IS ?`)
1360
+ .run(nowIso, leaseId, ownerAgentId);
1361
+ if (Number(result.changes ?? 0) === 0) {
1362
+ throw new Error("No active port lease matched leaseId/ownerAgentId");
1363
+ }
1364
+ const lease = this.getPortLeaseRowById(leaseId);
1365
+ if (!lease) {
1366
+ throw new Error(`Port lease ${leaseId} disappeared after release`);
1367
+ }
1368
+ return rowToPortLease(lease);
1369
+ });
1370
+ }
1371
+ getPortLease(leaseId) {
1372
+ const id = normalizePortLeaseId(leaseId);
1373
+ this.expirePortLeases();
1374
+ const row = this.getPortLeaseRowById(id);
1375
+ return row ? rowToPortLease(row) : null;
1376
+ }
1377
+ listPortLeases(options = {}) {
1378
+ const db = this.getDb();
1379
+ this.expirePortLeases();
1380
+ const clauses = [];
1381
+ const values = [];
1382
+ if (options.expiredOnly) {
1383
+ clauses.push("status = 'expired'");
1384
+ }
1385
+ else if (!options.includeInactive) {
1386
+ clauses.push("status = 'active'");
1387
+ }
1388
+ if (options.ownerAgentId !== undefined) {
1389
+ clauses.push("owner_agent_id IS ?");
1390
+ values.push(normalizeOptionalPortLeaseOwner(options.ownerAgentId) ?? null);
1391
+ }
1392
+ if (options.purpose !== undefined) {
1393
+ clauses.push("purpose = ?");
1394
+ values.push(normalizePortLeasePurpose(options.purpose));
1395
+ }
1396
+ if (options.host !== undefined) {
1397
+ clauses.push("host = ?");
1398
+ values.push(normalizePortLeaseHost(options.host));
1399
+ }
1400
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
1401
+ const rows = db
1402
+ .prepare(`SELECT * FROM port_leases
1403
+ ${where}
1404
+ ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'expired' THEN 1 ELSE 2 END,
1405
+ expires_at ASC,
1406
+ acquired_at ASC`)
1407
+ .all(...values);
1408
+ return rows.map(rowToPortLease);
1409
+ }
1410
+ expirePortLeases(nowIso = new Date().toISOString()) {
1411
+ return this.withTransaction(() => this.expirePortLeasesInternal(nowIso));
1412
+ }
1413
+ expirePortLeasesInternal(nowIso) {
1414
+ const db = this.getDb();
1415
+ if (Number.isNaN(Date.parse(nowIso))) {
1416
+ throw new Error("nowIso must be a valid ISO timestamp");
1417
+ }
1418
+ const expiredRows = db
1419
+ .prepare(`SELECT * FROM port_leases
1420
+ WHERE status = 'active' AND expires_at <= ?
1421
+ ORDER BY expires_at ASC, acquired_at ASC`)
1422
+ .all(nowIso);
1423
+ if (expiredRows.length === 0) {
1424
+ return [];
1425
+ }
1426
+ const expire = db.prepare(`UPDATE port_leases
1427
+ SET status = 'expired', released_at = COALESCE(released_at, ?)
1428
+ WHERE id = ? AND status = 'active'`);
1429
+ for (const row of expiredRows) {
1430
+ expire.run(nowIso, row.id);
1431
+ }
1432
+ const placeholders = expiredRows.map(() => "?").join(", ");
1433
+ const rows = db
1434
+ .prepare(`SELECT * FROM port_leases WHERE id IN (${placeholders}) ORDER BY expires_at ASC`)
1435
+ .all(...expiredRows.map((row) => row.id));
1436
+ return rows.map(rowToPortLease);
1437
+ }
1438
+ findAvailablePortLeasePort(host, minPort, maxPort) {
1439
+ const db = this.getDb();
1440
+ const rows = db
1441
+ .prepare(`SELECT port FROM port_leases
1442
+ WHERE host = ? AND status = 'active' AND port BETWEEN ? AND ?
1443
+ ORDER BY port ASC`)
1444
+ .all(host, minPort, maxPort);
1445
+ const activePorts = new Set(rows.map((row) => row.port));
1446
+ for (let port = minPort; port <= maxPort; port += 1) {
1447
+ if (!activePorts.has(port)) {
1448
+ return port;
1449
+ }
1450
+ }
1451
+ return null;
1452
+ }
1453
+ getPortLeaseRowById(leaseId) {
1454
+ const db = this.getDb();
1455
+ const row = db.prepare("SELECT * FROM port_leases WHERE id = ?").get(leaseId);
1456
+ return row ?? null;
1457
+ }
1458
+ touchAgent(id) {
1459
+ const db = this.getDb();
1460
+ db.prepare("UPDATE agents SET last_seen = ? WHERE id = ?").run(new Date().toISOString(), id);
1461
+ }
1462
+ heartbeatAgent(id) {
1463
+ const db = this.getDb();
1464
+ db.prepare("UPDATE agents SET last_heartbeat = ?, disconnected_at = NULL, resumable_until = NULL WHERE id = ?").run(new Date().toISOString(), id);
1465
+ }
1466
+ pruneStaleAgents(staleAfterMs) {
1467
+ const db = this.getDb();
1468
+ const cutoff = new Date(Date.now() - staleAfterMs).toISOString();
1469
+ const now = new Date().toISOString();
1470
+ return this.withTransaction(() => {
1471
+ const staleRows = db
1472
+ .prepare(`SELECT id FROM agents
1473
+ WHERE (disconnected_at IS NULL AND last_heartbeat <= ?)
1474
+ OR (disconnected_at IS NOT NULL AND resumable_until IS NOT NULL AND resumable_until <= ?)`)
1475
+ .all(cutoff, now);
1476
+ if (staleRows.length === 0) {
1477
+ return [];
1478
+ }
1479
+ const disconnectAgent = db.prepare("UPDATE agents SET disconnected_at = COALESCE(disconnected_at, ?), resumable_until = NULL WHERE id = ?");
1480
+ const releaseClaims = db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?");
1481
+ for (const row of staleRows) {
1482
+ this.requeueUndeliveredMessagesInternal(row.id, "agent_disconnected");
1483
+ disconnectAgent.run(now, row.id);
1484
+ releaseClaims.run(row.id);
1485
+ }
1486
+ return staleRows.map((row) => row.id);
1487
+ });
1488
+ }
1489
+ purgeDisconnectedAgents(graceMs = DEFAULT_DISCONNECTED_PURGE_GRACE_MS) {
1490
+ const db = this.getDb();
1491
+ const now = Date.now();
1492
+ const nowIso = new Date(now).toISOString();
1493
+ const cutoff = new Date(now - graceMs).toISOString();
1494
+ return this.withTransaction(() => {
1495
+ const rows = db
1496
+ .prepare(`SELECT id FROM agents
1497
+ WHERE disconnected_at IS NOT NULL
1498
+ AND disconnected_at <= ?
1499
+ AND (resumable_until IS NULL OR resumable_until <= ?)`)
1500
+ .all(cutoff, nowIso);
1501
+ if (rows.length === 0) {
1502
+ return [];
1503
+ }
1504
+ const releaseThreads = db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?");
1505
+ const deleteInbox = db.prepare("DELETE FROM inbox WHERE agent_id = ?");
1506
+ for (const row of rows) {
1507
+ // Requeue undelivered messages to the backlog
1508
+ this.requeueUndeliveredMessagesInternal(row.id, "agent_disconnected");
1509
+ // Release thread ownership for the purged agent
1510
+ releaseThreads.run(row.id);
1511
+ // Clean up all inbox entries (both delivered and undelivered) for the agent
1512
+ deleteInbox.run(row.id);
1513
+ }
1514
+ db.prepare(`DELETE FROM agents
1515
+ WHERE disconnected_at IS NOT NULL
1516
+ AND disconnected_at <= ?
1517
+ AND (resumable_until IS NULL OR resumable_until <= ?)`).run(cutoff, nowIso);
1518
+ return rows.map((row) => row.id);
1519
+ });
1520
+ }
1521
+ updateAgentStatus(id, status) {
1522
+ const db = this.getDb();
1523
+ const now = new Date().toISOString();
1524
+ if (status === "idle") {
1525
+ // Transitioning to idle: set idle_since, preserve last_activity
1526
+ db.prepare(`UPDATE agents
1527
+ SET status = ?, last_seen = ?,
1528
+ idle_since = COALESCE(CASE WHEN status = 'idle' THEN idle_since ELSE NULL END, ?)
1529
+ WHERE id = ?`).run(status, now, now, id);
1530
+ }
1531
+ else {
1532
+ // Transitioning to working: clear idle_since, update last_activity
1533
+ db.prepare("UPDATE agents SET status = ?, last_seen = ?, idle_since = NULL, last_activity = ? WHERE id = ?").run(status, now, now, id);
1534
+ }
1535
+ }
1536
+ updateAgentMetadata(id, metadata) {
1537
+ const db = this.getDb();
1538
+ if (!this.getAgentRowById(id))
1539
+ return null;
1540
+ db.prepare("UPDATE agents SET metadata = ?, last_seen = ? WHERE id = ?").run(metadata ? JSON.stringify(metadata) : null, new Date().toISOString(), id);
1541
+ const updated = this.getAgentRowById(id);
1542
+ return updated ? rowToAgent(updated) : null;
1543
+ }
1544
+ updateAgentIdentity(id, identity) {
1545
+ const db = this.getDb();
1546
+ const existing = this.getAgentRowById(id);
1547
+ if (!existing)
1548
+ return null;
1549
+ const finalName = this.ensureUniqueAgentName(identity.name, id);
1550
+ const finalEmoji = identity.emoji.trim() || existing.emoji;
1551
+ const metadata = identity.metadata ?? (existing.metadata ? JSON.parse(existing.metadata) : null);
1552
+ const metadataJson = metadata ? JSON.stringify(metadata) : null;
1553
+ db.prepare(`UPDATE agents
1554
+ SET name = ?, emoji = ?, metadata = ?, last_seen = ?
1555
+ WHERE id = ?`).run(finalName, finalEmoji, metadataJson, new Date().toISOString(), id);
1556
+ const updated = this.getAgentRowById(id);
1557
+ return updated ? rowToAgent(updated) : null;
1558
+ }
1559
+ touchAgentActivity(id) {
1560
+ const db = this.getDb();
1561
+ db.prepare("UPDATE agents SET last_activity = ?, last_seen = ? WHERE id = ?").run(new Date().toISOString(), new Date().toISOString(), id);
1562
+ }
1563
+ ensureUniqueAgentName(name, agentId) {
1564
+ const db = this.getDb();
1565
+ const baseName = name.trim() || "Agent";
1566
+ let candidate = baseName;
1567
+ let suffix = 2;
1568
+ while (true) {
1569
+ const row = db
1570
+ .prepare("SELECT id FROM agents WHERE lower(name) = lower(?) AND id != ? LIMIT 1")
1571
+ .get(candidate, agentId);
1572
+ if (!row) {
1573
+ return candidate;
1574
+ }
1575
+ candidate = `${baseName} ${suffix}`;
1576
+ suffix += 1;
1577
+ }
1578
+ }
1579
+ getAgentRowById(id) {
1580
+ const db = this.getDb();
1581
+ const row = db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
1582
+ return row ?? null;
1583
+ }
1584
+ getAgentByStableId(stableId) {
1585
+ const row = this.getAgentRowByStableId(stableId);
1586
+ return row ? rowToAgent(row) : null;
1587
+ }
1588
+ findAgentNameConflict(name, id, stableId) {
1589
+ const db = this.getDb();
1590
+ const existing = stableId ? this.getAgentRowByStableId(stableId) : null;
1591
+ const existingById = this.getAgentRowById(existing?.id ?? id);
1592
+ const agentId = existing?.id ?? existingById?.id ?? id;
1593
+ const normalizedName = name.trim();
1594
+ if (!normalizedName) {
1595
+ return null;
1596
+ }
1597
+ const row = db
1598
+ .prepare(`SELECT id, stable_id, name
1599
+ FROM agents
1600
+ WHERE lower(name) = lower(?) AND id != ?
1601
+ LIMIT 1`)
1602
+ .get(normalizedName, agentId);
1603
+ if (!row) {
1604
+ return null;
1605
+ }
1606
+ return {
1607
+ id: row.id,
1608
+ stableId: row.stable_id,
1609
+ name: row.name,
1610
+ };
1611
+ }
1612
+ getAgentRowByStableId(stableId) {
1613
+ const db = this.getDb();
1614
+ const row = db.prepare("SELECT * FROM agents WHERE stable_id = ?").get(stableId);
1615
+ return row ?? null;
1616
+ }
1617
+ createThread(threadOrId, source, channel, ownerAgent) {
1618
+ const db = this.getDb();
1619
+ const now = new Date().toISOString();
1620
+ const tId = typeof threadOrId === "string" ? threadOrId : threadOrId.threadId;
1621
+ const src = typeof threadOrId === "string" ? source : threadOrId.source;
1622
+ const ch = typeof threadOrId === "string" ? channel : threadOrId.channel;
1623
+ const owner = typeof threadOrId === "string" ? (ownerAgent ?? null) : threadOrId.ownerAgent;
1624
+ const ownerBinding = typeof threadOrId === "string" ? null : (threadOrId.ownerBinding ?? null);
1625
+ const metadata = typeof threadOrId === "string" ? null : (threadOrId.metadata ?? null);
1626
+ const serializedMetadata = metadata ? JSON.stringify(metadata) : null;
1627
+ db.prepare(`INSERT INTO threads (thread_id, source, channel, owner_agent, owner_binding, metadata, created_at, updated_at)
1628
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1629
+ ON CONFLICT(thread_id) DO UPDATE SET updated_at = excluded.updated_at`).run(tId, src, ch, owner, ownerBinding, serializedMetadata, now, now);
1630
+ return {
1631
+ threadId: tId,
1632
+ source: src,
1633
+ channel: ch,
1634
+ ownerAgent: owner,
1635
+ ownerBinding,
1636
+ metadata,
1637
+ createdAt: now,
1638
+ updatedAt: now,
1639
+ };
1640
+ }
1641
+ updateThread(threadId, updates) {
1642
+ const db = this.getDb();
1643
+ const now = new Date().toISOString();
1644
+ // Upsert: create the thread if it doesn't exist yet
1645
+ const existing = this.getThread(threadId);
1646
+ if (!existing) {
1647
+ db.prepare(`INSERT INTO threads (thread_id, source, channel, owner_agent, owner_binding, metadata, created_at, updated_at)
1648
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(threadId, updates.source ?? DEFAULT_EXTERNAL_THREAD_SOURCE, updates.channel ?? "", updates.ownerAgent !== undefined ? updates.ownerAgent : null, updates.ownerBinding !== undefined ? updates.ownerBinding : null, updates.metadata !== undefined && updates.metadata !== null
1649
+ ? JSON.stringify(updates.metadata)
1650
+ : null, now, now);
1651
+ return;
1652
+ }
1653
+ const sets = [];
1654
+ const values = [];
1655
+ if (updates.ownerAgent !== undefined) {
1656
+ sets.push("owner_agent = ?");
1657
+ values.push(updates.ownerAgent);
1658
+ }
1659
+ if (updates.channel !== undefined) {
1660
+ sets.push("channel = ?");
1661
+ values.push(updates.channel);
1662
+ }
1663
+ if (updates.source !== undefined) {
1664
+ sets.push("source = ?");
1665
+ values.push(updates.source);
1666
+ }
1667
+ if (updates.ownerBinding !== undefined) {
1668
+ sets.push("owner_binding = ?");
1669
+ values.push(updates.ownerBinding);
1670
+ }
1671
+ if (updates.metadata !== undefined) {
1672
+ sets.push("metadata = ?");
1673
+ values.push(updates.metadata === null ? null : JSON.stringify(updates.metadata));
1674
+ }
1675
+ sets.push("updated_at = ?");
1676
+ values.push(now);
1677
+ values.push(threadId);
1678
+ db.prepare(`UPDATE threads SET ${sets.join(", ")} WHERE thread_id = ?`).run(...values);
1679
+ }
1680
+ transferThreadOwnership(threadId, ownerAgent) {
1681
+ const db = this.getDb();
1682
+ const now = new Date().toISOString();
1683
+ const thread = this.getThread(threadId);
1684
+ if (!thread) {
1685
+ throw new Error(`Unknown thread ${threadId}`);
1686
+ }
1687
+ db.exec("BEGIN IMMEDIATE");
1688
+ try {
1689
+ db.prepare(`UPDATE threads
1690
+ SET owner_agent = ?, owner_binding = 'explicit', updated_at = ?
1691
+ WHERE thread_id = ?`).run(ownerAgent, now, threadId);
1692
+ const rows = db
1693
+ .prepare(`SELECT i.id AS inbox_id,
1694
+ i.agent_id AS agent_id,
1695
+ i.message_id AS message_id,
1696
+ m.metadata AS metadata
1697
+ FROM inbox i
1698
+ JOIN messages m ON m.id = i.message_id
1699
+ WHERE m.thread_id = ?
1700
+ AND m.source <> 'agent'
1701
+ AND m.direction = 'inbound'
1702
+ AND i.read_at IS NULL`)
1703
+ .all(threadId);
1704
+ let reassignedInboxCount = 0;
1705
+ const updatedMessageIds = new Set();
1706
+ const updateMessageMetadata = db.prepare("UPDATE messages SET metadata = ? WHERE id = ?");
1707
+ const findExistingInbox = db.prepare("SELECT id FROM inbox WHERE agent_id = ? AND message_id = ?");
1708
+ const reassignInbox = db.prepare("UPDATE inbox SET agent_id = ?, delivered = 0 WHERE id = ? AND agent_id = ?");
1709
+ const markDuplicateRead = db.prepare("UPDATE inbox SET delivered = 1, read_at = COALESCE(read_at, ?) WHERE id = ?");
1710
+ for (const row of rows) {
1711
+ if (!updatedMessageIds.has(row.message_id)) {
1712
+ const metadata = row.metadata ? parseJsonMetadata(row.metadata) : {};
1713
+ metadata.threadAffinityOwnerAgentId = ownerAgent;
1714
+ updateMessageMetadata.run(JSON.stringify(metadata), row.message_id);
1715
+ updatedMessageIds.add(row.message_id);
1716
+ }
1717
+ if (row.agent_id === ownerAgent) {
1718
+ continue;
1719
+ }
1720
+ const existing = findExistingInbox.get(ownerAgent, row.message_id);
1721
+ if (existing) {
1722
+ markDuplicateRead.run(now, row.inbox_id);
1723
+ continue;
1724
+ }
1725
+ const result = reassignInbox.run(ownerAgent, row.inbox_id, row.agent_id);
1726
+ reassignedInboxCount += Number(result.changes ?? 0);
1727
+ }
1728
+ db.exec("COMMIT");
1729
+ return { reassignedInboxCount, updatedMessageCount: updatedMessageIds.size };
1730
+ }
1731
+ catch (err) {
1732
+ db.exec("ROLLBACK");
1733
+ throw err;
1734
+ }
1735
+ }
1736
+ claimThread(threadId, agentId, source = DEFAULT_EXTERNAL_THREAD_SOURCE, channel = "") {
1737
+ const db = this.getDb();
1738
+ const now = new Date().toISOString();
1739
+ // Atomic claim: insert the thread if new, or update the owner only
1740
+ // if the thread is currently unclaimed or already owned by this agent.
1741
+ // A single statement avoids the TOCTOU race of read-then-write. (#125)
1742
+ db.prepare(`INSERT INTO threads (thread_id, source, channel, owner_agent, owner_binding, metadata, created_at, updated_at)
1743
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1744
+ ON CONFLICT(thread_id) DO UPDATE SET
1745
+ owner_agent = excluded.owner_agent,
1746
+ updated_at = excluded.updated_at
1747
+ WHERE threads.owner_agent IS NULL OR threads.owner_agent = excluded.owner_agent`).run(threadId, source, channel, agentId, null, null, now, now);
1748
+ // Verify: read back the owner. If the WHERE clause above didn't
1749
+ // match (another agent owns the thread), the row was not updated
1750
+ // and the owner will differ from agentId.
1751
+ const thread = this.getThread(threadId);
1752
+ return thread?.ownerAgent === agentId;
1753
+ }
1754
+ setAllowedUsers(users) {
1755
+ this.allowedUsers = users === null ? null : new Set(users);
1756
+ }
1757
+ getAllowedUsers() {
1758
+ return this.allowedUsers === null ? null : new Set(this.allowedUsers);
1759
+ }
1760
+ getChannelAssignment(_channel) {
1761
+ return null;
1762
+ }
1763
+ getThread(threadId) {
1764
+ const db = this.getDb();
1765
+ const row = db.prepare("SELECT * FROM threads WHERE thread_id = ?").get(threadId);
1766
+ return row ? rowToThread(row) : null;
1767
+ }
1768
+ getThreads(ownerAgent) {
1769
+ const db = this.getDb();
1770
+ if (ownerAgent) {
1771
+ const rows = db
1772
+ .prepare("SELECT * FROM threads WHERE owner_agent = ? ORDER BY updated_at DESC")
1773
+ .all(ownerAgent);
1774
+ return rows.map(rowToThread);
1775
+ }
1776
+ const rows = db
1777
+ .prepare("SELECT * FROM threads ORDER BY updated_at DESC")
1778
+ .all();
1779
+ return rows.map(rowToThread);
1780
+ }
1781
+ getPendingBacklog(limit = 50) {
1782
+ const db = this.getDb();
1783
+ const rows = db
1784
+ .prepare(`SELECT * FROM unrouted_backlog
1785
+ WHERE status = 'pending'
1786
+ ORDER BY created_at ASC
1787
+ LIMIT ?`)
1788
+ .all(limit);
1789
+ return rows.map(rowToBacklog);
1790
+ }
1791
+ getBacklogCount(status = "pending") {
1792
+ const db = this.getDb();
1793
+ const row = db
1794
+ .prepare("SELECT COUNT(*) AS count FROM unrouted_backlog WHERE status = ?")
1795
+ .get(status);
1796
+ return row.count;
1797
+ }
1798
+ queueUnroutedMessage(message, reason = "no_route") {
1799
+ const metadata = this.withInboundMailClassMetadata(message, {
1800
+ ...message.metadata,
1801
+ channel: message.channel,
1802
+ userName: message.userName,
1803
+ userId: message.userId,
1804
+ timestamp: message.timestamp,
1805
+ ...(message.isChannelMention ? { isChannelMention: true } : {}),
1806
+ });
1807
+ const existingThread = this.getThread(message.threadId);
1808
+ if (!existingThread) {
1809
+ this.createThread(message.threadId, message.source, message.channel, null);
1810
+ }
1811
+ const brokerMessage = this.insertMessage(message.threadId, message.source, "inbound", message.userId, message.text, [], metadata);
1812
+ const existingBacklog = this.getBacklogByMessageId(brokerMessage.id);
1813
+ if (existingBacklog) {
1814
+ return existingBacklog;
1815
+ }
1816
+ if (existingThread) {
1817
+ this.updateThread(message.threadId, {
1818
+ channel: message.channel,
1819
+ source: message.source,
1820
+ ownerAgent: null,
1821
+ });
1822
+ }
1823
+ return this.upsertBacklogEntry(brokerMessage.id, message.threadId, message.channel, reason, "pending", null, null);
1824
+ }
1825
+ assignBacklogEntry(id, agentId) {
1826
+ const db = this.getDb();
1827
+ return this.withTransaction(() => {
1828
+ const row = db
1829
+ .prepare("SELECT * FROM unrouted_backlog WHERE id = ? AND status = 'pending'")
1830
+ .get(id);
1831
+ if (!row)
1832
+ return null;
1833
+ const now = new Date().toISOString();
1834
+ const message = db
1835
+ .prepare("SELECT source, direction, metadata FROM messages WHERE id = ?")
1836
+ .get(row.message_id);
1837
+ if (message && isExternalTransportSource(message.source) && message.direction === "inbound") {
1838
+ let metadata = {};
1839
+ if (message.metadata) {
1840
+ try {
1841
+ metadata = JSON.parse(message.metadata);
1842
+ }
1843
+ catch {
1844
+ metadata = {};
1845
+ }
1846
+ }
1847
+ metadata.threadAffinityOwnerAgentId = agentId;
1848
+ db.prepare("UPDATE messages SET metadata = ? WHERE id = ?").run(JSON.stringify(metadata), row.message_id);
1849
+ }
1850
+ db.prepare(`INSERT OR IGNORE INTO inbox (agent_id, message_id, delivered, created_at)
1851
+ VALUES (?, ?, 0, ?)`).run(agentId, row.message_id, now);
1852
+ db.prepare(`UPDATE unrouted_backlog
1853
+ SET status = 'assigned',
1854
+ assigned_agent_id = ?,
1855
+ attempt_count = attempt_count + 1,
1856
+ last_attempt_at = ?,
1857
+ updated_at = ?
1858
+ WHERE id = ?`).run(agentId, now, now, id);
1859
+ this.updateThread(row.thread_id, { ownerAgent: agentId, channel: row.channel });
1860
+ return this.getBacklogById(id);
1861
+ });
1862
+ }
1863
+ recoverPendingTargetedBacklog(agentId) {
1864
+ const agent = this.getAgentRowById(agentId);
1865
+ if (!agent || agent.disconnected_at) {
1866
+ return 0;
1867
+ }
1868
+ const db = this.getDb();
1869
+ const rows = db
1870
+ .prepare(`SELECT id
1871
+ FROM unrouted_backlog
1872
+ WHERE status = 'pending'
1873
+ AND preferred_agent_id = ?
1874
+ ORDER BY created_at ASC`)
1875
+ .all(agentId);
1876
+ let recoveredCount = 0;
1877
+ for (const row of rows) {
1878
+ if (this.assignBacklogEntry(row.id, agentId)) {
1879
+ recoveredCount += 1;
1880
+ }
1881
+ }
1882
+ return recoveredCount;
1883
+ }
1884
+ dropBacklogEntry(id, reason) {
1885
+ const db = this.getDb();
1886
+ const now = new Date().toISOString();
1887
+ const result = db
1888
+ .prepare(`UPDATE unrouted_backlog
1889
+ SET status = 'dropped',
1890
+ reason = ?,
1891
+ assigned_agent_id = NULL,
1892
+ updated_at = ?
1893
+ WHERE id = ?
1894
+ AND status = 'pending'`)
1895
+ .run(reason, now, id);
1896
+ if (Number(result.changes ?? 0) === 0) {
1897
+ return null;
1898
+ }
1899
+ return this.getBacklogById(id);
1900
+ }
1901
+ repairOrphanedAssignedBacklog() {
1902
+ const db = this.getDb();
1903
+ return this.withTransaction(() => {
1904
+ const rows = db
1905
+ .prepare(`SELECT id, message_id, preferred_agent_id, assigned_agent_id
1906
+ FROM unrouted_backlog
1907
+ WHERE status = 'assigned'
1908
+ AND (
1909
+ (
1910
+ preferred_agent_id IS NOT NULL
1911
+ AND (
1912
+ assigned_agent_id IS NULL
1913
+ OR assigned_agent_id NOT IN (SELECT id FROM agents)
1914
+ OR NOT EXISTS (
1915
+ SELECT 1
1916
+ FROM inbox
1917
+ WHERE inbox.message_id = unrouted_backlog.message_id
1918
+ AND inbox.agent_id = unrouted_backlog.assigned_agent_id
1919
+ )
1920
+ )
1921
+ )
1922
+ OR (
1923
+ preferred_agent_id IS NULL
1924
+ AND (
1925
+ assigned_agent_id IS NULL
1926
+ OR assigned_agent_id NOT IN (SELECT id FROM agents)
1927
+ )
1928
+ )
1929
+ )`)
1930
+ .all();
1931
+ if (rows.length === 0) {
1932
+ return { resetToPendingCount: 0, droppedCount: 0 };
1933
+ }
1934
+ const now = new Date().toISOString();
1935
+ const clearStaleInbox = db.prepare(`DELETE FROM inbox
1936
+ WHERE message_id = ?
1937
+ AND agent_id = ?`);
1938
+ const resetPending = db.prepare(`UPDATE unrouted_backlog
1939
+ SET status = 'pending',
1940
+ assigned_agent_id = NULL,
1941
+ updated_at = ?
1942
+ WHERE id = ?
1943
+ AND status = 'assigned'`);
1944
+ const dropAssigned = db.prepare(`UPDATE unrouted_backlog
1945
+ SET status = 'dropped',
1946
+ reason = 'preferred_agent_missing',
1947
+ assigned_agent_id = NULL,
1948
+ updated_at = ?
1949
+ WHERE id = ?
1950
+ AND status = 'assigned'`);
1951
+ let resetToPendingCount = 0;
1952
+ let droppedCount = 0;
1953
+ for (const row of rows) {
1954
+ if (row.assigned_agent_id) {
1955
+ clearStaleInbox.run(row.message_id, row.assigned_agent_id);
1956
+ }
1957
+ if (!row.preferred_agent_id) {
1958
+ resetToPendingCount += Number(resetPending.run(now, row.id).changes ?? 0);
1959
+ continue;
1960
+ }
1961
+ if (this.getAgentRowById(row.preferred_agent_id)) {
1962
+ resetToPendingCount += Number(resetPending.run(now, row.id).changes ?? 0);
1963
+ continue;
1964
+ }
1965
+ droppedCount += Number(dropAssigned.run(now, row.id).changes ?? 0);
1966
+ }
1967
+ return { resetToPendingCount, droppedCount };
1968
+ });
1969
+ }
1970
+ requeueUndeliveredMessages(agentId, reason = "agent_disconnected") {
1971
+ return this.withTransaction(() => this.requeueUndeliveredMessagesInternal(agentId, reason));
1972
+ }
1973
+ getPendingInboxCount(agentId) {
1974
+ return this.withTransaction(() => {
1975
+ this.dropStaleTransportInboxRows(agentId);
1976
+ const db = this.getDb();
1977
+ const row = db
1978
+ .prepare("SELECT COUNT(*) AS count FROM inbox WHERE agent_id = ? AND delivered = 0")
1979
+ .get(agentId);
1980
+ return row.count;
1981
+ });
1982
+ }
1983
+ getOwnedThreadCount(agentId) {
1984
+ const db = this.getDb();
1985
+ const row = db
1986
+ .prepare("SELECT COUNT(*) AS count FROM threads WHERE owner_agent = ?")
1987
+ .get(agentId);
1988
+ return row.count;
1989
+ }
1990
+ releaseThreadClaims(agentId) {
1991
+ const db = this.getDb();
1992
+ const result = db
1993
+ .prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?")
1994
+ .run(agentId);
1995
+ return Number(result.changes ?? 0);
1996
+ }
1997
+ // ─── Task assignments ───────────────────────────────
1998
+ recordTaskAssignment(agentId, issueNumber, branch, threadId, sourceMessageId, options = {}) {
1999
+ const db = this.getDb();
2000
+ const now = new Date().toISOString();
2001
+ const repoOwner = options.repoOwner ?? null;
2002
+ const repoName = options.repoName ?? null;
2003
+ const repoRoot = options.repoRoot ?? null;
2004
+ const repoKey = buildTaskAssignmentRepoKey({ repoOwner, repoName, repoRoot });
2005
+ const taskKind = normalizeTaskAssignmentKind(options.taskKind ?? "implementation");
2006
+ const existing = db
2007
+ .prepare("SELECT * FROM task_assignments WHERE repo_key = ? AND issue_number = ?")
2008
+ .get(repoKey, issueNumber);
2009
+ if (!existing) {
2010
+ const info = db
2011
+ .prepare(`INSERT INTO task_assignments (
2012
+ agent_id, issue_number, branch, pr_number, status,
2013
+ thread_id, source_message_id, repo_key, repo_owner, repo_name, repo_root, task_kind,
2014
+ created_at, updated_at
2015
+ ) VALUES (?, ?, ?, NULL, 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2016
+ .run(agentId, issueNumber, branch, threadId, sourceMessageId, repoKey, repoOwner, repoName, repoRoot, taskKind, now, now);
2017
+ const row = db
2018
+ .prepare("SELECT * FROM task_assignments WHERE id = ?")
2019
+ .get(Number(info.lastInsertRowid));
2020
+ if (!row) {
2021
+ throw new Error(`Failed to create task assignment for ${agentId}#${issueNumber}`);
2022
+ }
2023
+ return rowToTaskAssignment(row);
2024
+ }
2025
+ const isReassignment = existing.agent_id !== agentId;
2026
+ const nextBranch = isReassignment ? branch : (branch ?? existing.branch);
2027
+ const nextTaskKind = taskKind === "unknown" ? normalizeTaskAssignmentKind(existing.task_kind) : taskKind;
2028
+ const shouldResetProgress = isReassignment || nextBranch !== existing.branch;
2029
+ db.prepare(`UPDATE task_assignments
2030
+ SET agent_id = ?,
2031
+ branch = ?,
2032
+ pr_number = CASE WHEN ? THEN NULL ELSE pr_number END,
2033
+ status = CASE WHEN ? THEN 'assigned' ELSE status END,
2034
+ thread_id = ?,
2035
+ source_message_id = ?,
2036
+ repo_owner = ?,
2037
+ repo_name = ?,
2038
+ repo_root = ?,
2039
+ task_kind = ?,
2040
+ updated_at = ?
2041
+ WHERE id = ?`).run(agentId, nextBranch, shouldResetProgress ? 1 : 0, shouldResetProgress ? 1 : 0, threadId, sourceMessageId, repoOwner, repoName, repoRoot, nextTaskKind, now, existing.id);
2042
+ const row = db.prepare("SELECT * FROM task_assignments WHERE id = ?").get(existing.id);
2043
+ if (!row) {
2044
+ throw new Error(`Failed to update task assignment for ${agentId}#${issueNumber}`);
2045
+ }
2046
+ return rowToTaskAssignment(row);
2047
+ }
2048
+ listTaskAssignments() {
2049
+ const db = this.getDb();
2050
+ const rows = db
2051
+ .prepare(`SELECT * FROM task_assignments
2052
+ ORDER BY updated_at DESC, created_at DESC, id DESC`)
2053
+ .all();
2054
+ return rows.map(rowToTaskAssignment);
2055
+ }
2056
+ listTaskAssignmentsAwaitingFirstReply() {
2057
+ const db = this.getDb();
2058
+ const rows = db
2059
+ .prepare(`SELECT
2060
+ ta.id AS id,
2061
+ ta.agent_id AS agent_id,
2062
+ ta.issue_number AS issue_number,
2063
+ ta.status AS status,
2064
+ ta.source_message_id AS source_message_id,
2065
+ source.sender AS original_sender_agent_id
2066
+ FROM task_assignments ta
2067
+ JOIN messages source ON source.id = ta.source_message_id
2068
+ WHERE ta.source_message_id IS NOT NULL
2069
+ AND ta.status IN ('assigned', 'branch_pushed', 'pr_open')
2070
+ AND source.source = 'agent'
2071
+ AND source.direction = 'inbound'
2072
+ AND source.sender != ta.agent_id
2073
+ AND NOT EXISTS (
2074
+ SELECT 1
2075
+ FROM messages reply
2076
+ JOIN inbox reply_inbox ON reply_inbox.message_id = reply.id
2077
+ WHERE reply.source = 'agent'
2078
+ AND reply.direction = 'inbound'
2079
+ AND reply.sender = ta.agent_id
2080
+ AND reply.id > source.id
2081
+ AND reply_inbox.agent_id = source.sender
2082
+ )
2083
+ ORDER BY ta.updated_at DESC, ta.created_at DESC, ta.id DESC`)
2084
+ .all();
2085
+ return rows.map((row) => ({
2086
+ id: row.id,
2087
+ agentId: row.agent_id,
2088
+ issueNumber: row.issue_number,
2089
+ status: row.status,
2090
+ sourceMessageId: row.source_message_id,
2091
+ originalSenderAgentId: row.original_sender_agent_id,
2092
+ }));
2093
+ }
2094
+ updateTaskAssignmentProgress(id, status, prNumber) {
2095
+ const db = this.getDb();
2096
+ db.prepare(`UPDATE task_assignments
2097
+ SET status = ?,
2098
+ pr_number = ?,
2099
+ updated_at = ?
2100
+ WHERE id = ?`).run(status, prNumber, new Date().toISOString(), id);
2101
+ }
2102
+ // ─── Pinet lane metadata ─────────────────────────────
2103
+ upsertPinetLane(input) {
2104
+ const db = this.getDb();
2105
+ const laneId = normalizeLaneId(input.laneId);
2106
+ const now = new Date().toISOString();
2107
+ const existing = db.prepare("SELECT * FROM pinet_lanes WHERE lane_id = ?").get(laneId);
2108
+ const nextState = input.state === undefined
2109
+ ? existing
2110
+ ? rowToPinetLane(existing).state
2111
+ : "active"
2112
+ : requirePinetLaneState(input.state);
2113
+ if (!existing) {
2114
+ db.prepare(`INSERT INTO pinet_lanes (
2115
+ lane_id, name, task, issue_number, pr_number, thread_id,
2116
+ owner_agent_id, implementation_lead_agent_id, pm_mode, state,
2117
+ summary, metadata, created_at, updated_at, last_activity_at
2118
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(laneId, normalizeOptionalText(input.name) ?? null, normalizeOptionalText(input.task) ?? null, normalizeOptionalInteger(input.issueNumber) ?? null, normalizeOptionalInteger(input.prNumber) ?? null, normalizeOptionalText(input.threadId) ?? null, normalizeOptionalText(input.ownerAgentId) ?? null, normalizeOptionalText(input.implementationLeadAgentId) ?? null, input.pmMode === true ? 1 : 0, nextState, normalizeOptionalText(input.summary) ?? null, serializeOptionalMetadata(input.metadata) ?? null, now, now, now);
2119
+ return this.getPinetLane(laneId);
2120
+ }
2121
+ db.prepare(`UPDATE pinet_lanes
2122
+ SET name = ?,
2123
+ task = ?,
2124
+ issue_number = ?,
2125
+ pr_number = ?,
2126
+ thread_id = ?,
2127
+ owner_agent_id = ?,
2128
+ implementation_lead_agent_id = ?,
2129
+ pm_mode = ?,
2130
+ state = ?,
2131
+ summary = ?,
2132
+ metadata = ?,
2133
+ updated_at = ?,
2134
+ last_activity_at = ?
2135
+ WHERE lane_id = ?`).run(input.name === undefined ? existing.name : (normalizeOptionalText(input.name) ?? null), input.task === undefined ? existing.task : (normalizeOptionalText(input.task) ?? null), input.issueNumber === undefined
2136
+ ? existing.issue_number
2137
+ : (normalizeOptionalInteger(input.issueNumber) ?? null), input.prNumber === undefined
2138
+ ? existing.pr_number
2139
+ : (normalizeOptionalInteger(input.prNumber) ?? null), input.threadId === undefined
2140
+ ? existing.thread_id
2141
+ : (normalizeOptionalText(input.threadId) ?? null), input.ownerAgentId === undefined
2142
+ ? existing.owner_agent_id
2143
+ : (normalizeOptionalText(input.ownerAgentId) ?? null), input.implementationLeadAgentId === undefined
2144
+ ? existing.implementation_lead_agent_id
2145
+ : (normalizeOptionalText(input.implementationLeadAgentId) ?? null), input.pmMode === undefined ? existing.pm_mode : input.pmMode ? 1 : 0, nextState, input.summary === undefined
2146
+ ? existing.summary
2147
+ : (normalizeOptionalText(input.summary) ?? null), input.metadata === undefined
2148
+ ? existing.metadata
2149
+ : (serializeOptionalMetadata(input.metadata) ?? null), now, now, laneId);
2150
+ return this.getPinetLane(laneId);
2151
+ }
2152
+ setPinetLaneParticipant(input) {
2153
+ const db = this.getDb();
2154
+ const laneId = normalizeLaneId(input.laneId);
2155
+ const agentId = normalizeLaneId(input.agentId);
2156
+ const role = requirePinetLaneRole(input.role);
2157
+ if (!this.getPinetLane(laneId)) {
2158
+ throw new Error(`Pinet lane not found: ${laneId}`);
2159
+ }
2160
+ const now = new Date().toISOString();
2161
+ const existing = db
2162
+ .prepare("SELECT * FROM pinet_lane_participants WHERE lane_id = ? AND agent_id = ?")
2163
+ .get(laneId, agentId);
2164
+ if (!existing) {
2165
+ db.prepare(`INSERT INTO pinet_lane_participants (
2166
+ lane_id, agent_id, lane_role, status, summary, metadata,
2167
+ created_at, updated_at, last_activity_at
2168
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(laneId, agentId, role, normalizeOptionalText(input.status) ?? null, normalizeOptionalText(input.summary) ?? null, serializeOptionalMetadata(input.metadata) ?? null, now, now, now);
2169
+ }
2170
+ else {
2171
+ db.prepare(`UPDATE pinet_lane_participants
2172
+ SET lane_role = ?,
2173
+ status = ?,
2174
+ summary = ?,
2175
+ metadata = ?,
2176
+ updated_at = ?,
2177
+ last_activity_at = ?
2178
+ WHERE lane_id = ? AND agent_id = ?`).run(role, input.status === undefined
2179
+ ? existing.status
2180
+ : (normalizeOptionalText(input.status) ?? null), input.summary === undefined
2181
+ ? existing.summary
2182
+ : (normalizeOptionalText(input.summary) ?? null), input.metadata === undefined
2183
+ ? existing.metadata
2184
+ : (serializeOptionalMetadata(input.metadata) ?? null), now, now, laneId, agentId);
2185
+ }
2186
+ db.prepare(`UPDATE pinet_lanes
2187
+ SET updated_at = ?, last_activity_at = ?
2188
+ WHERE lane_id = ?`).run(now, now, laneId);
2189
+ const row = db
2190
+ .prepare("SELECT * FROM pinet_lane_participants WHERE lane_id = ? AND agent_id = ?")
2191
+ .get(laneId, agentId);
2192
+ if (!row) {
2193
+ throw new Error(`Failed to update Pinet lane participant ${agentId} for ${laneId}`);
2194
+ }
2195
+ return rowToPinetLaneParticipant(row);
2196
+ }
2197
+ getPinetLane(laneId) {
2198
+ const db = this.getDb();
2199
+ const canonicalLaneId = normalizeLaneId(laneId);
2200
+ const row = db.prepare("SELECT * FROM pinet_lanes WHERE lane_id = ?").get(canonicalLaneId);
2201
+ if (!row)
2202
+ return null;
2203
+ const participantRows = db
2204
+ .prepare("SELECT * FROM pinet_lane_participants WHERE lane_id = ? ORDER BY updated_at DESC")
2205
+ .all(canonicalLaneId);
2206
+ return rowToPinetLane(row, participantRows.map(rowToPinetLaneParticipant));
2207
+ }
2208
+ listPinetLanes(options = {}) {
2209
+ const db = this.getDb();
2210
+ const clauses = [];
2211
+ const values = [];
2212
+ if (options.state) {
2213
+ clauses.push("state = ?");
2214
+ values.push(requirePinetLaneState(options.state));
2215
+ }
2216
+ else if (!options.includeDone) {
2217
+ clauses.push("state NOT IN ('done', 'cancelled', 'detached')");
2218
+ }
2219
+ const ownerAgentId = normalizeOptionalText(options.ownerAgentId);
2220
+ if (ownerAgentId) {
2221
+ clauses.push("owner_agent_id = ?");
2222
+ values.push(ownerAgentId);
2223
+ }
2224
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
2225
+ const rows = db
2226
+ .prepare(`SELECT * FROM pinet_lanes ${where} ORDER BY updated_at DESC, created_at DESC`)
2227
+ .all(...values);
2228
+ return rows.map((row) => this.getPinetLane(row.lane_id));
2229
+ }
2230
+ // ─── Scheduled wake-ups ──────────────────────────────
2231
+ scheduleWakeup(agentId, body, fireAt, threadId = `wakeup:${agentId}`) {
2232
+ const db = this.getDb();
2233
+ const createdAt = new Date().toISOString();
2234
+ const canonicalFireAt = new Date(fireAt).toISOString();
2235
+ const agentStableId = this.getAgentRowById(agentId)?.stable_id ?? null;
2236
+ const info = db
2237
+ .prepare(`INSERT INTO scheduled_wakeups (
2238
+ agent_id,
2239
+ agent_stable_id,
2240
+ thread_id,
2241
+ body,
2242
+ fire_at,
2243
+ created_at
2244
+ )
2245
+ VALUES (?, ?, ?, ?, ?, ?)`)
2246
+ .run(agentId, agentStableId, threadId, body, canonicalFireAt, createdAt);
2247
+ const row = db
2248
+ .prepare("SELECT * FROM scheduled_wakeups WHERE id = ?")
2249
+ .get(Number(info.lastInsertRowid));
2250
+ if (!row) {
2251
+ throw new Error(`Failed to create scheduled wake-up for ${agentId}`);
2252
+ }
2253
+ return rowToScheduledWakeup(row);
2254
+ }
2255
+ listScheduledWakeups(agentId) {
2256
+ const db = this.getDb();
2257
+ const agentStableId = agentId ? (this.getAgentRowById(agentId)?.stable_id ?? null) : null;
2258
+ const rows = (agentId
2259
+ ? agentStableId
2260
+ ? db
2261
+ .prepare(`SELECT * FROM scheduled_wakeups
2262
+ WHERE agent_stable_id = ?
2263
+ OR (agent_stable_id IS NULL AND agent_id = ?)
2264
+ ORDER BY fire_at ASC, id ASC`)
2265
+ .all(agentStableId, agentId)
2266
+ : db
2267
+ .prepare(`SELECT * FROM scheduled_wakeups
2268
+ WHERE agent_id = ?
2269
+ ORDER BY fire_at ASC, id ASC`)
2270
+ .all(agentId)
2271
+ : db
2272
+ .prepare(`SELECT * FROM scheduled_wakeups
2273
+ ORDER BY fire_at ASC, id ASC`)
2274
+ .all());
2275
+ return rows.map(rowToScheduledWakeup);
2276
+ }
2277
+ deliverDueScheduledWakeups(now = new Date().toISOString(), limit = 50) {
2278
+ const db = this.getDb();
2279
+ return this.withTransaction(() => {
2280
+ const rows = db
2281
+ .prepare(`SELECT
2282
+ sw.*,
2283
+ COALESCE(stable_agent.id, direct_agent.id) AS target_agent_id
2284
+ FROM scheduled_wakeups sw
2285
+ LEFT JOIN agents stable_agent
2286
+ ON sw.agent_stable_id IS NOT NULL
2287
+ AND stable_agent.stable_id = sw.agent_stable_id
2288
+ AND stable_agent.disconnected_at IS NULL
2289
+ LEFT JOIN agents direct_agent
2290
+ ON sw.agent_stable_id IS NULL
2291
+ AND direct_agent.id = sw.agent_id
2292
+ AND direct_agent.disconnected_at IS NULL
2293
+ WHERE sw.fire_at <= ?
2294
+ AND COALESCE(stable_agent.id, direct_agent.id) IS NOT NULL
2295
+ ORDER BY sw.fire_at ASC, sw.id ASC
2296
+ LIMIT ?`)
2297
+ .all(now, limit);
2298
+ if (rows.length === 0) {
2299
+ return [];
2300
+ }
2301
+ const deleteWakeup = db.prepare("DELETE FROM scheduled_wakeups WHERE id = ?");
2302
+ const deliveries = [];
2303
+ for (const row of rows) {
2304
+ const targetAgentId = row.target_agent_id;
2305
+ if (!this.getThread(row.thread_id)) {
2306
+ this.createThread(row.thread_id, "agent", "", targetAgentId);
2307
+ }
2308
+ else {
2309
+ this.updateThread(row.thread_id, { ownerAgent: targetAgentId });
2310
+ }
2311
+ const message = this.insertMessage(row.thread_id, "agent", "inbound", "scheduler", row.body, [targetAgentId], {
2312
+ senderAgent: "Pinet Scheduler",
2313
+ scheduledWakeup: true,
2314
+ a2a: true,
2315
+ pinetMailClass: "fwup",
2316
+ wakeupId: row.id,
2317
+ fireAt: row.fire_at,
2318
+ });
2319
+ deleteWakeup.run(row.id);
2320
+ deliveries.push({ wakeup: rowToScheduledWakeup(row), message });
2321
+ }
2322
+ return deliveries;
2323
+ });
2324
+ }
2325
+ repairThreadOwnership() {
2326
+ const db = this.getDb();
2327
+ return this.withTransaction(() => {
2328
+ const rows = db
2329
+ .prepare(`SELECT owner_agent, COUNT(*) AS claim_count
2330
+ FROM threads
2331
+ WHERE owner_agent IS NOT NULL
2332
+ AND owner_agent NOT IN (
2333
+ SELECT id FROM agents WHERE disconnected_at IS NULL
2334
+ )
2335
+ GROUP BY owner_agent`)
2336
+ .all();
2337
+ if (rows.length === 0) {
2338
+ return { releasedClaimCount: 0, releasedAgentIds: [] };
2339
+ }
2340
+ db.prepare(`UPDATE threads
2341
+ SET owner_agent = NULL
2342
+ WHERE owner_agent IS NOT NULL
2343
+ AND owner_agent NOT IN (
2344
+ SELECT id FROM agents WHERE disconnected_at IS NULL
2345
+ )`).run();
2346
+ return {
2347
+ releasedClaimCount: rows.reduce((count, row) => count + Number(row.claim_count), 0),
2348
+ releasedAgentIds: rows.map((row) => row.owner_agent),
2349
+ };
2350
+ });
2351
+ }
2352
+ // ─── Messages + Inbox ────────────────────────────────
2353
+ // ─── Interface-compatible queueMessage (single agent) ──
2354
+ queueMessage(agentId, message) {
2355
+ this.withTransaction(() => {
2356
+ this.insertMessage(message.threadId, message.source, "inbound", message.userId, message.text, [agentId], this.buildInboundMessageMetadata(message));
2357
+ this.reclassifyReferencedMessageFromReaction(message);
2358
+ });
2359
+ }
2360
+ queueDeliveredMessage(agentId, message) {
2361
+ return this.withTransaction(() => {
2362
+ const db = this.getDb();
2363
+ const brokerMessage = this.insertMessage(message.threadId, message.source, "inbound", message.userId, message.text, [], this.buildInboundMessageMetadata(message));
2364
+ this.reclassifyReferencedMessageFromReaction(message);
2365
+ const existing = db
2366
+ .prepare("SELECT id FROM inbox WHERE agent_id = ? AND message_id = ? ORDER BY id ASC LIMIT 1")
2367
+ .get(agentId, brokerMessage.id);
2368
+ if (existing) {
2369
+ db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ? AND agent_id = ?").run(existing.id, agentId);
2370
+ const entry = this.getInboxEntryById(existing.id, agentId);
2371
+ if (!entry) {
2372
+ throw new Error("Failed to read delivered inbox entry");
2373
+ }
2374
+ return { entry, message: brokerMessage, freshDelivery: false };
2375
+ }
2376
+ const now = new Date().toISOString();
2377
+ const result = db
2378
+ .prepare(`INSERT INTO inbox (agent_id, message_id, delivered, created_at)
2379
+ VALUES (?, ?, 1, ?)`)
2380
+ .run(agentId, brokerMessage.id, now);
2381
+ const inboxId = Number(result.lastInsertRowid);
2382
+ return {
2383
+ entry: {
2384
+ id: inboxId,
2385
+ agentId,
2386
+ messageId: brokerMessage.id,
2387
+ delivered: true,
2388
+ readAt: null,
2389
+ createdAt: now,
2390
+ },
2391
+ message: brokerMessage,
2392
+ freshDelivery: true,
2393
+ };
2394
+ });
2395
+ }
2396
+ buildInboundMessageMetadata(message) {
2397
+ const threadOwner = isExternalTransportSource(message.source) && message.threadId
2398
+ ? this.getThread(message.threadId)?.ownerAgent
2399
+ : null;
2400
+ return this.withInboundMailClassMetadata(message, {
2401
+ ...message.metadata,
2402
+ channel: message.channel,
2403
+ userName: message.userName,
2404
+ userId: message.userId,
2405
+ timestamp: message.timestamp,
2406
+ ...(threadOwner ? { threadAffinityOwnerAgentId: threadOwner } : {}),
2407
+ ...(message.isChannelMention ? { isChannelMention: true } : {}),
2408
+ ...(message.scope ? { scope: message.scope } : {}),
2409
+ });
2410
+ }
2411
+ withInboundMailClassMetadata(message, metadata) {
2412
+ if (!isExternalTransportSource(message.source)) {
2413
+ return metadata;
2414
+ }
2415
+ const classification = classifyPinetMail({
2416
+ source: message.source,
2417
+ threadId: message.threadId,
2418
+ sender: message.userId,
2419
+ body: message.text,
2420
+ metadata,
2421
+ });
2422
+ if (classification.explicit) {
2423
+ return metadata;
2424
+ }
2425
+ return {
2426
+ ...metadata,
2427
+ pinetMailClass: classification.class,
2428
+ };
2429
+ }
2430
+ reclassifyReferencedMessageFromReaction(message) {
2431
+ const metadata = message.metadata;
2432
+ if (!metadata)
2433
+ return;
2434
+ if (metadata.reactionTrigger !== true)
2435
+ return;
2436
+ const reactionAction = getStringMetadataValue(metadata, ["reactionAction", "reaction_action"]);
2437
+ if (reactionAction !== "steer")
2438
+ return;
2439
+ const reactionName = getStringMetadataValue(metadata, ["reactionName", "reaction_name"]);
2440
+ const referencedSource = getStringMetadataValue(metadata, ["referencedSource", "referenced_source"]) ?? message.source;
2441
+ const referencedExternalId = getStringMetadataValue(metadata, [
2442
+ "referencedExternalId",
2443
+ "referenced_external_id",
2444
+ ]);
2445
+ const referencedChannel = getStringMetadataValue(metadata, [
2446
+ "referencedChannel",
2447
+ "referenced_channel",
2448
+ ]);
2449
+ const referencedMessageTs = getStringMetadataValue(metadata, [
2450
+ "referencedMessageTs",
2451
+ "referenced_message_ts",
2452
+ "messageTs",
2453
+ "message_ts",
2454
+ ]);
2455
+ const externalId = referencedExternalId ??
2456
+ (referencedChannel && referencedMessageTs
2457
+ ? `${referencedChannel}:${referencedMessageTs}`
2458
+ : null);
2459
+ if (!externalId)
2460
+ return;
2461
+ const referenced = this.getMessageByExternalId(referencedSource, externalId);
2462
+ if (!referenced)
2463
+ return;
2464
+ const targetAgentIds = this.getUnreadReactionEscalationTargets(referenced);
2465
+ if (targetAgentIds.length === 0)
2466
+ return;
2467
+ const reclassified = this.reclassifyMessageByExternalId(referencedSource, externalId, "steering", {
2468
+ reason: "reaction_steer",
2469
+ reactionName,
2470
+ reactorUserId: getStringMetadataValue(metadata, ["reactorUserId", "reactor_user_id"]),
2471
+ reactorName: getStringMetadataValue(metadata, ["reactorName", "reactor_name"]),
2472
+ reactionEventTs: getStringMetadataValue(metadata, ["reactionEventTs", "reaction_event_ts"]),
2473
+ referencedThreadId: message.threadId,
2474
+ referencedMessageTs,
2475
+ });
2476
+ if (reclassified) {
2477
+ this.redeliverReclassifiedReactionMessage(reclassified, targetAgentIds);
2478
+ }
2479
+ }
2480
+ getUnreadReactionEscalationTargets(message) {
2481
+ const db = this.getDb();
2482
+ const ownerAgent = this.getThread(message.threadId)?.ownerAgent;
2483
+ if (ownerAgent) {
2484
+ const row = db
2485
+ .prepare("SELECT 1 FROM inbox WHERE agent_id = ? AND message_id = ? AND read_at IS NULL")
2486
+ .get(ownerAgent, message.id);
2487
+ return row ? [ownerAgent] : [];
2488
+ }
2489
+ const existingUnreadRecipients = db
2490
+ .prepare("SELECT DISTINCT agent_id FROM inbox WHERE message_id = ? AND read_at IS NULL")
2491
+ .all(message.id);
2492
+ return existingUnreadRecipients.map((row) => row.agent_id).filter(Boolean);
2493
+ }
2494
+ redeliverReclassifiedReactionMessage(message, targetAgentIds) {
2495
+ if (targetAgentIds.length === 0)
2496
+ return;
2497
+ const reopenUnreadInbox = this.getDb().prepare("UPDATE inbox SET delivered = 0 WHERE agent_id = ? AND message_id = ? AND read_at IS NULL");
2498
+ for (const agentId of targetAgentIds) {
2499
+ reopenUnreadInbox.run(agentId, message.id);
2500
+ }
2501
+ }
2502
+ reclassifyMessageByExternalId(source, externalId, mailClass, audit) {
2503
+ const db = this.getDb();
2504
+ const row = db
2505
+ .prepare("SELECT id, metadata FROM messages WHERE source = ? AND external_id = ?")
2506
+ .get(source, externalId);
2507
+ if (!row)
2508
+ return null;
2509
+ const metadata = parseJsonMetadata(row.metadata);
2510
+ metadata.pinetMailClass = mailClass;
2511
+ metadata.pinet_mail_class = mailClass;
2512
+ metadata.pinet_mail_class_reason = audit.reason ?? "manual_reclassification";
2513
+ appendMetadataAudit(metadata, "pinet_mail_class_audit", {
2514
+ ...audit,
2515
+ class: mailClass,
2516
+ at: new Date().toISOString(),
2517
+ });
2518
+ db.prepare("UPDATE messages SET metadata = ? WHERE id = ?").run(JSON.stringify(metadata), row.id);
2519
+ return this.getMessageById(row.id);
2520
+ }
2521
+ getInboxEntryById(id, agentId) {
2522
+ const row = this.getDb()
2523
+ .prepare("SELECT id, agent_id, message_id, delivered, read_at, created_at FROM inbox WHERE id = ? AND agent_id = ?")
2524
+ .get(id, agentId);
2525
+ return row
2526
+ ? {
2527
+ id: row.id,
2528
+ agentId: row.agent_id,
2529
+ messageId: row.message_id,
2530
+ delivered: row.delivered === 1,
2531
+ readAt: row.read_at,
2532
+ createdAt: row.created_at,
2533
+ }
2534
+ : null;
2535
+ }
2536
+ // ─── Detailed message insert (used by socket server) ──
2537
+ insertMessage(threadId, source, direction, sender, body, targetAgentIds, metadata) {
2538
+ const db = this.getDb();
2539
+ const now = new Date().toISOString();
2540
+ const metaJson = metadata ? JSON.stringify(metadata) : null;
2541
+ const identity = deriveMessageSyncIdentity(threadId, source, metadata);
2542
+ const info = db
2543
+ .prepare(`INSERT OR IGNORE INTO messages (
2544
+ thread_id,
2545
+ source,
2546
+ direction,
2547
+ sender,
2548
+ body,
2549
+ metadata,
2550
+ external_id,
2551
+ external_ts,
2552
+ created_at
2553
+ )
2554
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2555
+ .run(threadId, source, direction, sender, body, metaJson, identity.externalId, identity.externalTs, now);
2556
+ const insertedMessageId = Number(info.lastInsertRowid);
2557
+ const messageId = Number(info.changes ?? 0) > 0
2558
+ ? insertedMessageId
2559
+ : this.getExistingMessageIdForIdentity(source, identity.externalId);
2560
+ if (messageId == null) {
2561
+ throw new Error("Failed to persist broker message");
2562
+ }
2563
+ const insertInbox = db.prepare(`INSERT INTO inbox (agent_id, message_id, delivered, created_at)
2564
+ SELECT ?, ?, 0, ?
2565
+ WHERE NOT EXISTS (
2566
+ SELECT 1
2567
+ FROM inbox
2568
+ WHERE agent_id = ?
2569
+ AND message_id = ?
2570
+ )`);
2571
+ for (const agentId of targetAgentIds) {
2572
+ insertInbox.run(agentId, messageId, now, agentId, messageId);
2573
+ }
2574
+ // Update thread timestamp
2575
+ db.prepare("UPDATE threads SET updated_at = ? WHERE thread_id = ?").run(now, threadId);
2576
+ return (this.getMessageById(messageId) ?? {
2577
+ id: messageId,
2578
+ threadId,
2579
+ source,
2580
+ direction,
2581
+ sender,
2582
+ body,
2583
+ metadata: metadata ?? null,
2584
+ ...(identity.externalId ? { externalId: identity.externalId } : {}),
2585
+ ...(identity.externalTs ? { externalTs: identity.externalTs } : {}),
2586
+ createdAt: now,
2587
+ });
2588
+ }
2589
+ getExistingMessageIdForIdentity(source, externalId) {
2590
+ if (!externalId) {
2591
+ return null;
2592
+ }
2593
+ const row = this.getDb()
2594
+ .prepare("SELECT id FROM messages WHERE source = ? AND external_id = ?")
2595
+ .get(source, externalId);
2596
+ return typeof row?.id === "number" ? row.id : null;
2597
+ }
2598
+ getMessageByExternalId(source, externalId) {
2599
+ const messageId = this.getExistingMessageIdForIdentity(source, externalId);
2600
+ return messageId === null ? null : this.getMessageById(messageId);
2601
+ }
2602
+ getMessageById(messageId) {
2603
+ const row = this.getDb()
2604
+ .prepare(`SELECT id, thread_id, source, direction, sender, body, metadata, external_id, external_ts, created_at
2605
+ FROM messages
2606
+ WHERE id = ?`)
2607
+ .get(messageId);
2608
+ return row ? rowToBrokerMessage(row) : null;
2609
+ }
2610
+ dropStaleTransportInboxRows(agentId) {
2611
+ const db = this.getDb();
2612
+ const rows = db
2613
+ .prepare(`SELECT i.id AS inbox_id,
2614
+ i.agent_id AS agent_id,
2615
+ m.metadata AS metadata,
2616
+ t.owner_agent AS owner_agent
2617
+ FROM inbox i
2618
+ JOIN messages m ON m.id = i.message_id
2619
+ JOIN threads t ON t.thread_id = m.thread_id
2620
+ WHERE i.agent_id = ?
2621
+ AND (i.delivered = 0 OR i.read_at IS NULL)
2622
+ AND m.source <> 'agent'
2623
+ AND m.direction = 'inbound'
2624
+ AND t.owner_agent IS NOT NULL`)
2625
+ .all(agentId);
2626
+ const staleIds = [];
2627
+ for (const row of rows) {
2628
+ if (!row.metadata)
2629
+ continue;
2630
+ let metadata;
2631
+ try {
2632
+ metadata = JSON.parse(row.metadata);
2633
+ }
2634
+ catch {
2635
+ continue;
2636
+ }
2637
+ const affinityOwner = metadata.threadAffinityOwnerAgentId;
2638
+ if (typeof affinityOwner !== "string" || affinityOwner.length === 0)
2639
+ continue;
2640
+ if (row.agent_id !== row.owner_agent || affinityOwner !== row.owner_agent) {
2641
+ staleIds.push(row.inbox_id);
2642
+ }
2643
+ }
2644
+ if (staleIds.length === 0)
2645
+ return 0;
2646
+ const now = new Date().toISOString();
2647
+ const stmt = db.prepare("UPDATE inbox SET delivered = 1, read_at = COALESCE(read_at, ?) WHERE id = ? AND agent_id = ?");
2648
+ for (const staleId of staleIds) {
2649
+ stmt.run(now, staleId, agentId);
2650
+ }
2651
+ return staleIds.length;
2652
+ }
2653
+ getInbox(agentId, limit = 50) {
2654
+ this.dropStaleTransportInboxRows(agentId);
2655
+ const db = this.getDb();
2656
+ const rows = db
2657
+ .prepare(`SELECT
2658
+ i.id AS i_id, i.agent_id AS i_agent_id, i.message_id AS i_message_id,
2659
+ i.delivered AS i_delivered, i.read_at AS i_read_at, i.created_at AS i_created_at,
2660
+ m.id AS m_id, m.thread_id AS m_thread_id, m.source AS m_source,
2661
+ m.direction AS m_direction, m.sender AS m_sender, m.body AS m_body,
2662
+ m.metadata AS m_metadata, m.external_id AS m_external_id,
2663
+ m.external_ts AS m_external_ts, m.created_at AS m_created_at
2664
+ FROM inbox i
2665
+ JOIN messages m ON m.id = i.message_id
2666
+ WHERE i.agent_id = ? AND i.delivered = 0
2667
+ ORDER BY i.created_at ASC
2668
+ LIMIT ?`)
2669
+ .all(agentId, limit);
2670
+ return rows.map((r) => ({
2671
+ entry: {
2672
+ id: r.i_id,
2673
+ agentId: r.i_agent_id,
2674
+ messageId: r.i_message_id,
2675
+ delivered: r.i_delivered === 1,
2676
+ readAt: r.i_read_at,
2677
+ createdAt: r.i_created_at,
2678
+ },
2679
+ message: {
2680
+ id: r.m_id,
2681
+ threadId: r.m_thread_id,
2682
+ source: r.m_source,
2683
+ direction: r.m_direction,
2684
+ sender: r.m_sender,
2685
+ body: r.m_body,
2686
+ metadata: r.m_metadata ? JSON.parse(r.m_metadata) : null,
2687
+ ...(r.m_external_id ? { externalId: r.m_external_id } : {}),
2688
+ ...(r.m_external_ts ? { externalTs: r.m_external_ts } : {}),
2689
+ createdAt: r.m_created_at,
2690
+ },
2691
+ }));
2692
+ }
2693
+ readInbox(agentId, options = {}) {
2694
+ this.dropStaleTransportInboxRows(agentId);
2695
+ const db = this.getDb();
2696
+ const unreadOnly = options.unreadOnly ?? true;
2697
+ const markRead = options.markRead ?? true;
2698
+ const limit = Math.min(Math.max(Math.trunc(options.limit ?? 20), 1), 100);
2699
+ const threadId = options.threadId?.trim();
2700
+ const unreadCountBefore = this.getUnreadInboxCount(agentId);
2701
+ const clauses = ["i.agent_id = ?"];
2702
+ const values = [agentId];
2703
+ if (unreadOnly) {
2704
+ clauses.push("i.read_at IS NULL");
2705
+ }
2706
+ if (threadId) {
2707
+ clauses.push("m.thread_id = ?");
2708
+ values.push(threadId);
2709
+ }
2710
+ const order = unreadOnly ? "m.created_at ASC, i.id ASC" : "m.created_at DESC, i.id DESC";
2711
+ const limitClause = unreadOnly ? "" : "\n LIMIT ?";
2712
+ const queryValues = unreadOnly ? values : [...values, limit];
2713
+ const rows = db
2714
+ .prepare(`SELECT
2715
+ i.id AS i_id, i.agent_id AS i_agent_id, i.message_id AS i_message_id,
2716
+ i.delivered AS i_delivered, i.read_at AS i_read_at, i.created_at AS i_created_at,
2717
+ m.id AS m_id, m.thread_id AS m_thread_id, m.source AS m_source,
2718
+ m.direction AS m_direction, m.sender AS m_sender, m.body AS m_body,
2719
+ m.metadata AS m_metadata, m.external_id AS m_external_id,
2720
+ m.external_ts AS m_external_ts, m.created_at AS m_created_at
2721
+ FROM inbox i
2722
+ JOIN messages m ON m.id = i.message_id
2723
+ WHERE ${clauses.join(" AND ")}
2724
+ ORDER BY ${order}${limitClause}`)
2725
+ .all(...queryValues);
2726
+ const orderedRows = unreadOnly ? rows : rows.reverse();
2727
+ const messages = orderedRows.map((r) => ({
2728
+ entry: {
2729
+ id: r.i_id,
2730
+ agentId: r.i_agent_id,
2731
+ messageId: r.i_message_id,
2732
+ delivered: r.i_delivered === 1,
2733
+ readAt: r.i_read_at,
2734
+ createdAt: r.i_created_at,
2735
+ },
2736
+ message: {
2737
+ id: r.m_id,
2738
+ threadId: r.m_thread_id,
2739
+ source: r.m_source,
2740
+ direction: r.m_direction,
2741
+ sender: r.m_sender,
2742
+ body: r.m_body,
2743
+ metadata: r.m_metadata ? JSON.parse(r.m_metadata) : null,
2744
+ ...(r.m_external_id ? { externalId: r.m_external_id } : {}),
2745
+ ...(r.m_external_ts ? { externalTs: r.m_external_ts } : {}),
2746
+ createdAt: r.m_created_at,
2747
+ },
2748
+ }));
2749
+ const prioritizedMessages = unreadOnly
2750
+ ? messages
2751
+ .map((item, index) => ({
2752
+ item,
2753
+ index,
2754
+ mailClass: classifyPinetMail({
2755
+ source: item.message.source,
2756
+ threadId: item.message.threadId,
2757
+ sender: item.message.sender,
2758
+ body: item.message.body,
2759
+ metadata: item.message.metadata,
2760
+ }).class,
2761
+ }))
2762
+ .sort((a, b) => {
2763
+ const classOrder = comparePinetMailClassPriority(a.mailClass, b.mailClass);
2764
+ if (classOrder !== 0)
2765
+ return classOrder;
2766
+ const createdOrder = a.item.message.createdAt.localeCompare(b.item.message.createdAt);
2767
+ if (createdOrder !== 0)
2768
+ return createdOrder;
2769
+ return a.index - b.index;
2770
+ })
2771
+ .slice(0, limit)
2772
+ .map(({ item }) => item)
2773
+ : messages;
2774
+ const markedReadIds = prioritizedMessages
2775
+ .filter((item) => item.entry.readAt === null)
2776
+ .map((item) => item.entry.id);
2777
+ if (markRead && markedReadIds.length > 0) {
2778
+ this.markRead(markedReadIds, agentId);
2779
+ const readAt = new Date().toISOString();
2780
+ for (const item of prioritizedMessages) {
2781
+ if (markedReadIds.includes(item.entry.id)) {
2782
+ item.entry.readAt = readAt;
2783
+ }
2784
+ }
2785
+ }
2786
+ const unreadCountAfter = this.getUnreadInboxCount(agentId);
2787
+ const unreadThreads = this.getUnreadThreadSummary(agentId);
2788
+ return {
2789
+ messages: prioritizedMessages,
2790
+ unreadCountBefore,
2791
+ unreadCountAfter,
2792
+ unreadThreads,
2793
+ markedReadIds: markRead ? markedReadIds : [],
2794
+ };
2795
+ }
2796
+ getUnreadInboxCount(agentId) {
2797
+ this.dropStaleTransportInboxRows(agentId);
2798
+ const db = this.getDb();
2799
+ const row = db
2800
+ .prepare("SELECT COUNT(*) AS count FROM inbox WHERE agent_id = ? AND read_at IS NULL")
2801
+ .get(agentId);
2802
+ return Number(row?.count ?? 0);
2803
+ }
2804
+ getUnreadThreadSummary(agentId, limit = 20) {
2805
+ this.dropStaleTransportInboxRows(agentId);
2806
+ const db = this.getDb();
2807
+ const clampedLimit = Math.min(Math.max(Math.trunc(limit), 1), 100);
2808
+ const rows = db
2809
+ .prepare(`SELECT
2810
+ m.thread_id AS thread_id,
2811
+ m.source AS source,
2812
+ COALESCE(t.channel, '') AS channel,
2813
+ m.id AS message_id,
2814
+ m.sender AS sender,
2815
+ m.body AS body,
2816
+ m.metadata AS metadata,
2817
+ m.created_at AS created_at
2818
+ FROM inbox i
2819
+ JOIN messages m ON m.id = i.message_id
2820
+ LEFT JOIN threads t ON t.thread_id = m.thread_id
2821
+ WHERE i.agent_id = ? AND i.read_at IS NULL
2822
+ ORDER BY m.created_at DESC, m.id DESC`)
2823
+ .all(agentId);
2824
+ const summaries = new Map();
2825
+ for (const row of rows) {
2826
+ const key = `${row.source}\u0000${row.thread_id}\u0000${row.channel}`;
2827
+ const metadata = row.metadata ? parseJsonMetadata(row.metadata) : null;
2828
+ const mailClass = classifyPinetMail({
2829
+ source: row.source,
2830
+ threadId: row.thread_id,
2831
+ sender: row.sender,
2832
+ body: row.body,
2833
+ metadata,
2834
+ }).class;
2835
+ const existing = summaries.get(key);
2836
+ if (!existing) {
2837
+ const counts = emptyMailClassCounts();
2838
+ counts[mailClass] = 1;
2839
+ summaries.set(key, {
2840
+ threadId: row.thread_id,
2841
+ source: row.source,
2842
+ channel: row.channel,
2843
+ unreadCount: 1,
2844
+ latestMessageId: row.message_id,
2845
+ latestAt: row.created_at,
2846
+ highestMailClass: mailClass,
2847
+ mailClassCounts: counts,
2848
+ });
2849
+ continue;
2850
+ }
2851
+ existing.unreadCount += 1;
2852
+ existing.mailClassCounts[mailClass] += 1;
2853
+ if (comparePinetMailClassPriority(mailClass, existing.highestMailClass) < 0) {
2854
+ existing.highestMailClass = mailClass;
2855
+ }
2856
+ if (row.created_at > existing.latestAt ||
2857
+ (row.created_at === existing.latestAt && row.message_id > existing.latestMessageId)) {
2858
+ existing.latestAt = row.created_at;
2859
+ existing.latestMessageId = row.message_id;
2860
+ }
2861
+ }
2862
+ return Array.from(summaries.values())
2863
+ .sort((a, b) => {
2864
+ const classOrder = comparePinetMailClassPriority(a.highestMailClass, b.highestMailClass);
2865
+ if (classOrder !== 0)
2866
+ return classOrder;
2867
+ const latestOrder = b.latestAt.localeCompare(a.latestAt);
2868
+ if (latestOrder !== 0)
2869
+ return latestOrder;
2870
+ return b.latestMessageId - a.latestMessageId;
2871
+ })
2872
+ .slice(0, clampedLimit);
2873
+ }
2874
+ markRead(inboxIds, agentId) {
2875
+ if (inboxIds.length === 0)
2876
+ return;
2877
+ const db = this.getDb();
2878
+ const now = new Date().toISOString();
2879
+ const stmt = db.prepare("UPDATE inbox SET read_at = COALESCE(read_at, ?) WHERE id = ? AND agent_id = ?");
2880
+ for (const id of inboxIds) {
2881
+ stmt.run(now, id, agentId);
2882
+ }
2883
+ }
2884
+ getMessagesByIds(messageIds) {
2885
+ if (messageIds.length === 0) {
2886
+ return [];
2887
+ }
2888
+ const db = this.getDb();
2889
+ const placeholders = messageIds.map(() => "?").join(", ");
2890
+ const rows = db
2891
+ .prepare(`SELECT id, thread_id, source, direction, sender, body, metadata, external_id, external_ts, created_at
2892
+ FROM messages
2893
+ WHERE id IN (${placeholders})
2894
+ ORDER BY id ASC`)
2895
+ .all(...messageIds);
2896
+ return rows.map(rowToBrokerMessage);
2897
+ }
2898
+ markDelivered(inboxIds, agentId) {
2899
+ if (inboxIds.length === 0)
2900
+ return;
2901
+ const db = this.getDb();
2902
+ const lookup = db.prepare("SELECT message_id, agent_id FROM inbox WHERE id = ?");
2903
+ if (agentId) {
2904
+ const stmt = db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ? AND agent_id = ?");
2905
+ for (const id of inboxIds) {
2906
+ const result = stmt.run(id, agentId);
2907
+ if (Number(result.changes ?? 0) === 0)
2908
+ continue;
2909
+ const row = lookup.get(id);
2910
+ if (row) {
2911
+ this.completeTargetedBacklogAssignment(row.message_id, row.agent_id);
2912
+ }
2913
+ }
2914
+ return;
2915
+ }
2916
+ const stmt = db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ?");
2917
+ for (const id of inboxIds) {
2918
+ const result = stmt.run(id);
2919
+ if (Number(result.changes ?? 0) === 0)
2920
+ continue;
2921
+ const row = lookup.get(id);
2922
+ if (row) {
2923
+ this.completeTargetedBacklogAssignment(row.message_id, row.agent_id);
2924
+ }
2925
+ }
2926
+ }
2927
+ /** Mark all undelivered inbox rows for a given message+agent as delivered. */
2928
+ markDeliveredByMessageId(messageId, agentId) {
2929
+ const db = this.getDb();
2930
+ const result = db
2931
+ .prepare("UPDATE inbox SET delivered = 1 WHERE message_id = ? AND agent_id = ? AND delivered = 0")
2932
+ .run(messageId, agentId);
2933
+ if (Number(result.changes ?? 0) > 0) {
2934
+ this.completeTargetedBacklogAssignment(messageId, agentId);
2935
+ }
2936
+ }
2937
+ completeTargetedBacklogAssignment(messageId, agentId) {
2938
+ const db = this.getDb();
2939
+ db.prepare(`DELETE FROM unrouted_backlog
2940
+ WHERE message_id = ?
2941
+ AND status = 'assigned'
2942
+ AND preferred_agent_id IS NOT NULL
2943
+ AND assigned_agent_id = ?`).run(messageId, agentId);
2944
+ }
2945
+ // ─── Internal ────────────────────────────────────────
2946
+ requeueUndeliveredMessagesInternal(agentId, reason = "agent_disconnected") {
2947
+ const db = this.getDb();
2948
+ const rows = db
2949
+ .prepare(`SELECT
2950
+ i.id AS inbox_id,
2951
+ i.agent_id AS target_agent_id,
2952
+ m.id AS message_id,
2953
+ m.thread_id AS thread_id,
2954
+ m.source AS source,
2955
+ m.metadata AS metadata
2956
+ FROM inbox i
2957
+ JOIN messages m ON m.id = i.message_id
2958
+ WHERE i.agent_id = ?
2959
+ AND i.delivered = 0
2960
+ AND m.direction = 'inbound'`)
2961
+ .all(agentId);
2962
+ if (rows.length === 0) {
2963
+ return 0;
2964
+ }
2965
+ const markDelivered = db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ?");
2966
+ for (const row of rows) {
2967
+ const metadata = row.metadata ? JSON.parse(row.metadata) : {};
2968
+ const channel = typeof metadata.channel === "string" ? metadata.channel : "";
2969
+ const preferredAgentId = row.source === "agent" ? row.target_agent_id : null;
2970
+ this.upsertBacklogEntry(row.message_id, row.thread_id, channel, reason, "pending", preferredAgentId, null);
2971
+ markDelivered.run(row.inbox_id);
2972
+ }
2973
+ return rows.length;
2974
+ }
2975
+ getBacklogById(id) {
2976
+ const db = this.getDb();
2977
+ const row = db.prepare("SELECT * FROM unrouted_backlog WHERE id = ?").get(id);
2978
+ return row ? rowToBacklog(row) : null;
2979
+ }
2980
+ getBacklogByMessageId(messageId) {
2981
+ const db = this.getDb();
2982
+ const row = db.prepare("SELECT * FROM unrouted_backlog WHERE message_id = ?").get(messageId);
2983
+ return row ? rowToBacklog(row) : null;
2984
+ }
2985
+ upsertBacklogEntry(messageId, threadId, channel, reason, status, preferredAgentId, assignedAgentId) {
2986
+ const db = this.getDb();
2987
+ const now = new Date().toISOString();
2988
+ db.prepare(`INSERT INTO unrouted_backlog (
2989
+ thread_id,
2990
+ channel,
2991
+ message_id,
2992
+ reason,
2993
+ status,
2994
+ preferred_agent_id,
2995
+ assigned_agent_id,
2996
+ attempt_count,
2997
+ last_attempt_at,
2998
+ created_at,
2999
+ updated_at
3000
+ )
3001
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)
3002
+ ON CONFLICT(message_id) DO UPDATE SET
3003
+ thread_id = excluded.thread_id,
3004
+ channel = excluded.channel,
3005
+ reason = excluded.reason,
3006
+ status = excluded.status,
3007
+ preferred_agent_id = excluded.preferred_agent_id,
3008
+ assigned_agent_id = excluded.assigned_agent_id,
3009
+ updated_at = excluded.updated_at`).run(threadId, channel, messageId, reason, status, preferredAgentId, assignedAgentId, now, now);
3010
+ const row = db.prepare("SELECT * FROM unrouted_backlog WHERE message_id = ?").get(messageId);
3011
+ if (!row) {
3012
+ throw new Error(`Failed to upsert backlog entry for message ${messageId}`);
3013
+ }
3014
+ return rowToBacklog(row);
3015
+ }
3016
+ withTransaction(operation) {
3017
+ const db = this.getDb();
3018
+ db.exec("BEGIN IMMEDIATE");
3019
+ try {
3020
+ const result = operation();
3021
+ db.exec("COMMIT");
3022
+ return result;
3023
+ }
3024
+ catch (err) {
3025
+ try {
3026
+ db.exec("ROLLBACK");
3027
+ }
3028
+ catch {
3029
+ /* best effort */
3030
+ }
3031
+ throw err;
3032
+ }
3033
+ }
3034
+ openAndMigrate() {
3035
+ const db = this.openDatabase();
3036
+ this.db = db;
3037
+ runSchemaMigrations(db);
3038
+ this.ensureRequiredAgentLifecycleColumns(db);
3039
+ }
3040
+ openDatabase() {
3041
+ const db = new DatabaseSync(this.dbPath, { timeout: 5000 });
3042
+ const journalMode = db.prepare("PRAGMA journal_mode=WAL").get();
3043
+ if (!isSqliteWalEnabled(journalMode)) {
3044
+ console.warn(buildSqliteWalFallbackWarning("BrokerDB", journalMode));
3045
+ }
3046
+ db.exec("PRAGMA busy_timeout=5000");
3047
+ return db;
3048
+ }
3049
+ resetDatabaseFiles() {
3050
+ this.close();
3051
+ for (const file of [this.dbPath, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
3052
+ try {
3053
+ fs.rmSync(file, { force: true });
3054
+ }
3055
+ catch {
3056
+ /* best effort */
3057
+ }
3058
+ }
3059
+ }
3060
+ getMissingRequiredAgentLifecycleColumns(db) {
3061
+ const columns = getTableColumns(db, "agents");
3062
+ return REQUIRED_AGENT_LIFECYCLE_COLUMNS.filter((column) => !columns.has(column));
3063
+ }
3064
+ ensureRequiredAgentLifecycleColumns(db) {
3065
+ const missingColumns = this.getMissingRequiredAgentLifecycleColumns(db);
3066
+ if (missingColumns.length > 0) {
3067
+ throw new Error(`agents table missing required columns: ${missingColumns.join(", ")}`);
3068
+ }
3069
+ }
3070
+ getDb() {
3071
+ if (!this.db) {
3072
+ throw new Error("BrokerDB not initialized — call initialize() first");
3073
+ }
3074
+ return this.db;
3075
+ }
3076
+ }