@adhdev/daemon-core 0.9.82-rc.136 → 0.9.82-rc.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/chat/source-machine.d.ts +166 -0
  2. package/dist/chat/source-resolver.d.ts +104 -0
  3. package/dist/cli-adapters/cli-script-runner.d.ts +45 -0
  4. package/dist/cli-adapters/cli-state-engine.d.ts +169 -0
  5. package/dist/cli-adapters/provider-cli-adapter.d.ts +72 -74
  6. package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
  7. package/dist/cli-adapters/provider-cli-shared.d.ts +5 -0
  8. package/dist/config/chat-history.d.ts +1 -0
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +3507 -2288
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +3515 -2301
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/beads-db.d.ts +54 -0
  15. package/dist/mesh/contracts.d.ts +164 -0
  16. package/dist/mesh/mesh-active-work.d.ts +7 -1
  17. package/dist/mesh/mesh-events.d.ts +10 -4
  18. package/dist/mesh/mesh-ledger.d.ts +21 -1
  19. package/dist/mesh/mesh-refine-status.d.ts +2 -3
  20. package/dist/mesh/mesh-work-queue.d.ts +17 -0
  21. package/dist/mesh/worktree-bootstrap-config.d.ts +2 -4
  22. package/dist/providers/contracts.d.ts +19 -0
  23. package/dist/providers/read-chat-contract.d.ts +29 -0
  24. package/dist/providers/transcript-v2.d.ts +176 -0
  25. package/dist/repo-mesh-types.d.ts +5 -0
  26. package/dist/shared-types.d.ts +7 -0
  27. package/dist/status/snapshot.d.ts +1 -0
  28. package/dist/types.d.ts +5 -0
  29. package/package.json +1 -1
  30. package/src/chat/source-machine.ts +534 -0
  31. package/src/chat/source-resolver.ts +0 -0
  32. package/src/chat/subscription-updates.ts +9 -0
  33. package/src/cli-adapters/cli-script-runner.ts +145 -0
  34. package/src/cli-adapters/cli-state-engine.ts +1054 -0
  35. package/src/cli-adapters/provider-cli-adapter.d.ts +0 -1
  36. package/src/cli-adapters/provider-cli-adapter.ts +413 -1399
  37. package/src/cli-adapters/provider-cli-parse.ts +3 -0
  38. package/src/cli-adapters/provider-cli-shared.ts +17 -1
  39. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  40. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  41. package/src/commands/chat-commands.ts +715 -368
  42. package/src/commands/router.ts +22 -2
  43. package/src/config/chat-history.ts +43 -16
  44. package/src/git/git-worktree.ts +8 -1
  45. package/src/index.ts +3 -2
  46. package/src/mesh/beads-db.ts +305 -2
  47. package/src/mesh/contracts.ts +329 -0
  48. package/src/mesh/coordinator-prompt.ts +12 -17
  49. package/src/mesh/mesh-active-work.ts +162 -59
  50. package/src/mesh/mesh-events.ts +198 -53
  51. package/src/mesh/mesh-ledger.ts +321 -105
  52. package/src/mesh/mesh-refine-status.ts +2 -3
  53. package/src/mesh/mesh-work-queue.ts +116 -120
  54. package/src/mesh/worktree-bootstrap-config.ts +17 -4
  55. package/src/providers/contracts.ts +19 -0
  56. package/src/providers/provider-loader.ts +21 -7
  57. package/src/providers/provider-schema.ts +12 -0
  58. package/src/providers/read-chat-contract.ts +74 -14
  59. package/src/providers/transcript-v2.ts +567 -0
  60. package/src/repo-mesh-types.ts +10 -0
  61. package/src/shared-types.ts +7 -0
  62. package/src/status/snapshot.ts +35 -11
  63. package/src/types.ts +5 -0
@@ -1434,10 +1434,18 @@ function buildSubmodulePublishRequiredNextStep(entries: MeshRefineSubmoduleReach
1434
1434
 
1435
1435
  function resolveRefineryAutoPublishSubmoduleMainCommits(mesh: any, workspace: string): { enabled: boolean; source?: string } {
1436
1436
  if (mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true) {
1437
+ process.stderr.write(
1438
+ `[adhdev-mesh] WARNING: allowAutoPublishSubmoduleMainCommits is ENABLED via mesh.policy. `
1439
+ + `Refinery may push unreachable submodule commits to submodule origin/main without additional user approval.\n`,
1440
+ );
1437
1441
  return { enabled: true, source: 'mesh.policy.allowAutoPublishSubmoduleMainCommits' };
1438
1442
  }
1439
1443
  const loaded = loadMeshRefineConfig(mesh, workspace);
1440
1444
  if (loaded.config?.allowAutoPublishSubmoduleMainCommits === true) {
1445
+ process.stderr.write(
1446
+ `[adhdev-mesh] WARNING: allowAutoPublishSubmoduleMainCommits is ENABLED via ${loaded.path || loaded.source}. `
1447
+ + `Refinery may push unreachable submodule commits to submodule origin/main without additional user approval.\n`,
1448
+ );
1441
1449
  return { enabled: true, source: loaded.path || loaded.source };
1442
1450
  }
1443
1451
  return { enabled: false };
@@ -3637,7 +3645,13 @@ export class DaemonCommandRouter {
3637
3645
 
3638
3646
  case 'get_pending_mesh_events': {
3639
3647
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
3640
- const events = drainPendingMeshCoordinatorEvents(meshId || undefined);
3648
+ // (B3) Respect coordinatorDaemonId when the caller declares it
3649
+ // so unicast events route to the right coordinator instead of
3650
+ // being silently consumed by the first drainer.
3651
+ const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
3652
+ ? args.coordinatorDaemonId.trim()
3653
+ : undefined;
3654
+ const events = drainPendingMeshCoordinatorEvents(meshId || undefined, coordinatorDaemonId);
3641
3655
  return { success: true, events };
3642
3656
  }
3643
3657
 
@@ -5879,7 +5893,13 @@ export class DaemonCommandRouter {
5879
5893
  nodeStatuses.push(status);
5880
5894
  }
5881
5895
 
5882
- const pendingCoordinatorEvents = drainPendingMeshCoordinatorEvents(meshId);
5896
+ // (B3) Pass coordinatorDaemonId when the caller declares
5897
+ // it so v1.5 unicast routing (targetCoordinatorDaemonId)
5898
+ // delivers events to the right coordinator.
5899
+ const callerCoordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
5900
+ ? args.coordinatorDaemonId.trim()
5901
+ : undefined;
5902
+ const pendingCoordinatorEvents = drainPendingMeshCoordinatorEvents(meshId, callerCoordinatorDaemonId);
5883
5903
  const previewFreshness = (() => {
5884
5904
  const localRepoRoot = nodeStatuses
5885
5905
  .map((node: any) => readStringValue(node?.git?.repoRoot, node?.repoRoot, node?.workspace))
@@ -1375,19 +1375,42 @@ function normalizeProviderNativeHistoryRecords(agentType: string, historySession
1375
1375
  if (!Array.isArray(records)) return [];
1376
1376
  const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId);
1377
1377
  return records
1378
- .map((record: any) => sanitizeHistoryMessage(agentType, {
1379
- ts: typeof record?.ts === 'string' ? record.ts : new Date(Number(record?.receivedAt) || Date.now()).toISOString(),
1380
- receivedAt: Number(record?.receivedAt) || Date.parse(record?.ts || '') || Date.now(),
1381
- role: record?.role,
1382
- content: String(record?.content || ''),
1383
- kind: record?.kind || (record?.role === 'system' ? 'session_start' : 'standard'),
1384
- senderName: record?.senderName,
1385
- agent: agentType,
1386
- instanceId: record?.instanceId,
1387
- historySessionId: normalizeSavedHistorySessionId(record?.historySessionId || normalizedSessionId),
1388
- sessionTitle: record?.sessionTitle,
1389
- workspace: record?.workspace,
1390
- } as HistoryMessage))
1378
+ .map((record: any) => {
1379
+ const base: HistoryMessage = {
1380
+ ts: typeof record?.ts === 'string' ? record.ts : new Date(Number(record?.receivedAt) || Date.now()).toISOString(),
1381
+ receivedAt: Number(record?.receivedAt) || Date.parse(record?.ts || '') || Date.now(),
1382
+ role: record?.role,
1383
+ content: String(record?.content || ''),
1384
+ kind: record?.kind || (record?.role === 'system' ? 'session_start' : 'standard'),
1385
+ senderName: record?.senderName,
1386
+ agent: agentType,
1387
+ instanceId: record?.instanceId,
1388
+ historySessionId: normalizeSavedHistorySessionId(record?.historySessionId || normalizedSessionId),
1389
+ sessionTitle: record?.sessionTitle,
1390
+ workspace: record?.workspace,
1391
+ } as HistoryMessage;
1392
+ // (A2.3 v2 identity passthrough) — if the producer (native_history.js)
1393
+ // emitted v2 stable identity, keep it across the sanitize layer so
1394
+ // downstream (chat-commands.ts normalizeNativeHistoryMessages) sees
1395
+ // the producer's contract output instead of recomputing from index
1396
+ // and content hash. v1 producers without these fields are unaffected.
1397
+ if (typeof record?.providerUnitKey === 'string' && record.providerUnitKey) {
1398
+ (base as any).providerUnitKey = record.providerUnitKey;
1399
+ }
1400
+ if (typeof record?.bubbleId === 'string' && record.bubbleId) {
1401
+ (base as any).bubbleId = record.bubbleId;
1402
+ }
1403
+ if (typeof record?.sequence === 'number' && Number.isFinite(record.sequence)) {
1404
+ (base as any).sequence = record.sequence;
1405
+ }
1406
+ if (typeof record?._turnKey === 'string' && record._turnKey) {
1407
+ (base as any)._turnKey = record._turnKey;
1408
+ }
1409
+ if (typeof record?.bubbleState === 'string' && record.bubbleState) {
1410
+ (base as any).bubbleState = record.bubbleState;
1411
+ }
1412
+ return sanitizeHistoryMessage(agentType, base);
1413
+ })
1391
1414
  .filter(Boolean) as HistoryMessage[];
1392
1415
  }
1393
1416
 
@@ -1397,6 +1420,7 @@ function callProviderNativeHistoryRead(
1397
1420
  scripts: ProviderNativeHistoryScripts | undefined,
1398
1421
  historySessionId: string | undefined,
1399
1422
  workspace?: string,
1423
+ excludeInProgressTurn?: boolean,
1400
1424
  ): ProviderNativeHistoryReadResult | null {
1401
1425
  const fn = getProviderNativeHistoryScript(scripts, canonicalHistory, 'readSession');
1402
1426
  if (!fn) return null;
@@ -1408,7 +1432,8 @@ function callProviderNativeHistoryRead(
1408
1432
  workspace,
1409
1433
  format: canonicalHistory?.format,
1410
1434
  watchPath: canonicalHistory?.watchPath,
1411
- args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, workspace },
1435
+ excludeInProgressTurn: excludeInProgressTurn === true,
1436
+ args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, workspace, excludeInProgressTurn: excludeInProgressTurn === true },
1412
1437
  });
1413
1438
  if (!result || typeof result !== 'object') return null;
1414
1439
  const records = normalizeProviderNativeHistoryRecords(agentType, normalizedSessionId, (result as any).messages || (result as any).records);
@@ -1430,11 +1455,12 @@ function buildNativeHistoryReadResult(
1430
1455
  scripts: ProviderNativeHistoryScripts | undefined,
1431
1456
  historySessionId: string | undefined,
1432
1457
  workspace?: string,
1458
+ excludeInProgressTurn?: boolean,
1433
1459
  ): ProviderNativeHistoryReadResult | null {
1434
1460
  const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId || '');
1435
1461
  const normalizedWorkspace = typeof workspace === 'string' ? workspace.trim() : '';
1436
1462
  if (!canonicalHistory || (!normalizedSessionId && !normalizedWorkspace) || !isNativeSourceCanonicalHistory(canonicalHistory)) return null;
1437
- return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace);
1463
+ return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace, excludeInProgressTurn);
1438
1464
  }
1439
1465
 
1440
1466
  function materializeNativeHistoryToMirror(
@@ -1490,6 +1516,7 @@ export function readProviderChatHistory(
1490
1516
  excludeRecentCount?: number;
1491
1517
  historyBehavior?: ProviderHistoryBehavior;
1492
1518
  scripts?: ProviderNativeHistoryScripts;
1519
+ excludeInProgressTurn?: boolean;
1493
1520
  } = {},
1494
1521
  ): {
1495
1522
  messages: HistoryMessage[];
@@ -1503,7 +1530,7 @@ export function readProviderChatHistory(
1503
1530
  unavailableReason?: string;
1504
1531
  } {
1505
1532
  if (isNativeSourceCanonicalHistory(options.canonicalHistory) && (options.historySessionId || options.workspace)) {
1506
- const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace);
1533
+ const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace, options.excludeInProgressTurn);
1507
1534
  if (!nativeResult) return { messages: [], hasMore: false, source: 'native-unavailable' };
1508
1535
  return {
1509
1536
  ...pageHistoryRecords(agentType, nativeResult.records, options.offset || 0, options.limit || 30, options.excludeRecentCount || 0, options.historyBehavior),
@@ -116,8 +116,11 @@ export async function createWorktree(opts: WorktreeCreateOptions): Promise<Workt
116
116
  });
117
117
  } catch (error: any) {
118
118
  const stderr = typeof error.stderr === 'string' ? error.stderr : '';
119
- // Clean error messages for common failures
120
119
  if (/already exists/i.test(stderr)) {
120
+ // Distinguish directory-collision (TOCTOU race) from branch-already-exists
121
+ if (existsSync(targetDir)) {
122
+ throw new Error(`Worktree target directory was created concurrently: ${targetDir}`);
123
+ }
121
124
  throw new Error(`Branch '${branch}' already exists or is checked out in another worktree`);
122
125
  }
123
126
  throw new Error(`git worktree add failed: ${stderr.trim() || error.message}`);
@@ -170,6 +173,10 @@ export async function removeWorktree(repoRoot: string, worktreePath: string, opt
170
173
  const stdout = typeof error.stdout === 'string' ? error.stdout : '';
171
174
  const detail = `${stderr}\n${stdout}\n${error.message || ''}`;
172
175
  if (opts.allowSubmoduleForceFallback && SUBMODULE_WORKTREE_REMOVE_RE.test(detail)) {
176
+ process.stderr.write(
177
+ `[adhdev-mesh] WARNING: git worktree remove --force fallback for submodule worktree '${worktreePath}'. `
178
+ + `Any uncommitted changes inside submodules will be lost.\n`,
179
+ );
173
180
  try {
174
181
  await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], {
175
182
  cwd: repoRoot,
package/src/index.ts CHANGED
@@ -115,6 +115,7 @@ export type {
115
115
  RepoMeshLedgerEntryStatus,
116
116
  RepoMeshLedgerSummaryStatus,
117
117
  RepoMeshLedgerStatus,
118
+ MeshAsyncJobLifecycle,
118
119
  } from './repo-mesh-types.js';
119
120
  export { DEFAULT_MESH_POLICY } from './repo-mesh-types.js';
120
121
 
@@ -195,8 +196,8 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
195
196
  export type { MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
196
197
 
197
198
  // ── Mesh Work Queue (GUPP) ──
198
- export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest } from './mesh/mesh-work-queue.js';
199
- export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult } from './mesh/mesh-work-queue.js';
199
+ export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches } from './mesh/mesh-work-queue.js';
200
+ export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord } from './mesh/mesh-work-queue.js';
200
201
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary } from './mesh/mesh-active-work.js';
201
202
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
202
203
  export { buildMeshAsyncRefineJobs } from './mesh/mesh-refine-status.js';
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
3
  import { createRequire } from 'module';
4
4
  import { getLedgerDir } from './mesh-ledger.js';
@@ -28,12 +28,18 @@ function legacyQueuePath(meshId: string): string {
28
28
  export class BeadsDB {
29
29
  private static instance: BeadsDB | undefined;
30
30
  private readonly db: DatabaseHandle;
31
+ private readonly dbPath: string;
31
32
  private readonly migratedMeshIds = new Set<string>();
33
+ private fingerprintSweepCounter = 0;
34
+ private walWriteCounter = 0;
35
+ private static readonly WAL_CHECK_INTERVAL = 500;
36
+ private static readonly WAL_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
32
37
 
33
38
  private constructor(dbPath: string) {
34
39
  const dir = dirname(dbPath);
35
40
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
36
41
 
42
+ this.dbPath = dbPath;
37
43
  this.db = new (loadDatabaseCtor())(dbPath);
38
44
  this.db.pragma('journal_mode = WAL');
39
45
  this.db.pragma('synchronous = NORMAL');
@@ -81,9 +87,72 @@ export class BeadsDB {
81
87
  ON mesh_queue(mesh_id, status, created_at);
82
88
  CREATE INDEX IF NOT EXISTS idx_mesh_queue_assignment
83
89
  ON mesh_queue(mesh_id, assigned_node_id, assigned_session_id, status);
90
+
91
+ CREATE TABLE IF NOT EXISTS mesh_completion_fingerprints (
92
+ fingerprint TEXT PRIMARY KEY,
93
+ expires_at INTEGER NOT NULL
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS mesh_direct_dispatches (
97
+ task_id TEXT PRIMARY KEY,
98
+ mesh_id TEXT NOT NULL,
99
+ node_id TEXT,
100
+ session_id TEXT,
101
+ provider_type TEXT,
102
+ message TEXT NOT NULL,
103
+ task_mode TEXT,
104
+ via TEXT NOT NULL,
105
+ status TEXT NOT NULL DEFAULT 'dispatched',
106
+ dispatched_to_idle_session INTEGER NOT NULL DEFAULT 0,
107
+ dispatched_at TEXT NOT NULL,
108
+ updated_at TEXT NOT NULL
109
+ );
110
+
111
+ CREATE INDEX IF NOT EXISTS idx_direct_dispatches_mesh_session
112
+ ON mesh_direct_dispatches(mesh_id, session_id, status);
84
113
  `);
85
114
  }
86
115
 
116
+ hasCompletionFingerprint(fingerprint: string): boolean {
117
+ const now = Date.now();
118
+ const row = this.db
119
+ .prepare('SELECT 1 FROM mesh_completion_fingerprints WHERE fingerprint = ? AND expires_at > ?')
120
+ .get(fingerprint, now) as { 1: number } | undefined;
121
+ // Sweep expired fingerprints every 100 reads so stale rows don't accumulate
122
+ // even during read-heavy (non-write) periods when recordFingerprintSeen is idle.
123
+ if (++this.fingerprintSweepCounter >= 100) {
124
+ this.fingerprintSweepCounter = 0;
125
+ this.sweepExpiredFingerprints();
126
+ }
127
+ return row !== undefined;
128
+ }
129
+
130
+ recordCompletionFingerprint(fingerprint: string, ttlMs: number): void {
131
+ const expiresAt = Date.now() + ttlMs;
132
+ this.db.prepare('INSERT OR REPLACE INTO mesh_completion_fingerprints (fingerprint, expires_at) VALUES (?, ?)')
133
+ .run(fingerprint, expiresAt);
134
+ this.maybeCheckpointWal();
135
+ }
136
+
137
+ sweepExpiredFingerprints(): void {
138
+ this.db.prepare('DELETE FROM mesh_completion_fingerprints WHERE expires_at <= ?').run(Date.now());
139
+ }
140
+
141
+ private maybeCheckpointWal(): void {
142
+ if (++this.walWriteCounter < BeadsDB.WAL_CHECK_INTERVAL) return;
143
+ this.walWriteCounter = 0;
144
+ try {
145
+ const walPath = `${this.dbPath}-wal`;
146
+ if (!existsSync(walPath)) return;
147
+ const size = statSync(walPath).size;
148
+ if (size < BeadsDB.WAL_MAX_BYTES) return;
149
+ process.stderr.write(
150
+ `[adhdev-mesh] WAL file ${Math.round(size / 1024 / 1024)}MB exceeds threshold; forcing checkpoint\n`,
151
+ );
152
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
153
+ } catch { /* best-effort */ }
154
+ }
155
+
87
156
  private ensureLegacyQueueMigrated(meshId: string): void {
88
157
  if (this.migratedMeshIds.has(meshId)) return;
89
158
  this.migratedMeshIds.add(meshId);
@@ -136,7 +205,8 @@ export class BeadsDB {
136
205
  const rows = this.db
137
206
  .prepare('SELECT id, status, updated_at FROM mesh_queue WHERE mesh_id = ? ORDER BY id ASC')
138
207
  .all(meshId) as Array<{ id: string; status: string; updated_at: string }>;
139
- return rows.map(row => `${row.id}:${row.status}:${row.updated_at}`).join('|');
208
+ // Tab as field delimiter (UUIDs and ISO timestamps never contain tabs).
209
+ return rows.map(row => `${row.id}\t${row.status}\t${row.updated_at}`).join('\n');
140
210
  }
141
211
 
142
212
  replaceQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
@@ -152,6 +222,7 @@ export class BeadsDB {
152
222
  `);
153
223
  deleteStmt.run(meshId);
154
224
  for (const entry of queue) insert.run(this.toRow(entry));
225
+ this.maybeCheckpointWal();
155
226
  }
156
227
 
157
228
  deleteQueue(meshId: string): void {
@@ -159,6 +230,134 @@ export class BeadsDB {
159
230
  this.migratedMeshIds.delete(meshId);
160
231
  }
161
232
 
233
+ insertQueueEntry(entry: MeshWorkQueueEntry): void {
234
+ this.db.prepare(`
235
+ INSERT INTO mesh_queue (
236
+ id, mesh_id, status, target_node_id, target_session_id,
237
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
238
+ ) VALUES (
239
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
240
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
241
+ )
242
+ `).run(this.toRow(entry));
243
+ this.maybeCheckpointWal();
244
+ }
245
+
246
+ updateQueueEntry(entry: MeshWorkQueueEntry): void {
247
+ const now = new Date().toISOString();
248
+ entry.updatedAt = now;
249
+ this.db.prepare(`
250
+ UPDATE mesh_queue SET
251
+ status = @status,
252
+ target_node_id = @targetNodeId,
253
+ target_session_id = @targetSessionId,
254
+ assigned_node_id = @assignedNodeId,
255
+ assigned_session_id = @assignedSessionId,
256
+ updated_at = @updatedAt,
257
+ payload = @payload
258
+ WHERE id = @id AND mesh_id = @meshId
259
+ `).run(this.toRow(entry));
260
+ this.maybeCheckpointWal();
261
+ }
262
+
263
+ findQueueEntryById(meshId: string, id: string): MeshWorkQueueEntry | null {
264
+ this.ensureLegacyQueueMigrated(meshId);
265
+ const row = this.db.prepare(
266
+ 'SELECT payload FROM mesh_queue WHERE id = ? AND mesh_id = ?'
267
+ ).get(id, meshId) as { payload: string } | undefined;
268
+ return row ? JSON.parse(row.payload) as MeshWorkQueueEntry : null;
269
+ }
270
+
271
+ hasActiveAssignment(meshId: string, sessionId: string, nodeId: string): boolean {
272
+ this.ensureLegacyQueueMigrated(meshId);
273
+ const row = this.db.prepare(`
274
+ SELECT 1 FROM mesh_queue
275
+ WHERE mesh_id = ? AND status = 'assigned'
276
+ AND (assigned_session_id = ? OR assigned_node_id = ?)
277
+ LIMIT 1
278
+ `).get(meshId, sessionId, nodeId);
279
+ return row !== undefined;
280
+ }
281
+
282
+ // O(1) claim: transaction ensures only one session claims a pending task
283
+ claimNextQueueTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
284
+ return this.transaction(() => {
285
+ this.ensureLegacyQueueMigrated(meshId);
286
+ if (this.hasActiveAssignment(meshId, sessionId, nodeId)) return null;
287
+
288
+ // Priority: session-targeted > node-targeted (no session) > unconstrained
289
+ const row = (
290
+ this.db.prepare(`
291
+ SELECT payload FROM mesh_queue
292
+ WHERE mesh_id = ? AND status = 'pending' AND target_session_id = ?
293
+ ORDER BY created_at ASC LIMIT 1
294
+ `).get(meshId, sessionId) as { payload: string } | undefined
295
+ ) || (
296
+ this.db.prepare(`
297
+ SELECT payload FROM mesh_queue
298
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id = ? AND target_session_id IS NULL
299
+ ORDER BY created_at ASC LIMIT 1
300
+ `).get(meshId, nodeId) as { payload: string } | undefined
301
+ ) || (
302
+ this.db.prepare(`
303
+ SELECT payload FROM mesh_queue
304
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id IS NULL AND target_session_id IS NULL
305
+ ORDER BY created_at ASC LIMIT 1
306
+ `).get(meshId) as { payload: string } | undefined
307
+ );
308
+ if (!row) return null;
309
+
310
+ const entry = JSON.parse(row.payload) as MeshWorkQueueEntry;
311
+ const now = new Date().toISOString();
312
+ entry.status = 'assigned';
313
+ entry.assignedNodeId = nodeId;
314
+ entry.assignedSessionId = sessionId;
315
+ entry.dispatchTimestamp = now;
316
+ entry.updatedAt = now;
317
+
318
+ this.db.prepare(`
319
+ UPDATE mesh_queue SET
320
+ status = 'assigned', assigned_node_id = ?, assigned_session_id = ?,
321
+ updated_at = ?, payload = ?
322
+ WHERE id = ? AND mesh_id = ?
323
+ `).run(nodeId, sessionId, now, JSON.stringify(entry), entry.id, meshId);
324
+
325
+ this.maybeCheckpointWal();
326
+ return entry;
327
+ });
328
+ }
329
+
330
+ getQueueStatsByStatus(meshId: string): { status: string; count: number }[] {
331
+ this.ensureLegacyQueueMigrated(meshId);
332
+ return this.db.prepare(
333
+ `SELECT status, COUNT(*) as count FROM mesh_queue WHERE mesh_id = ? GROUP BY status`
334
+ ).all(meshId) as { status: string; count: number }[];
335
+ }
336
+
337
+ getActiveAssignmentDetails(meshId: string): Array<{ id: string; nodeId?: string; sessionId?: string; message: string }> {
338
+ this.ensureLegacyQueueMigrated(meshId);
339
+ const rows = this.db.prepare(`
340
+ SELECT assigned_node_id, assigned_session_id, payload
341
+ FROM mesh_queue WHERE mesh_id = ? AND status = 'assigned'
342
+ `).all(meshId) as Array<{ assigned_node_id: string | null; assigned_session_id: string | null; payload: string }>;
343
+ return rows.map(r => {
344
+ let id = '', message = '';
345
+ try { const e = JSON.parse(r.payload) as MeshWorkQueueEntry; id = e.id; message = e.message; } catch { /* ignore */ }
346
+ return { id, nodeId: r.assigned_node_id ?? undefined, sessionId: r.assigned_session_id ?? undefined, message };
347
+ });
348
+ }
349
+
350
+ findAssignedBySession(meshId: string, sessionId: string, occurredAtIso?: string): MeshWorkQueueEntry | null {
351
+ this.ensureLegacyQueueMigrated(meshId);
352
+ // Use updated_at (≈ dispatchTimestamp when status='assigned') for the occurredAt filter.
353
+ const sql = occurredAtIso
354
+ ? `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned' AND updated_at <= ? ORDER BY updated_at DESC LIMIT 1`
355
+ : `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned' ORDER BY updated_at DESC LIMIT 1`;
356
+ const args: string[] = occurredAtIso ? [meshId, sessionId, occurredAtIso] : [meshId, sessionId];
357
+ const row = this.db.prepare(sql).get(...args as [string, string, string?]) as { payload: string } | undefined;
358
+ return row ? JSON.parse(row.payload) as MeshWorkQueueEntry : null;
359
+ }
360
+
162
361
  private toRow(entry: MeshWorkQueueEntry): Record<string, unknown> {
163
362
  return {
164
363
  id: entry.id,
@@ -173,4 +372,108 @@ export class BeadsDB {
173
372
  payload: JSON.stringify(entry),
174
373
  };
175
374
  }
375
+
376
+ // ── Direct Dispatch Tracking ─────────────────────────────────────────────
377
+
378
+ insertDirectDispatch(entry: {
379
+ taskId: string;
380
+ meshId: string;
381
+ nodeId?: string;
382
+ sessionId?: string;
383
+ providerType?: string;
384
+ message: string;
385
+ taskMode?: string;
386
+ via: string;
387
+ dispatchedToIdleSession?: boolean;
388
+ dispatchedAt: string;
389
+ }): void {
390
+ const now = new Date().toISOString();
391
+ this.db.prepare(`
392
+ INSERT OR REPLACE INTO mesh_direct_dispatches
393
+ (task_id, mesh_id, node_id, session_id, provider_type, message, task_mode, via,
394
+ status, dispatched_to_idle_session, dispatched_at, updated_at)
395
+ VALUES
396
+ (@taskId, @meshId, @nodeId, @sessionId, @providerType, @message, @taskMode, @via,
397
+ 'dispatched', @dispatchedToIdle, @dispatchedAt, @updatedAt)
398
+ `).run({
399
+ taskId: entry.taskId,
400
+ meshId: entry.meshId,
401
+ nodeId: entry.nodeId ?? null,
402
+ sessionId: entry.sessionId ?? null,
403
+ providerType: entry.providerType ?? null,
404
+ message: entry.message,
405
+ taskMode: entry.taskMode ?? null,
406
+ via: entry.via,
407
+ dispatchedToIdle: entry.dispatchedToIdleSession ? 1 : 0,
408
+ dispatchedAt: entry.dispatchedAt,
409
+ updatedAt: now,
410
+ });
411
+ }
412
+
413
+ getActiveDirectDispatches(meshId: string): Array<{
414
+ taskId: string;
415
+ meshId: string;
416
+ nodeId: string | null;
417
+ sessionId: string | null;
418
+ providerType: string | null;
419
+ message: string;
420
+ taskMode: string | null;
421
+ via: string;
422
+ status: string;
423
+ dispatchedToIdleSession: boolean;
424
+ dispatchedAt: string;
425
+ updatedAt: string;
426
+ }> {
427
+ const rows = this.db.prepare(`
428
+ SELECT task_id, mesh_id, node_id, session_id, provider_type, message, task_mode, via,
429
+ status, dispatched_to_idle_session, dispatched_at, updated_at
430
+ FROM mesh_direct_dispatches
431
+ WHERE mesh_id = ? AND status NOT IN ('completed', 'failed', 'stale')
432
+ ORDER BY dispatched_at ASC
433
+ `).all(meshId) as Array<Record<string, unknown>>;
434
+ return rows.map(r => ({
435
+ taskId: r.task_id as string,
436
+ meshId: r.mesh_id as string,
437
+ nodeId: r.node_id as string | null,
438
+ sessionId: r.session_id as string | null,
439
+ providerType: r.provider_type as string | null,
440
+ message: r.message as string,
441
+ taskMode: r.task_mode as string | null,
442
+ via: r.via as string,
443
+ status: r.status as string,
444
+ dispatchedToIdleSession: (r.dispatched_to_idle_session as number) === 1,
445
+ dispatchedAt: r.dispatched_at as string,
446
+ updatedAt: r.updated_at as string,
447
+ }));
448
+ }
449
+
450
+ updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void {
451
+ if (!sessionId) return; // never update rows without a session binding
452
+ const now = new Date().toISOString();
453
+ this.db.prepare(`
454
+ UPDATE mesh_direct_dispatches
455
+ SET status = @status, updated_at = @updatedAt
456
+ WHERE mesh_id = @meshId AND session_id = @sessionId
457
+ AND session_id IS NOT NULL
458
+ AND status NOT IN ('completed', 'failed')
459
+ `).run({ status, meshId, sessionId, updatedAt: now });
460
+ }
461
+
462
+ cleanupTerminalDirectDispatches(olderThanMs: number): void {
463
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
464
+ this.db.prepare(`
465
+ DELETE FROM mesh_direct_dispatches
466
+ WHERE status IN ('completed', 'failed', 'stale') AND updated_at < ?
467
+ `).run(cutoff);
468
+ }
469
+
470
+ markStaleDirectDispatches(meshId: string, olderThanMs: number): void {
471
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
472
+ const now = new Date().toISOString();
473
+ this.db.prepare(`
474
+ UPDATE mesh_direct_dispatches
475
+ SET status = 'stale', updated_at = ?
476
+ WHERE mesh_id = ? AND status = 'dispatched' AND dispatched_at < ?
477
+ `).run(now, meshId, cutoff);
478
+ }
176
479
  }