@adhdev/daemon-core 0.9.82-rc.195 → 0.9.82-rc.197

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 (41) hide show
  1. package/dist/cli-adapter-types.d.ts +1 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +594 -78
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +593 -83
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/mesh/contracts.d.ts +1 -1
  8. package/dist/mesh/mesh-active-work.d.ts +1 -1
  9. package/dist/mesh/mesh-delivery-policy.d.ts +126 -0
  10. package/dist/mesh/{beads-db.d.ts → mesh-runtime-store.d.ts} +68 -2
  11. package/dist/mesh/mesh-work-queue.d.ts +3 -3
  12. package/dist/providers/provider-instance.d.ts +1 -1
  13. package/dist/providers/spec/driver.d.ts +4 -1
  14. package/dist/providers/spec/schema.gen.d.ts +46 -0
  15. package/dist/providers/spec/types.d.ts +39 -0
  16. package/dist/shared-types-extra.d.ts +1 -1
  17. package/dist/status/normalize.d.ts +1 -1
  18. package/dist/status/normalize.js +1 -0
  19. package/dist/status/normalize.js.map +1 -1
  20. package/dist/status/normalize.mjs +1 -0
  21. package/dist/status/normalize.mjs.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli-adapter-types.ts +1 -0
  24. package/src/cli-adapters/cli-state-engine.ts +44 -2
  25. package/src/index.ts +4 -0
  26. package/src/mesh/contracts.ts +1 -1
  27. package/src/mesh/mesh-active-work.ts +8 -8
  28. package/src/mesh/mesh-delivery-policy.ts +298 -0
  29. package/src/mesh/mesh-events.ts +64 -15
  30. package/src/mesh/{beads-db.ts → mesh-runtime-store.ts} +249 -7
  31. package/src/mesh/mesh-work-queue.ts +33 -33
  32. package/src/providers/cli-provider-instance.ts +31 -8
  33. package/src/providers/provider-instance.ts +1 -1
  34. package/src/providers/spec/driver.ts +34 -3
  35. package/src/providers/spec/evaluator.ts +32 -3
  36. package/src/providers/spec/schema.gen.ts +22 -2
  37. package/src/providers/spec/schema.json +1 -0
  38. package/src/providers/spec/types.ts +39 -0
  39. package/src/providers/types/interactive-prompt.ts +21 -7
  40. package/src/shared-types-extra.ts +1 -1
  41. package/src/status/normalize.ts +2 -0
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
3
  import { createRequire } from 'module';
4
4
  import { getLedgerDir } from './mesh-ledger.js';
@@ -26,8 +26,31 @@ function legacyQueuePath(meshId: string): string {
26
26
  return join(getLedgerDir(), `${safeMeshId(meshId)}.queue.json`);
27
27
  }
28
28
 
29
- export class BeadsDB {
30
- private static instance: BeadsDB | undefined;
29
+ function meshRuntimeStorePath(): string {
30
+ const dir = getLedgerDir();
31
+ const nextPath = join(dir, 'mesh-runtime.db');
32
+ if (existsSync(nextPath)) return nextPath;
33
+
34
+ const legacyPath = join(dir, 'beads.db');
35
+ if (!existsSync(legacyPath)) return nextPath;
36
+
37
+ try {
38
+ renameSync(legacyPath, nextPath);
39
+ for (const suffix of ['-wal', '-shm']) {
40
+ const legacyCompanion = `${legacyPath}${suffix}`;
41
+ if (existsSync(legacyCompanion)) {
42
+ renameSync(legacyCompanion, `${nextPath}${suffix}`);
43
+ }
44
+ }
45
+ } catch {
46
+ // Best-effort compatibility for existing installs. If migration fails,
47
+ // opening the new store will create a clean DB instead of blocking boot.
48
+ }
49
+ return nextPath;
50
+ }
51
+
52
+ export class MeshRuntimeStore {
53
+ private static instance: MeshRuntimeStore | undefined;
31
54
  private readonly db: DatabaseHandle;
32
55
  private readonly dbPath: string;
33
56
  private readonly migratedMeshIds = new Set<string>();
@@ -49,9 +72,9 @@ export class BeadsDB {
49
72
  this.migrate();
50
73
  }
51
74
 
52
- static getInstance(): BeadsDB {
75
+ static getInstance(): MeshRuntimeStore {
53
76
  if (!this.instance) {
54
- this.instance = new BeadsDB(join(getLedgerDir(), 'beads.db'));
77
+ this.instance = new MeshRuntimeStore(meshRuntimeStorePath());
55
78
  }
56
79
  return this.instance;
57
80
  }
@@ -120,6 +143,49 @@ export class BeadsDB {
120
143
  metadata TEXT,
121
144
  PRIMARY KEY (node_id, session_id)
122
145
  );
146
+
147
+ CREATE TABLE IF NOT EXISTS mesh_session_delivery (
148
+ id TEXT PRIMARY KEY,
149
+ mesh_id TEXT NOT NULL,
150
+ node_id TEXT,
151
+ session_id TEXT,
152
+ provider_type TEXT,
153
+ task_id TEXT,
154
+ kind TEXT NOT NULL,
155
+ priority INTEGER NOT NULL DEFAULT 0,
156
+ message TEXT NOT NULL,
157
+ status TEXT NOT NULL DEFAULT 'queued',
158
+ deliver_after TEXT,
159
+ expires_at TEXT,
160
+ attempt_count INTEGER NOT NULL DEFAULT 0,
161
+ source_coordinator_session_id TEXT,
162
+ source_coordinator_daemon_id TEXT,
163
+ last_error TEXT,
164
+ created_at TEXT NOT NULL,
165
+ updated_at TEXT NOT NULL
166
+ );
167
+
168
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_mesh_status
169
+ ON mesh_session_delivery(mesh_id, status, created_at);
170
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_session
171
+ ON mesh_session_delivery(mesh_id, session_id, status);
172
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_task
173
+ ON mesh_session_delivery(mesh_id, task_id);
174
+
175
+ CREATE TABLE IF NOT EXISTS mesh_completion_conflicts (
176
+ id TEXT PRIMARY KEY,
177
+ mesh_id TEXT NOT NULL,
178
+ fingerprint TEXT NOT NULL,
179
+ conflicting_task_id TEXT,
180
+ conflicting_session_id TEXT,
181
+ original_task_id TEXT,
182
+ original_session_id TEXT,
183
+ event TEXT NOT NULL,
184
+ created_at TEXT NOT NULL
185
+ );
186
+
187
+ CREATE INDEX IF NOT EXISTS idx_mesh_completion_conflicts_mesh
188
+ ON mesh_completion_conflicts(mesh_id, created_at);
123
189
  `);
124
190
  }
125
191
 
@@ -149,13 +215,13 @@ export class BeadsDB {
149
215
  }
150
216
 
151
217
  private maybeCheckpointWal(): void {
152
- if (++this.walWriteCounter < BeadsDB.WAL_CHECK_INTERVAL) return;
218
+ if (++this.walWriteCounter < MeshRuntimeStore.WAL_CHECK_INTERVAL) return;
153
219
  this.walWriteCounter = 0;
154
220
  try {
155
221
  const walPath = `${this.dbPath}-wal`;
156
222
  if (!existsSync(walPath)) return;
157
223
  const size = statSync(walPath).size;
158
- if (size < BeadsDB.WAL_MAX_BYTES) return;
224
+ if (size < MeshRuntimeStore.WAL_MAX_BYTES) return;
159
225
  process.stderr.write(
160
226
  `[adhdev-mesh] WAL file ${Math.round(size / 1024 / 1024)}MB exceeds threshold; forcing checkpoint\n`,
161
227
  );
@@ -524,4 +590,180 @@ export class BeadsDB {
524
590
  pruneExpiredRemoteIdleSessions(): void {
525
591
  this.db.prepare('DELETE FROM remote_idle_sessions WHERE expires_at <= ?').run(Date.now());
526
592
  }
593
+
594
+ // ── Session Delivery Queue ───────────────────────────────────────────────
595
+
596
+ insertSessionDelivery(entry: {
597
+ id: string;
598
+ meshId: string;
599
+ nodeId?: string;
600
+ sessionId?: string;
601
+ providerType?: string;
602
+ taskId?: string;
603
+ kind: string;
604
+ priority?: number;
605
+ message: string;
606
+ status: string;
607
+ deliverAfter?: string;
608
+ expiresAt?: string;
609
+ sourceCoordinatorSessionId?: string;
610
+ sourceCoordinatorDaemonId?: string;
611
+ createdAt: string;
612
+ updatedAt: string;
613
+ }): void {
614
+ this.db.prepare(`
615
+ INSERT OR REPLACE INTO mesh_session_delivery (
616
+ id, mesh_id, node_id, session_id, provider_type, task_id, kind, priority,
617
+ message, status, deliver_after, expires_at, attempt_count,
618
+ source_coordinator_session_id, source_coordinator_daemon_id,
619
+ last_error, created_at, updated_at
620
+ ) VALUES (
621
+ @id, @meshId, @nodeId, @sessionId, @providerType, @taskId, @kind, @priority,
622
+ @message, @status, @deliverAfter, @expiresAt, 0,
623
+ @sourceCoordinatorSessionId, @sourceCoordinatorDaemonId,
624
+ NULL, @createdAt, @updatedAt
625
+ )
626
+ `).run({
627
+ id: entry.id,
628
+ meshId: entry.meshId,
629
+ nodeId: entry.nodeId ?? null,
630
+ sessionId: entry.sessionId ?? null,
631
+ providerType: entry.providerType ?? null,
632
+ taskId: entry.taskId ?? null,
633
+ kind: entry.kind,
634
+ priority: entry.priority ?? 0,
635
+ message: entry.message,
636
+ status: entry.status,
637
+ deliverAfter: entry.deliverAfter ?? null,
638
+ expiresAt: entry.expiresAt ?? null,
639
+ sourceCoordinatorSessionId: entry.sourceCoordinatorSessionId ?? null,
640
+ sourceCoordinatorDaemonId: entry.sourceCoordinatorDaemonId ?? null,
641
+ createdAt: entry.createdAt,
642
+ updatedAt: entry.updatedAt,
643
+ });
644
+ this.maybeCheckpointWal();
645
+ }
646
+
647
+ updateSessionDeliveryStatus(id: string, status: string, opts?: { lastError?: string; incrementAttempt?: boolean }): void {
648
+ const now = new Date().toISOString();
649
+ if (opts?.incrementAttempt) {
650
+ this.db.prepare(`
651
+ UPDATE mesh_session_delivery
652
+ SET status = @status, last_error = @lastError, attempt_count = attempt_count + 1, updated_at = @updatedAt
653
+ WHERE id = @id
654
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
655
+ } else {
656
+ this.db.prepare(`
657
+ UPDATE mesh_session_delivery
658
+ SET status = @status, last_error = @lastError, updated_at = @updatedAt
659
+ WHERE id = @id
660
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
661
+ }
662
+ }
663
+
664
+ getActiveSessionDeliveries(meshId: string, sessionId?: string): Array<{
665
+ id: string; meshId: string; nodeId: string | null; sessionId: string | null;
666
+ providerType: string | null; taskId: string | null; kind: string; priority: number;
667
+ message: string; status: string; deliverAfter: string | null; expiresAt: string | null;
668
+ attemptCount: number; sourceCoordinatorSessionId: string | null;
669
+ sourceCoordinatorDaemonId: string | null; lastError: string | null;
670
+ createdAt: string; updatedAt: string;
671
+ }> {
672
+ const now = new Date().toISOString();
673
+ const sql = sessionId
674
+ ? `SELECT * FROM mesh_session_delivery WHERE mesh_id = ? AND session_id = ? AND status NOT IN ('delivered','completed','failed','expired','cancelled') AND (expires_at IS NULL OR expires_at > ?) ORDER BY priority DESC, created_at ASC`
675
+ : `SELECT * FROM mesh_session_delivery WHERE mesh_id = ? AND status NOT IN ('delivered','completed','failed','expired','cancelled') AND (expires_at IS NULL OR expires_at > ?) ORDER BY priority DESC, created_at ASC`;
676
+ const rows = sessionId
677
+ ? this.db.prepare(sql).all(meshId, sessionId, now) as Array<Record<string, unknown>>
678
+ : this.db.prepare(sql).all(meshId, now) as Array<Record<string, unknown>>;
679
+ return rows.map(r => ({
680
+ id: r.id as string,
681
+ meshId: r.mesh_id as string,
682
+ nodeId: r.node_id as string | null,
683
+ sessionId: r.session_id as string | null,
684
+ providerType: r.provider_type as string | null,
685
+ taskId: r.task_id as string | null,
686
+ kind: r.kind as string,
687
+ priority: r.priority as number,
688
+ message: r.message as string,
689
+ status: r.status as string,
690
+ deliverAfter: r.deliver_after as string | null,
691
+ expiresAt: r.expires_at as string | null,
692
+ attemptCount: r.attempt_count as number,
693
+ sourceCoordinatorSessionId: r.source_coordinator_session_id as string | null,
694
+ sourceCoordinatorDaemonId: r.source_coordinator_daemon_id as string | null,
695
+ lastError: r.last_error as string | null,
696
+ createdAt: r.created_at as string,
697
+ updatedAt: r.updated_at as string,
698
+ }));
699
+ }
700
+
701
+ expireStaleSessionDeliveries(meshId: string): void {
702
+ const now = new Date().toISOString();
703
+ this.db.prepare(`
704
+ UPDATE mesh_session_delivery
705
+ SET status = 'expired', updated_at = ?
706
+ WHERE mesh_id = ? AND expires_at IS NOT NULL AND expires_at <= ?
707
+ AND status NOT IN ('delivered','completed','failed','expired','cancelled')
708
+ `).run(now, meshId, now);
709
+ }
710
+
711
+ deleteSessionDeliveries(meshId: string): void {
712
+ this.db.prepare('DELETE FROM mesh_session_delivery WHERE mesh_id = ?').run(meshId);
713
+ }
714
+
715
+ // ── Completion Conflict Diagnostics ──────────────────────────────────────
716
+
717
+ recordCompletionConflict(entry: {
718
+ id: string;
719
+ meshId: string;
720
+ fingerprint: string;
721
+ conflictingTaskId?: string;
722
+ conflictingSessionId?: string;
723
+ originalTaskId?: string;
724
+ originalSessionId?: string;
725
+ event: string;
726
+ createdAt: string;
727
+ }): void {
728
+ this.db.prepare(`
729
+ INSERT OR IGNORE INTO mesh_completion_conflicts
730
+ (id, mesh_id, fingerprint, conflicting_task_id, conflicting_session_id,
731
+ original_task_id, original_session_id, event, created_at)
732
+ VALUES (@id, @meshId, @fingerprint, @conflictingTaskId, @conflictingSessionId,
733
+ @originalTaskId, @originalSessionId, @event, @createdAt)
734
+ `).run({
735
+ id: entry.id,
736
+ meshId: entry.meshId,
737
+ fingerprint: entry.fingerprint,
738
+ conflictingTaskId: entry.conflictingTaskId ?? null,
739
+ conflictingSessionId: entry.conflictingSessionId ?? null,
740
+ originalTaskId: entry.originalTaskId ?? null,
741
+ originalSessionId: entry.originalSessionId ?? null,
742
+ event: entry.event,
743
+ createdAt: entry.createdAt,
744
+ });
745
+ this.maybeCheckpointWal();
746
+ }
747
+
748
+ getRecentCompletionConflicts(meshId: string, limitMs: number = 60 * 60 * 1000): Array<{
749
+ id: string; meshId: string; fingerprint: string; conflictingTaskId: string | null;
750
+ conflictingSessionId: string | null; originalTaskId: string | null;
751
+ originalSessionId: string | null; event: string; createdAt: string;
752
+ }> {
753
+ const cutoff = new Date(Date.now() - limitMs).toISOString();
754
+ const rows = this.db.prepare(
755
+ 'SELECT * FROM mesh_completion_conflicts WHERE mesh_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT 50'
756
+ ).all(meshId, cutoff) as Array<Record<string, unknown>>;
757
+ return rows.map(r => ({
758
+ id: r.id as string,
759
+ meshId: r.mesh_id as string,
760
+ fingerprint: r.fingerprint as string,
761
+ conflictingTaskId: r.conflicting_task_id as string | null,
762
+ conflictingSessionId: r.conflicting_session_id as string | null,
763
+ originalTaskId: r.original_task_id as string | null,
764
+ originalSessionId: r.original_session_id as string | null,
765
+ event: r.event as string,
766
+ createdAt: r.created_at as string,
767
+ }));
768
+ }
527
769
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
3
3
  import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
- import { BeadsDB } from './beads-db.js';
4
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
5
5
 
6
6
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
7
7
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -145,15 +145,15 @@ export function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags:
145
145
  }
146
146
 
147
147
  function withQueueLock<T>(_meshId: string, fn: () => T): T {
148
- return BeadsDB.getInstance().transaction(fn);
148
+ return MeshRuntimeStore.getInstance().transaction(fn);
149
149
  }
150
150
 
151
151
  function readQueue(meshId: string): MeshWorkQueueEntry[] {
152
- return BeadsDB.getInstance().getQueueEntries(meshId);
152
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId);
153
153
  }
154
154
 
155
155
  function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
156
- BeadsDB.getInstance().replaceQueue(meshId, queue);
156
+ MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
157
157
  }
158
158
 
159
159
  /**
@@ -181,7 +181,7 @@ export function enqueueTask(
181
181
  createdAt: new Date().toISOString(),
182
182
  updatedAt: new Date().toISOString(),
183
183
  };
184
- BeadsDB.getInstance().insertQueueEntry(entry);
184
+ MeshRuntimeStore.getInstance().insertQueueEntry(entry);
185
185
  return entry;
186
186
  }
187
187
 
@@ -189,18 +189,18 @@ export function enqueueTask(
189
189
  * Get all tasks in the queue, optionally filtered by status.
190
190
  */
191
191
  export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }): MeshWorkQueueEntry[] {
192
- return BeadsDB.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
192
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
193
193
  }
194
194
 
195
195
  export function getMeshQueueRevision(meshId: string): string {
196
- return BeadsDB.getInstance().getQueueRevision(meshId);
196
+ return MeshRuntimeStore.getInstance().getQueueRevision(meshId);
197
197
  }
198
198
 
199
199
  /**
200
200
  * Find the next pending task that this node is allowed to claim, and mark it as assigned.
201
201
  */
202
202
  export function claimNextTask(meshId: string, nodeId: string, sessionId: string, capabilityTags?: string[]): MeshWorkQueueEntry | null {
203
- return BeadsDB.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags);
203
+ return MeshRuntimeStore.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags);
204
204
  }
205
205
 
206
206
  /**
@@ -215,10 +215,10 @@ export function updateTaskStatus(
215
215
  ): MeshWorkQueueEntry | null {
216
216
  requireMeshHostQueueOwner(opts);
217
217
  return withQueueLock(meshId, () => {
218
- const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
218
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
219
219
  if (!entry) return null;
220
220
  entry.status = status;
221
- BeadsDB.getInstance().updateQueueEntry(entry);
221
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
222
222
  return entry;
223
223
  });
224
224
  }
@@ -229,11 +229,11 @@ export function recordTaskAutoLaunch(
229
229
  autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
230
230
  ): MeshWorkQueueEntry | null {
231
231
  return withQueueLock(meshId, () => {
232
- const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
232
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
233
233
  if (!entry) return null;
234
234
  const now = new Date().toISOString();
235
235
  entry.autoLaunch = { ...autoLaunch, updatedAt: now };
236
- BeadsDB.getInstance().updateQueueEntry(entry);
236
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
237
237
  return entry;
238
238
  });
239
239
  }
@@ -248,13 +248,13 @@ export function cancelTask(
248
248
  ): MeshWorkQueueEntry | null {
249
249
  requireMeshHostQueueOwner(opts);
250
250
  return withQueueLock(meshId, () => {
251
- const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
251
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
252
252
  if (!entry) return null;
253
253
  const now = new Date().toISOString();
254
254
  entry.status = 'cancelled';
255
255
  entry.cancelledAt = now;
256
256
  if (opts?.reason) entry.cancelReason = opts.reason;
257
- BeadsDB.getInstance().updateQueueEntry(entry);
257
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
258
258
  return entry;
259
259
  });
260
260
  }
@@ -276,7 +276,7 @@ export function requeueTask(
276
276
  ): MeshWorkQueueEntry | null {
277
277
  requireMeshHostQueueOwner(opts);
278
278
  return withQueueLock(meshId, () => {
279
- const entry = BeadsDB.getInstance().findQueueEntryById(meshId, taskId);
279
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
280
280
  if (!entry) return null;
281
281
  entry.status = 'pending';
282
282
  delete entry.assignedNodeId;
@@ -290,7 +290,7 @@ export function requeueTask(
290
290
  entry.requeuedAt = new Date().toISOString();
291
291
  entry.requeueCount = (entry.requeueCount || 0) + 1;
292
292
  if (opts?.reason) entry.requeueReason = opts.reason;
293
- BeadsDB.getInstance().updateQueueEntry(entry);
293
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
294
294
  return entry;
295
295
  });
296
296
  }
@@ -306,10 +306,10 @@ export function updateSessionTaskStatus(
306
306
  ): MeshWorkQueueEntry | null {
307
307
  return withQueueLock(meshId, () => {
308
308
  const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
309
- const entry = BeadsDB.getInstance().findAssignedBySession(meshId, sessionId, occurredAtIso);
309
+ const entry = MeshRuntimeStore.getInstance().findAssignedBySession(meshId, sessionId, occurredAtIso);
310
310
  if (!entry) return null;
311
311
  entry.status = status;
312
- BeadsDB.getInstance().updateQueueEntry(entry);
312
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
313
313
  return entry;
314
314
  });
315
315
  }
@@ -339,7 +339,7 @@ export interface MeshWorkQueueStats {
339
339
  * Return aggregate queue statistics for the given mesh.
340
340
  */
341
341
  export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
342
- const rows = BeadsDB.getInstance().getQueueStatsByStatus(meshId);
342
+ const rows = MeshRuntimeStore.getInstance().getQueueStatsByStatus(meshId);
343
343
  const counts: Record<string, number> = {};
344
344
  for (const r of rows) counts[r.status] = r.count;
345
345
  const pending = counts['pending'] ?? 0;
@@ -358,33 +358,33 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
358
358
  cancelled,
359
359
  activeCounts: { pending, assigned },
360
360
  historicalCounts: { completed, failed, cancelled },
361
- activeAssignments: BeadsDB.getInstance().getActiveAssignmentDetails(meshId),
361
+ activeAssignments: MeshRuntimeStore.getInstance().getActiveAssignmentDetails(meshId),
362
362
  };
363
363
  }
364
364
 
365
365
  export function __replaceMeshQueueForTests(meshId: string, queue: MeshWorkQueueEntry[]): void {
366
- BeadsDB.getInstance().transaction(() => {
367
- BeadsDB.getInstance().replaceQueue(meshId, queue);
366
+ MeshRuntimeStore.getInstance().transaction(() => {
367
+ MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
368
368
  });
369
369
  }
370
370
 
371
371
  export function __clearMeshQueueForTests(meshId: string): void {
372
- BeadsDB.getInstance().deleteQueue(meshId);
372
+ MeshRuntimeStore.getInstance().deleteQueue(meshId);
373
373
  }
374
374
 
375
375
  export function __clearDirectDispatchesForTests(meshId: string): void {
376
- BeadsDB.getInstance().deleteDirectDispatches(meshId);
376
+ MeshRuntimeStore.getInstance().deleteDirectDispatches(meshId);
377
377
  }
378
378
 
379
- export function __resetBeadsDBForTests(): void {
380
- BeadsDB.resetForTests();
379
+ export function __resetMeshRuntimeStoreForTests(): void {
380
+ MeshRuntimeStore.resetForTests();
381
381
  }
382
382
 
383
383
  // ── Direct Dispatch Tracking ─────────────────────────────────────────────────
384
384
  // Persists direct (non-queue) task dispatches so buildMeshActiveWork can read
385
- // active work from BeadsDB instead of scanning ledger JSONL entries.
385
+ // active work from MeshRuntimeStore instead of scanning ledger JSONL entries.
386
386
 
387
- export type DirectDispatchRecord = ReturnType<BeadsDB['getActiveDirectDispatches']>[number];
387
+ export type DirectDispatchRecord = ReturnType<MeshRuntimeStore['getActiveDirectDispatches']>[number];
388
388
 
389
389
  export function insertDirectDispatch(
390
390
  meshId: string,
@@ -401,7 +401,7 @@ export function insertDirectDispatch(
401
401
  },
402
402
  ): void {
403
403
  try {
404
- BeadsDB.getInstance().insertDirectDispatch({ ...data, meshId });
404
+ MeshRuntimeStore.getInstance().insertDirectDispatch({ ...data, meshId });
405
405
  } catch (e: any) {
406
406
  process.stderr.write(`[adhdev-mesh] insertDirectDispatch failed for task ${data.taskId}: ${e?.message || e}\n`);
407
407
  }
@@ -409,7 +409,7 @@ export function insertDirectDispatch(
409
409
 
410
410
  export function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[] {
411
411
  try {
412
- return BeadsDB.getInstance().getActiveDirectDispatches(meshId);
412
+ return MeshRuntimeStore.getInstance().getActiveDirectDispatches(meshId);
413
413
  } catch {
414
414
  return [];
415
415
  }
@@ -421,18 +421,18 @@ export function updateDirectDispatchStatus(
421
421
  status: 'acked' | 'completed' | 'failed' | 'stale',
422
422
  ): void {
423
423
  try {
424
- BeadsDB.getInstance().updateDirectDispatchStatus(meshId, sessionId, status);
424
+ MeshRuntimeStore.getInstance().updateDirectDispatchStatus(meshId, sessionId, status);
425
425
  } catch { /* best-effort */ }
426
426
  }
427
427
 
428
428
  export function cleanupTerminalDirectDispatches(olderThanMs = 7 * 24 * 60 * 60_000): void {
429
429
  try {
430
- BeadsDB.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
430
+ MeshRuntimeStore.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
431
431
  } catch { /* best-effort */ }
432
432
  }
433
433
 
434
434
  export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_000): void {
435
435
  try {
436
- BeadsDB.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
436
+ MeshRuntimeStore.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
437
437
  } catch { /* best-effort */ }
438
438
  }
@@ -724,16 +724,24 @@ export class CliProviderInstance implements ProviderInstance {
724
724
  ? visibleStatus
725
725
  : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
726
726
 
727
+ // If an AskUserQuestion prompt is awaiting user input, overlay status as
728
+ // waiting_choice. This is distinct from waiting_approval (tool-use consent)
729
+ // — the engine's isWaitingForResponse state is unchanged, so completion
730
+ // tracking continues normally once the user responds.
731
+ const hasInteractivePrompt = !!this.activeInteractivePrompt;
732
+ const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
733
+ const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
734
+
727
735
  return {
728
736
  type: this.type,
729
737
  name: this.provider.name,
730
738
  category: 'cli',
731
- status: visibleStatus,
739
+ status: finalStatus,
732
740
  mode: this.presentationMode,
733
741
  activeChat: {
734
742
  id: activeChatId,
735
743
  title: parsedStatus?.title || dirName,
736
- status: activeChatStatus,
744
+ status: finalChatStatus,
737
745
  messages: statusMessages,
738
746
  activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
739
747
  activeInteractivePrompt: this.activeInteractivePrompt,
@@ -1265,6 +1273,7 @@ export class CliProviderInstance implements ProviderInstance {
1265
1273
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1266
1274
  const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1267
1275
  const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1276
+ LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1268
1277
  if (!finalAssistantEvidence.present) {
1269
1278
  if (adapterOwnsMessagesElsewhere) {
1270
1279
  if (finalAssistantEvidence.source === 'external-native') {
@@ -1273,19 +1282,20 @@ export class CliProviderInstance implements ProviderInstance {
1273
1282
  LOG.info('CLI', `[${this.type}] external transcript probe: msgCount=${probe.msgCount} lastRole=${probe.lastRole || 'none'} lastKind=${probe.lastKind || 'none'} contentLen=${probe.contentLen} sourceMtime=${probe.sourceMtimeMs ?? 'unknown'} mtimeAge=${probe.mtimeAgeMs ?? 'unknown'}ms`);
1274
1283
  pending.loggedTranscriptProbe = true;
1275
1284
  }
1285
+ LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
1286
+ if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1287
+ return null;
1288
+ }
1276
1289
  if (this.type === 'antigravity-cli') {
1277
1290
  return null;
1278
1291
  }
1279
1292
  return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1280
1293
  }
1281
- // SpecCliAdapter never populates parsed.messages — chat history flows
1282
- // through the daemon's native-history pipeline, not the status hook.
1283
- // If that pipeline is unavailable, keep the old skip behavior for
1284
- // providers that have not opted into strict final-assistant evidence.
1285
1294
  if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
1286
1295
  return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1287
1296
  }
1288
1297
  } else {
1298
+ LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
1289
1299
  return {
1290
1300
  reason: 'missing_final_assistant',
1291
1301
  terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
@@ -1331,6 +1341,7 @@ export class CliProviderInstance implements ProviderInstance {
1331
1341
  const latestVisibleStatus = externalNativeFinal && isCliGeneratingLikeStatus(latestStatus.status)
1332
1342
  ? 'idle'
1333
1343
  : (latestAutoApproveActive ? 'generating' : latestStatus.status);
1344
+ LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} externalNativeFinal=${!!externalNativeFinal} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
1334
1345
  if (latestVisibleStatus !== 'idle') {
1335
1346
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
1336
1347
  this.completedDebouncePending = null;
@@ -1342,6 +1353,7 @@ export class CliProviderInstance implements ProviderInstance {
1342
1353
  if (block) {
1343
1354
  const blockReason = block.reason;
1344
1355
  const waitedMs = Date.now() - pending.firstObservedAt;
1356
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1345
1357
  if ((block.terminal && !block.allowTimeout) || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1346
1358
  if (pending.loggedBlockReason !== blockReason) {
1347
1359
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
@@ -1485,10 +1497,18 @@ export class CliProviderInstance implements ProviderInstance {
1485
1497
  if (newStatus !== this.lastStatus) {
1486
1498
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
1487
1499
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
1500
+ // If a completion event is already pending and the turn has ended
1501
+ // (generatingStartedAt===0), the PTY is painting its prompt area
1502
+ // after completing. Ignore this blip — do not cancel the pending
1503
+ // completion and do not advance lastStatus to generating.
1504
+ if (this.completedDebouncePending && this.generatingStartedAt === 0) {
1505
+ LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
1506
+ return;
1507
+ }
1488
1508
  this.suppressIdleHistoryReplay = false;
1489
1509
  // Cancel any pending completed event (multi-step: idle→generating resume)
1490
1510
  if (this.completedDebouncePending) {
1491
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
1511
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
1492
1512
  if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1493
1513
  this.completedDebouncePending = null;
1494
1514
  }
@@ -1586,7 +1606,10 @@ export class CliProviderInstance implements ProviderInstance {
1586
1606
  firstObservedAt: now,
1587
1607
  previousStatus: this.lastStatus,
1588
1608
  };
1589
- this.scheduleCompletedDebounceFlush(3000);
1609
+ const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
1610
+ const flushDelay = ownsExternalHistory ? 0 : 3000;
1611
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
1612
+ this.scheduleCompletedDebounceFlush(flushDelay);
1590
1613
  }
1591
1614
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
1592
1615
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
@@ -16,7 +16,7 @@ import type { InteractivePrompt } from './types/interactive-prompt.js';
16
16
 
17
17
  // ─── ProviderState — Discriminated union by category ─────────────
18
18
 
19
- export type ProviderStatus = 'idle' | 'generating' | 'waiting_approval' | 'error' | 'stopped' | 'starting';
19
+ export type ProviderStatus = 'idle' | 'generating' | 'waiting_approval' | 'waiting_choice' | 'error' | 'stopped' | 'starting';
20
20
 
21
21
  export interface ProviderRuntimeWriteOwner {
22
22
  clientId: string;