@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.
- package/dist/cli-adapters/provider-cli-adapter.d.ts +2 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +14 -4
- package/dist/commands/mesh-coordinator.d.ts +8 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1018 -216
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1014 -224
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events.d.ts +11 -1
- package/dist/mesh/mesh-ledger.d.ts +90 -0
- package/dist/mesh/mesh-sync.d.ts +10 -0
- package/dist/mesh/mesh-work-queue.d.ts +50 -0
- package/dist/repo-mesh-types.d.ts +6 -0
- package/dist/shared-types.d.ts +12 -0
- package/package.json +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +10 -4
- package/src/cli-adapters/provider-cli-shared.ts +14 -4
- package/src/commands/mesh-coordinator.ts +28 -6
- package/src/commands/router.ts +222 -0
- package/src/commands/stream-commands.ts +8 -1
- package/src/index.ts +11 -0
- package/src/mesh/coordinator-prompt.ts +27 -12
- package/src/mesh/mesh-events.ts +200 -1
- package/src/mesh/mesh-ledger.ts +378 -0
- package/src/mesh/mesh-sync.ts +32 -0
- package/src/mesh/mesh-work-queue.ts +164 -0
- package/src/repo-mesh-types.ts +7 -0
- package/src/shared-types.ts +12 -0
- package/src/status/builders.ts +13 -0
|
@@ -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
|
+
}
|
package/src/mesh/mesh-sync.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -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 ───────────────────────────────
|
package/src/shared-types.ts
CHANGED
|
@@ -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 =
|