@adhdev/daemon-core 0.9.77-rc.1 → 0.9.77-rc.11

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.
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Mesh Task Ledger — GasTown-inspired append-only JSONL task history
3
+ *
4
+ * Records all mesh orchestration events (task dispatch, completion, failure,
5
+ * checkpoint, node lifecycle) as an append-only JSONL file per mesh.
6
+ *
7
+ * Inspired by GasTown's "Beads" pattern: every action is a versioned record
8
+ * that persists across agent sessions, enabling recovery, auditing, and
9
+ * continuity when individual sessions fail or context windows are exhausted.
10
+ *
11
+ * Storage: ~/.adhdev/mesh-ledger/<meshId>.jsonl
12
+ * Format: One JSON object per line, newest entries appended at end
13
+ * Safety: mode 0o600, atomic append via appendFileSync
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, statSync, renameSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { randomUUID } from 'crypto';
19
+ import { getConfigDir } from '../config/config.js';
20
+ import { EventEmitter } from 'events';
21
+ // ─── Types ──────────────────────────────────────
22
+
23
+ export type MeshLedgerKind =
24
+ | 'task_dispatched'
25
+ | 'task_completed'
26
+ | 'task_failed'
27
+ | 'task_stalled'
28
+ | 'task_approval_needed'
29
+ | 'session_launched'
30
+ | 'session_stopped'
31
+ | 'checkpoint_created'
32
+ | 'node_cloned'
33
+ | 'node_removed'
34
+ | 'coordinator_started'
35
+ | 'recovery_attempted'
36
+ ;
37
+
38
+ export interface MeshLedgerEntry {
39
+ id: string;
40
+ meshId: string;
41
+ timestamp: string;
42
+ kind: MeshLedgerKind;
43
+ nodeId?: string;
44
+ sessionId?: string;
45
+ providerType?: string;
46
+ payload: Record<string, unknown>;
47
+ }
48
+
49
+ export interface MeshLedgerSummary {
50
+ meshId: string;
51
+ totalEntries: number;
52
+ taskDispatched: number;
53
+ taskCompleted: number;
54
+ taskFailed: number;
55
+ taskStalled: number;
56
+ sessionLaunched: number;
57
+ checkpointCreated: number;
58
+ lastActivityAt: string | null;
59
+ recentFailures: number; // failures in last 30 minutes
60
+ }
61
+
62
+ export interface ReadLedgerOptions {
63
+ tail?: number;
64
+ since?: string;
65
+ kind?: MeshLedgerKind[];
66
+ }
67
+
68
+ // ─── Constants ──────────────────────────────────
69
+
70
+ const LEDGER_DIR_NAME = 'mesh-ledger';
71
+ const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
72
+ const RECENT_FAILURE_WINDOW_MS = 30 * 60 * 1000; // 30 minutes
73
+
74
+ // ─── Path Helpers ───────────────────────────────
75
+
76
+ export function getLedgerDir(): string {
77
+ const dir = join(getConfigDir(), LEDGER_DIR_NAME);
78
+ if (!existsSync(dir)) {
79
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
80
+ }
81
+ return dir;
82
+ }
83
+
84
+ function getLedgerPath(meshId: string): string {
85
+ // Sanitize meshId to prevent path traversal
86
+ const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
87
+ return join(getLedgerDir(), `${safe}.jsonl`);
88
+ }
89
+
90
+ function getRotatedPath(meshId: string, index: number): string {
91
+ const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
92
+ return join(getLedgerDir(), `${safe}.${index}.jsonl`);
93
+ }
94
+
95
+ // ─── Core API ───────────────────────────────────
96
+
97
+ /**
98
+ * Append a new entry to the mesh ledger.
99
+ * Handles file creation, rotation on size overflow, and atomic writes.
100
+ */
101
+ export const meshLedgerEvents = new EventEmitter();
102
+
103
+ export function appendLedgerEntry(
104
+ meshId: string,
105
+ partial: Omit<MeshLedgerEntry, 'id' | 'meshId' | 'timestamp'>,
106
+ ): MeshLedgerEntry {
107
+ const entry: MeshLedgerEntry = {
108
+ id: randomUUID(),
109
+ meshId,
110
+ timestamp: new Date().toISOString(),
111
+ ...partial,
112
+ };
113
+
114
+ const filePath = getLedgerPath(meshId);
115
+
116
+ // Rotate if file exceeds max size
117
+ if (existsSync(filePath)) {
118
+ try {
119
+ const stat = statSync(filePath);
120
+ if (stat.size >= MAX_FILE_SIZE_BYTES) {
121
+ rotateLedgerFile(meshId, filePath);
122
+ }
123
+ } catch {
124
+ // stat failed — proceed with append anyway
125
+ }
126
+ }
127
+
128
+ try {
129
+ const line = JSON.stringify(entry) + '\n';
130
+ appendFileSync(filePath, line, { encoding: 'utf-8', mode: 0o600 });
131
+ meshLedgerEvents.emit('append', meshId, entry);
132
+ return entry;
133
+ } catch (e: any) {
134
+ throw new Error(`Failed to append to ledger for mesh ${meshId}: ${e.message}`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Append entries received from the cloud to the local ledger.
140
+ * This skips deduplicated entries and just writes new ones.
141
+ */
142
+ export function appendRemoteLedgerEntries(meshId: string, entries: MeshLedgerEntry[]): void {
143
+ if (entries.length === 0) return;
144
+ const ledgerPath = getLedgerPath(meshId);
145
+
146
+ // Read existing to deduplicate by ID
147
+ const existing = new Set(readLedgerEntries(meshId).map(e => e.id));
148
+ const newEntries = entries.filter(e => !existing.has(e.id));
149
+
150
+ if (newEntries.length === 0) return;
151
+
152
+ try {
153
+ const lines = newEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
154
+ appendFileSync(ledgerPath, lines, { encoding: 'utf-8', mode: 0o600 });
155
+ } catch (e: any) {
156
+ throw new Error(`Failed to append remote ledger entries for mesh ${meshId}: ${e.message}`);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Read ledger entries with optional filtering.
162
+ */
163
+ export function readLedgerEntries(meshId: string, opts?: ReadLedgerOptions): MeshLedgerEntry[] {
164
+ const filePath = getLedgerPath(meshId);
165
+ if (!existsSync(filePath)) return [];
166
+
167
+ let content: string;
168
+ try {
169
+ content = readFileSync(filePath, 'utf-8');
170
+ } catch {
171
+ return [];
172
+ }
173
+
174
+ const lines = content.split('\n').filter(line => line.trim());
175
+ let entries: MeshLedgerEntry[] = [];
176
+
177
+ for (const line of lines) {
178
+ try {
179
+ const entry = JSON.parse(line) as MeshLedgerEntry;
180
+ if (!entry.id || !entry.kind) continue;
181
+ entries.push(entry);
182
+ } catch {
183
+ // Skip malformed lines
184
+ }
185
+ }
186
+
187
+ // Apply filters
188
+ if (opts?.since) {
189
+ const sinceDate = new Date(opts.since).getTime();
190
+ if (!isNaN(sinceDate)) {
191
+ entries = entries.filter(e => new Date(e.timestamp).getTime() >= sinceDate);
192
+ }
193
+ }
194
+
195
+ if (opts?.kind?.length) {
196
+ const kindSet = new Set(opts.kind);
197
+ entries = entries.filter(e => kindSet.has(e.kind));
198
+ }
199
+
200
+ // Apply tail (return last N entries)
201
+ if (opts?.tail && opts.tail > 0 && entries.length > opts.tail) {
202
+ entries = entries.slice(-opts.tail);
203
+ }
204
+
205
+ return entries;
206
+ }
207
+
208
+ /**
209
+ * Get a summary of mesh activity from the ledger.
210
+ */
211
+ export function getLedgerSummary(meshId: string): MeshLedgerSummary {
212
+ const entries = readLedgerEntries(meshId);
213
+ const now = Date.now();
214
+ const recentFailureCutoff = now - RECENT_FAILURE_WINDOW_MS;
215
+
216
+ const summary: MeshLedgerSummary = {
217
+ meshId,
218
+ totalEntries: entries.length,
219
+ taskDispatched: 0,
220
+ taskCompleted: 0,
221
+ taskFailed: 0,
222
+ taskStalled: 0,
223
+ sessionLaunched: 0,
224
+ checkpointCreated: 0,
225
+ lastActivityAt: null,
226
+ recentFailures: 0,
227
+ };
228
+
229
+ for (const entry of entries) {
230
+ switch (entry.kind) {
231
+ case 'task_dispatched': summary.taskDispatched++; break;
232
+ case 'task_completed': summary.taskCompleted++; break;
233
+ case 'task_failed': {
234
+ summary.taskFailed++;
235
+ if (new Date(entry.timestamp).getTime() >= recentFailureCutoff) {
236
+ summary.recentFailures++;
237
+ }
238
+ break;
239
+ }
240
+ case 'task_stalled': summary.taskStalled++; break;
241
+ case 'session_launched': summary.sessionLaunched++; break;
242
+ case 'checkpoint_created': summary.checkpointCreated++; break;
243
+ }
244
+ }
245
+
246
+ if (entries.length > 0) {
247
+ summary.lastActivityAt = entries[entries.length - 1].timestamp;
248
+ }
249
+
250
+ return summary;
251
+ }
252
+
253
+ // ─── Recovery Context ───────────────────────────
254
+
255
+ export interface SessionRecoveryContext {
256
+ /** The original task message that was dispatched to this session/node */
257
+ lastTaskMessage: string | null;
258
+ /** The node that was running the failed task */
259
+ failedNodeId: string | null;
260
+ /** Session ID of the failed session */
261
+ failedSessionId: string | null;
262
+ /** Provider used for the failed session */
263
+ failedProviderType: string | null;
264
+ /** Number of consecutive failures for this node (within recent window) */
265
+ consecutiveNodeFailures: number;
266
+ /** Number of times this specific task was attempted (matched by truncated message prefix) */
267
+ taskAttemptCount: number;
268
+ /** Whether a retry is recommended based on maxRetries policy */
269
+ retryRecommended: boolean;
270
+ /** Human-readable recovery advice for the coordinator */
271
+ advice: string;
272
+ }
273
+
274
+ /**
275
+ * Build recovery context for a failed session.
276
+ * Looks up the ledger to find the original task, count failures, and advise on retry.
277
+ */
278
+ export function getSessionRecoveryContext(
279
+ meshId: string,
280
+ opts: {
281
+ sessionId?: string;
282
+ nodeId?: string;
283
+ maxRetries?: number;
284
+ },
285
+ ): SessionRecoveryContext {
286
+ const maxRetries = opts.maxRetries ?? 1;
287
+ const entries = readLedgerEntries(meshId);
288
+
289
+ // Find the last task_dispatched for this session or node
290
+ let lastDispatch: MeshLedgerEntry | null = null;
291
+ for (let i = entries.length - 1; i >= 0; i--) {
292
+ const e = entries[i];
293
+ if (e.kind !== 'task_dispatched') continue;
294
+ if (opts.sessionId && e.sessionId === opts.sessionId) { lastDispatch = e; break; }
295
+ if (opts.nodeId && e.nodeId === opts.nodeId) { lastDispatch = e; break; }
296
+ }
297
+
298
+ const lastTaskMessage = typeof lastDispatch?.payload?.message === 'string'
299
+ ? lastDispatch.payload.message
300
+ : null;
301
+
302
+ // Count consecutive recent failures for this node (within 30 min window)
303
+ const now = Date.now();
304
+ const recentWindow = now - RECENT_FAILURE_WINDOW_MS;
305
+ let consecutiveNodeFailures = 0;
306
+ for (let i = entries.length - 1; i >= 0; i--) {
307
+ const e = entries[i];
308
+ if (new Date(e.timestamp).getTime() < recentWindow) break;
309
+ if (opts.nodeId && e.nodeId !== opts.nodeId) continue;
310
+ if (e.kind === 'task_failed') {
311
+ consecutiveNodeFailures++;
312
+ } else if (e.kind === 'task_completed' || e.kind === 'task_dispatched') {
313
+ // A completion or new dispatch breaks the consecutive failure chain
314
+ break;
315
+ }
316
+ }
317
+
318
+ // Count how many times the same task was attempted (match by message prefix)
319
+ let taskAttemptCount = 0;
320
+ if (lastTaskMessage) {
321
+ const prefix = lastTaskMessage.slice(0, 200);
322
+ for (const e of entries) {
323
+ if (e.kind === 'task_dispatched' && typeof e.payload?.message === 'string') {
324
+ if (e.payload.message.startsWith(prefix)) {
325
+ taskAttemptCount++;
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ const retryRecommended = consecutiveNodeFailures <= maxRetries;
332
+
333
+ // Build advice string
334
+ let advice: string;
335
+ if (consecutiveNodeFailures === 0) {
336
+ advice = 'No recent failures detected. This may be a normal stop.';
337
+ } else if (retryRecommended) {
338
+ const remaining = maxRetries - consecutiveNodeFailures + 1;
339
+ advice = `Retry recommended (${consecutiveNodeFailures}/${maxRetries + 1} attempts used, ${remaining} remaining). `
340
+ + (lastTaskMessage
341
+ ? `Re-launch the session and resend the original task.`
342
+ : `Re-launch the session. Original task message not found in ledger.`);
343
+ } else {
344
+ advice = `Max retries exceeded (${consecutiveNodeFailures} consecutive failures). `
345
+ + `Consider: (1) reassigning to a different node, (2) simplifying the task, or (3) escalating to the user.`;
346
+ }
347
+
348
+ return {
349
+ lastTaskMessage,
350
+ failedNodeId: opts.nodeId || null,
351
+ failedSessionId: opts.sessionId || null,
352
+ failedProviderType: null, // filled by caller if available
353
+ consecutiveNodeFailures,
354
+ taskAttemptCount,
355
+ retryRecommended,
356
+ advice,
357
+ };
358
+ }
359
+
360
+ // ─── File Rotation ──────────────────────────────
361
+
362
+ function rotateLedgerFile(meshId: string, currentPath: string): void {
363
+ // Find next rotation index
364
+ let index = 1;
365
+ while (existsSync(getRotatedPath(meshId, index))) {
366
+ index++;
367
+ if (index > 10) break; // Max 10 rotations
368
+ }
369
+
370
+ // If all slots full, overwrite the oldest
371
+ if (index > 10) index = 10;
372
+
373
+ try {
374
+ renameSync(currentPath, getRotatedPath(meshId, index));
375
+ } catch {
376
+ // Rotation failed — the next append will just grow the file
377
+ }
378
+ }
@@ -26,6 +26,8 @@ export interface MeshSyncTransport {
26
26
  }): Promise<{ mesh: RemoteMeshRecord }>;
27
27
  /** DELETE /api/v1/repo-meshes/:id */
28
28
  deleteRemoteMesh(meshId: string): Promise<void>;
29
+ /** POST /api/v1/repo-meshes/:id/ledger/sync */
30
+ syncMeshLedger?(meshId: string, data: { newEntries: any[] }): Promise<{ missingEntries: any[] }>;
29
31
  }
30
32
 
31
33
  export interface RemoteMeshRecord {
@@ -105,5 +107,35 @@ export async function syncMeshes(transport: MeshSyncTransport): Promise<MeshSync
105
107
  }
106
108
  }
107
109
 
110
+ // Sync ledgers for all local meshes if the transport supports it
111
+ if (transport.syncMeshLedger) {
112
+ for (const local of localMeshes) {
113
+ try {
114
+ await syncMeshLedger(local.id, transport);
115
+ } catch (e: any) {
116
+ result.errors.push(`Ledger sync failed for "${local.name}": ${e.message}`);
117
+ }
118
+ }
119
+ }
120
+
108
121
  return result;
109
122
  }
123
+
124
+ /**
125
+ * Sync the task ledger for a specific mesh.
126
+ */
127
+ export async function syncMeshLedger(meshId: string, transport: MeshSyncTransport): Promise<void> {
128
+ if (!transport.syncMeshLedger) return;
129
+ const { readLedgerEntries, appendRemoteLedgerEntries } = await import('./mesh-ledger.js');
130
+
131
+ // Read all local entries (no tail)
132
+ const localEntries = readLedgerEntries(meshId);
133
+
134
+ // Send to cloud and get missing entries back
135
+ const res = await transport.syncMeshLedger(meshId, { newEntries: localEntries });
136
+
137
+ // Append any missing entries from the cloud
138
+ if (res.missingEntries && res.missingEntries.length > 0) {
139
+ appendRemoteLedgerEntries(meshId, res.missingEntries);
140
+ }
141
+ }
@@ -0,0 +1,164 @@
1
+ import { existsSync, writeFileSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { randomUUID } from 'crypto';
4
+ import { getLedgerDir } from './mesh-ledger.js';
5
+
6
+ export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed';
7
+
8
+ export interface MeshWorkQueueEntry {
9
+ id: string;
10
+ meshId: string;
11
+ message: string;
12
+ status: MeshTaskStatus;
13
+ /** If specified, only this node can claim the task (used by legacy mesh_send_task) */
14
+ targetNodeId?: string;
15
+ /** The node that actually claimed and is executing the task */
16
+ assignedNodeId?: string;
17
+ /** The session currently executing the task */
18
+ assignedSessionId?: string;
19
+ createdAt: string;
20
+ updatedAt: string;
21
+ }
22
+
23
+ function getQueuePath(meshId: string): string {
24
+ const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
25
+ return join(getLedgerDir(), `${safe}.queue.json`);
26
+ }
27
+
28
+ function readQueue(meshId: string): MeshWorkQueueEntry[] {
29
+ const path = getQueuePath(meshId);
30
+ if (!existsSync(path)) return [];
31
+ try {
32
+ const content = readFileSync(path, 'utf-8');
33
+ return JSON.parse(content) as MeshWorkQueueEntry[];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
40
+ const path = getQueuePath(meshId);
41
+ writeFileSync(path, JSON.stringify(queue, null, 2), 'utf-8');
42
+ }
43
+
44
+ /**
45
+ * Add a new task to the mesh queue.
46
+ */
47
+ export function enqueueTask(
48
+ meshId: string,
49
+ message: string,
50
+ opts?: { targetNodeId?: string }
51
+ ): MeshWorkQueueEntry {
52
+ const queue = readQueue(meshId);
53
+ const entry: MeshWorkQueueEntry = {
54
+ id: randomUUID(),
55
+ meshId,
56
+ message,
57
+ status: 'pending',
58
+ targetNodeId: opts?.targetNodeId,
59
+ createdAt: new Date().toISOString(),
60
+ updatedAt: new Date().toISOString(),
61
+ };
62
+ queue.push(entry);
63
+ writeQueue(meshId, queue);
64
+ return entry;
65
+ }
66
+
67
+ /**
68
+ * Get all tasks in the queue, optionally filtered by status.
69
+ */
70
+ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }): MeshWorkQueueEntry[] {
71
+ let queue = readQueue(meshId);
72
+ if (opts?.status?.length) {
73
+ const statuses = new Set(opts.status);
74
+ queue = queue.filter(q => statuses.has(q.status));
75
+ }
76
+ return queue;
77
+ }
78
+
79
+ /**
80
+ * Find the next pending task that this node is allowed to claim, and mark it as assigned.
81
+ */
82
+ export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
83
+ const queue = readQueue(meshId);
84
+
85
+ // Find highest priority task:
86
+ // 1. Pending tasks explicitly targeted at this node
87
+ // 2. Pending tasks with no target node
88
+ let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId);
89
+ if (targetIdx === -1) {
90
+ targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId);
91
+ }
92
+
93
+ if (targetIdx === -1) return null;
94
+
95
+ const entry = queue[targetIdx];
96
+ entry.status = 'assigned';
97
+ entry.assignedNodeId = nodeId;
98
+ entry.assignedSessionId = sessionId;
99
+ entry.updatedAt = new Date().toISOString();
100
+
101
+ writeQueue(meshId, queue);
102
+ return entry;
103
+ }
104
+
105
+ /**
106
+ * Update the status of a specific task.
107
+ * Used when a session completes, fails, or stalls.
108
+ */
109
+ export function updateTaskStatus(
110
+ meshId: string,
111
+ taskId: string,
112
+ status: MeshTaskStatus,
113
+ ): MeshWorkQueueEntry | null {
114
+ const queue = readQueue(meshId);
115
+ const idx = queue.findIndex(q => q.id === taskId);
116
+ if (idx === -1) return null;
117
+
118
+ queue[idx].status = status;
119
+ queue[idx].updatedAt = new Date().toISOString();
120
+ writeQueue(meshId, queue);
121
+ return queue[idx];
122
+ }
123
+
124
+ /**
125
+ * Update the status of the task currently assigned to a specific session.
126
+ */
127
+ export function updateSessionTaskStatus(
128
+ meshId: string,
129
+ sessionId: string,
130
+ status: MeshTaskStatus,
131
+ ): MeshWorkQueueEntry | null {
132
+ const queue = readQueue(meshId);
133
+ // Find the most recently assigned task for this session that isn't already terminal
134
+ // (In case multiple tasks were assigned to the same session over time, though rare)
135
+ for (let i = queue.length - 1; i >= 0; i--) {
136
+ if (queue[i].assignedSessionId === sessionId && queue[i].status === 'assigned') {
137
+ queue[i].status = status;
138
+ queue[i].updatedAt = new Date().toISOString();
139
+ writeQueue(meshId, queue);
140
+ return queue[i];
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+
146
+ export interface MeshWorkQueueStats {
147
+ pending: number;
148
+ assigned: number;
149
+ completed: number;
150
+ failed: number;
151
+ }
152
+
153
+ /**
154
+ * Return aggregate queue statistics for the given mesh.
155
+ */
156
+ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
157
+ const queue = readQueue(meshId);
158
+ return {
159
+ pending: queue.filter(q => q.status === 'pending').length,
160
+ assigned: queue.filter(q => q.status === 'assigned').length,
161
+ completed: queue.filter(q => q.status === 'completed').length,
162
+ failed: queue.filter(q => q.status === 'failed').length,
163
+ };
164
+ }
@@ -78,6 +78,12 @@ export interface RepoMeshPolicy {
78
78
  * runtimes are never stopped/deleted unless the mesh owner opts in.
79
79
  */
80
80
  sessionCleanupOnNodeRemove?: RepoMeshSessionCleanupMode;
81
+ /**
82
+ * Maximum number of automatic retry recommendations for a failed task on the
83
+ * same node before the daemon advises the coordinator to escalate or reassign.
84
+ * Defaults to 1 (allow one retry). Set to 0 to disable auto-recovery advice.
85
+ */
86
+ maxTaskRetries?: number;
81
87
  }
82
88
 
83
89
  export interface RepoMeshRelatedRepo {
@@ -110,6 +116,7 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
110
116
  maxParallelTasks: 2,
111
117
  spawnedSessionVisibility: 'visible',
112
118
  sessionCleanupOnNodeRemove: 'preserve',
119
+ maxTaskRetries: 1,
113
120
  };
114
121
 
115
122
  // ─── Capabilities ───────────────────────────────
@@ -389,6 +389,12 @@ export interface SessionEntry {
389
389
  seenCompletionMarker?: string;
390
390
  surfaceHidden?: boolean;
391
391
  settings?: Record<string, any>;
392
+ meshQueueStats?: {
393
+ pending: number;
394
+ assigned: number;
395
+ completed: number;
396
+ failed: number;
397
+ };
392
398
  }
393
399
 
394
400
  /**
@@ -428,6 +434,12 @@ export interface CompactSessionEntry {
428
434
  providerControls?: ProviderControlSchema[];
429
435
  summaryMetadata?: ProviderSummaryMetadata;
430
436
  settings?: Record<string, any>;
437
+ meshQueueStats?: {
438
+ pending: number;
439
+ assigned: number;
440
+ completed: number;
441
+ failed: number;
442
+ };
431
443
  }
432
444
 
433
445
  export type VersionUpdateReason =