@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
@@ -13,7 +13,7 @@
13
13
  * Safety: mode 0o600, atomic append via appendFileSync
14
14
  */
15
15
 
16
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, renameSync } from 'fs';
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, renameSync, writeFileSync } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { randomUUID } from 'crypto';
19
19
  import { getConfigDir } from '../config/config.js';
@@ -26,6 +26,7 @@ export type MeshLedgerKind =
26
26
  | 'task_failed'
27
27
  | 'task_stalled'
28
28
  | 'task_approval_needed'
29
+ | 'p2p_dispatch_failed'
29
30
  | 'session_launched'
30
31
  | 'session_auto_launch'
31
32
  | 'session_stopped'
@@ -202,8 +203,19 @@ export interface AppendRemoteLedgerResult {
202
203
  // ─── Constants ──────────────────────────────────
203
204
 
204
205
  const LEDGER_DIR_NAME = 'mesh-ledger';
205
- const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
206
+ const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB — full rotation threshold
207
+ const COMPACT_THRESHOLD_BYTES = 2 * 1024 * 1024; // 2 MB — compaction threshold
208
+ const ARCHIVE_TERMINAL_OLDER_THAN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
206
209
  const RECENT_FAILURE_WINDOW_MS = 30 * 60 * 1000; // 30 minutes
210
+
211
+ // Kinds that accumulate indefinitely and are safe to archive after ARCHIVE_TERMINAL_OLDER_THAN_MS.
212
+ // Non-terminal kinds (dispatched, sessions, nodes, checkpoints) are always kept in the active file.
213
+ const ARCHIVABLE_KINDS: ReadonlySet<MeshLedgerKind> = new Set([
214
+ 'task_completed',
215
+ 'task_failed',
216
+ 'task_stalled',
217
+ 'recovery_attempted',
218
+ ] as MeshLedgerKind[]);
207
219
  const DEFAULT_LEDGER_SLICE_LIMIT = 100;
208
220
  export const MAX_LEDGER_SLICE_LIMIT = 500;
209
221
 
@@ -228,6 +240,146 @@ function getRotatedPath(meshId: string, index: number): string {
228
240
  return join(getLedgerDir(), `${safe}.${index}.jsonl`);
229
241
  }
230
242
 
243
+ function getArchivePath(meshId: string): string {
244
+ const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
245
+ return join(getLedgerDir(), `${safe}.archive.jsonl`);
246
+ }
247
+
248
+ function getRotatedArchivePath(meshId: string, index: number): string {
249
+ const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
250
+ return join(getLedgerDir(), `${safe}.archive.${index}.jsonl`);
251
+ }
252
+
253
+ function getArchivedCountsPath(meshId: string): string {
254
+ const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
255
+ return join(getLedgerDir(), `${safe}.archived-counts.json`);
256
+ }
257
+
258
+ function rotateArchiveFile(meshId: string, archivePath: string): void {
259
+ let index = 1;
260
+ while (existsSync(getRotatedArchivePath(meshId, index))) {
261
+ index++;
262
+ if (index > 5) break;
263
+ }
264
+ if (index > 5) index = 5;
265
+ try {
266
+ renameSync(archivePath, getRotatedArchivePath(meshId, index));
267
+ } catch (e: any) {
268
+ process.stderr.write(`[adhdev-mesh] Archive rotation failed for mesh ${meshId}: ${e?.message || e}\n`);
269
+ }
270
+ }
271
+
272
+ interface LedgerArchivedCounts {
273
+ taskCompleted: number;
274
+ taskFailed: number;
275
+ taskStalled: number;
276
+ recoveryAttempted: number;
277
+ totalArchived: number;
278
+ lastArchivedAt: string;
279
+ }
280
+
281
+ function readArchivedCounts(meshId: string): LedgerArchivedCounts {
282
+ const path = getArchivedCountsPath(meshId);
283
+ if (!existsSync(path)) return { taskCompleted: 0, taskFailed: 0, taskStalled: 0, recoveryAttempted: 0, totalArchived: 0, lastArchivedAt: '' };
284
+ try { return JSON.parse(readFileSync(path, 'utf-8')) as LedgerArchivedCounts; } catch { return { taskCompleted: 0, taskFailed: 0, taskStalled: 0, recoveryAttempted: 0, totalArchived: 0, lastArchivedAt: '' }; }
285
+ }
286
+
287
+ function updateArchivedCounts(meshId: string, archived: MeshLedgerEntry[]): void {
288
+ const counts = readArchivedCounts(meshId);
289
+ for (const e of archived) {
290
+ if (e.kind === 'task_completed') counts.taskCompleted++;
291
+ else if (e.kind === 'task_failed') counts.taskFailed++;
292
+ else if (e.kind === 'task_stalled') counts.taskStalled++;
293
+ else if (e.kind === 'recovery_attempted') counts.recoveryAttempted++;
294
+ }
295
+ counts.totalArchived += archived.length;
296
+ counts.lastArchivedAt = new Date().toISOString();
297
+ try { writeFileSync(getArchivedCountsPath(meshId), JSON.stringify(counts), { encoding: 'utf-8', mode: 0o600 }); } catch { /* best-effort */ }
298
+ }
299
+
300
+ // ─── Worker Result Footer ───────────────────────
301
+
302
+ /**
303
+ * Footer to append to worker task messages so workers output structured results
304
+ * that the daemon parses via extractJsonObjectFromSummary / normalizeMeshWorkerResult.
305
+ *
306
+ * Usage: append buildWorkerTaskFooter() to the task message in mesh_send_task /
307
+ * mesh_enqueue_task. The coordinator prompt rules instruct coordinators to do this.
308
+ */
309
+ export function buildWorkerTaskFooter(): string {
310
+ return `
311
+
312
+ ---
313
+ When your task is done, end your final response with a JSON code block in this exact format (omit fields that don't apply):
314
+ \`\`\`json
315
+ {
316
+ "status": "completed",
317
+ "changedFiles": ["src/foo.ts", "tests/foo.test.ts"],
318
+ "gitStatus": { "branch": "feat/your-branch", "committed": true, "pushed": false },
319
+ "validationResults": [{ "command": "npm test", "status": "passed" }],
320
+ "errors": [],
321
+ "nextAction": "optional guidance for the coordinator"
322
+ }
323
+ \`\`\`
324
+ Valid status values: \`completed\` | \`failed\` | \`blocked\` | \`partial\`.`;
325
+ }
326
+
327
+ // ─── Ledger Compaction ──────────────────────────
328
+
329
+ /**
330
+ * Compact the active ledger file for a mesh by moving old terminal entries
331
+ * (task_completed, task_failed, task_stalled, recovery_attempted older than 7 days)
332
+ * to <meshId>.archive.jsonl, keeping the active file lean.
333
+ *
334
+ * Non-terminal entries (dispatch, sessions, node lifecycle) are always retained.
335
+ * Called automatically from appendLedgerEntry when the file exceeds COMPACT_THRESHOLD_BYTES.
336
+ */
337
+ export function compactLedger(meshId: string): { archivedCount: number; retainedCount: number } {
338
+ const filePath = getLedgerPath(meshId);
339
+ if (!existsSync(filePath)) return { archivedCount: 0, retainedCount: 0 };
340
+
341
+ const cutoff = Date.now() - ARCHIVE_TERMINAL_OLDER_THAN_MS;
342
+ const entries = readLedgerEntries(meshId);
343
+
344
+ const keep: MeshLedgerEntry[] = [];
345
+ const archive: MeshLedgerEntry[] = [];
346
+ for (const entry of entries) {
347
+ if (ARCHIVABLE_KINDS.has(entry.kind) && new Date(entry.timestamp).getTime() < cutoff) {
348
+ archive.push(entry);
349
+ } else {
350
+ keep.push(entry);
351
+ }
352
+ }
353
+
354
+ if (archive.length === 0) return { archivedCount: 0, retainedCount: keep.length };
355
+
356
+ // Append archived entries to the archive file, rotate if it exceeds 50MB
357
+ const archivePath = getArchivePath(meshId);
358
+ try {
359
+ if (existsSync(archivePath) && statSync(archivePath).size > 50 * 1024 * 1024) {
360
+ rotateArchiveFile(meshId, archivePath);
361
+ }
362
+ const archiveLines = archive.map(e => JSON.stringify(e)).join('\n') + '\n';
363
+ appendFileSync(archivePath, archiveLines, { encoding: 'utf-8', mode: 0o600 });
364
+ updateArchivedCounts(meshId, archive);
365
+ } catch (e: any) {
366
+ process.stderr.write(`[adhdev-mesh] Ledger archive write failed for mesh ${meshId}: ${e?.message || e}\n`);
367
+ return { archivedCount: 0, retainedCount: entries.length };
368
+ }
369
+
370
+ // Rewrite active file with retained entries only
371
+ try {
372
+ const keepLines = keep.length ? keep.map(e => JSON.stringify(e)).join('\n') + '\n' : '';
373
+ writeFileSync(filePath, keepLines, { encoding: 'utf-8', mode: 0o600 });
374
+ invalidateLedgerCache(meshId);
375
+ } catch (e: any) {
376
+ process.stderr.write(`[adhdev-mesh] Ledger compaction rewrite failed for mesh ${meshId}: ${e?.message || e}\n`);
377
+ return { archivedCount: archive.length, retainedCount: keep.length };
378
+ }
379
+
380
+ return { archivedCount: archive.length, retainedCount: keep.length };
381
+ }
382
+
231
383
  // ─── Core API ───────────────────────────────────
232
384
 
233
385
  function readNonEmptyString(value: unknown): string | undefined {
@@ -249,7 +401,16 @@ function extractJsonObjectFromSummary(summary?: string): Record<string, unknown>
249
401
  if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue;
250
402
  try {
251
403
  const parsed = JSON.parse(trimmed);
252
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
404
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
405
+ // Require at least one mesh worker result field to avoid false positives
406
+ // (e.g. JSON from tool call outputs or log lines in the final summary).
407
+ const hasWorkerShape = 'status' in parsed && (
408
+ 'changedFiles' in parsed || 'errors' in parsed
409
+ || 'gitStatus' in parsed || 'nextAction' in parsed
410
+ || 'validationResults' in parsed
411
+ );
412
+ if (hasWorkerShape) return parsed;
413
+ }
253
414
  } catch { /* try next candidate */ }
254
415
  }
255
416
  return undefined;
@@ -376,12 +537,14 @@ export function appendLedgerEntry(
376
537
 
377
538
  const filePath = getLedgerPath(meshId);
378
539
 
379
- // Rotate if file exceeds max size
540
+ // Compact or rotate based on file size
380
541
  if (existsSync(filePath)) {
381
542
  try {
382
543
  const stat = statSync(filePath);
383
544
  if (stat.size >= MAX_FILE_SIZE_BYTES) {
384
545
  rotateLedgerFile(meshId, filePath);
546
+ } else if (stat.size >= COMPACT_THRESHOLD_BYTES) {
547
+ compactLedger(meshId);
385
548
  }
386
549
  } catch {
387
550
  // stat failed — proceed with append anyway
@@ -391,6 +554,7 @@ export function appendLedgerEntry(
391
554
  try {
392
555
  const line = JSON.stringify(entry) + '\n';
393
556
  appendFileSync(filePath, line, { encoding: 'utf-8', mode: 0o600 });
557
+ invalidateLedgerCache(meshId);
394
558
  meshLedgerEvents.emit('append', meshId, entry);
395
559
  return entry;
396
560
  } catch (e: any) {
@@ -422,8 +586,9 @@ export function appendRemoteLedgerEntries(meshId: string, entries: MeshLedgerEnt
422
586
  if (entries.length === 0) return { accepted: 0, skippedDuplicate: 0, rejectedInvalid: 0, entries: [] };
423
587
  const ledgerPath = getLedgerPath(meshId);
424
588
 
425
- // Read existing to deduplicate by ID
426
- const existing = new Set(readLedgerEntries(meshId).map(e => e.id));
589
+ // Dedup against recent entries only — P2P replication is incremental (cursor-based),
590
+ // so duplicates appear in the recent tail, not deep history.
591
+ const existing = new Set(readLedgerEntries(meshId, { tail: 1000 }).map(e => e.id));
427
592
  const validEntries: MeshLedgerEntry[] = [];
428
593
  let rejectedInvalid = 0;
429
594
  let skippedDuplicate = 0;
@@ -447,6 +612,7 @@ export function appendRemoteLedgerEntries(meshId: string, entries: MeshLedgerEnt
447
612
  try {
448
613
  const lines = validEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
449
614
  appendFileSync(ledgerPath, lines, { encoding: 'utf-8', mode: 0o600 });
615
+ invalidateLedgerCache(meshId);
450
616
  for (const entry of validEntries) {
451
617
  meshLedgerEvents.emit('append', meshId, entry);
452
618
  }
@@ -456,103 +622,81 @@ export function appendRemoteLedgerEntries(meshId: string, entries: MeshLedgerEnt
456
622
  }
457
623
  }
458
624
 
459
- /**
460
- * Read ledger entries with optional filtering.
461
- */
462
- export function readLedgerEntries(meshId: string, opts?: ReadLedgerOptions): MeshLedgerEntry[] {
625
+ // ─── Ledger Read Cache ─────────────────────────
626
+ // Absorbs repeated reads within a single event-processing burst (e.g. agent:stopped
627
+ // triggers shouldSuppressIntentionalCleanupStop, findRecentTerminalLedgerEvidence,
628
+ // hasDispatchAfterTerminal, and getSessionRecoveryContext all reading the same file).
629
+ // TTL is 100ms: short enough to stay current, long enough to cover one event cycle.
630
+ // Cache is invalidated on every write (append, remote import, compaction).
631
+
632
+ const ledgerReadCache = new Map<string, { entries: MeshLedgerEntry[]; cachedAt: number }>();
633
+ const LEDGER_CACHE_TTL_MS = 100;
634
+
635
+ function readLedgerFile(meshId: string): MeshLedgerEntry[] {
463
636
  const filePath = getLedgerPath(meshId);
464
637
  if (!existsSync(filePath)) return [];
465
-
466
638
  let content: string;
467
- try {
468
- content = readFileSync(filePath, 'utf-8');
469
- } catch {
470
- return [];
471
- }
472
-
473
- const lines = content.split('\n').filter(line => line.trim());
474
- let entries: MeshLedgerEntry[] = [];
475
-
476
- for (const line of lines) {
639
+ try { content = readFileSync(filePath, 'utf-8'); } catch { return []; }
640
+ const entries: MeshLedgerEntry[] = [];
641
+ for (const line of content.split('\n')) {
642
+ if (!line.trim()) continue;
477
643
  try {
478
644
  const entry = JSON.parse(line) as MeshLedgerEntry;
479
- if (!entry.id || !entry.kind) continue;
480
- entries.push(entry);
481
- } catch {
482
- // Skip malformed lines
483
- }
645
+ if (entry.id && entry.kind) entries.push(entry);
646
+ } catch { /* skip malformed lines */ }
484
647
  }
648
+ return entries;
649
+ }
650
+
651
+ function getCachedRawEntries(meshId: string): MeshLedgerEntry[] {
652
+ const now = Date.now();
653
+ const cached = ledgerReadCache.get(meshId);
654
+ if (cached && now - cached.cachedAt < LEDGER_CACHE_TTL_MS) return cached.entries;
655
+ const entries = readLedgerFile(meshId);
656
+ ledgerReadCache.set(meshId, { entries, cachedAt: now });
657
+ return entries;
658
+ }
659
+
660
+ function invalidateLedgerCache(meshId: string): void {
661
+ ledgerReadCache.delete(meshId);
662
+ }
663
+
664
+ /**
665
+ * Read ledger entries with optional filtering.
666
+ */
667
+ export function readLedgerEntries(meshId: string, opts?: ReadLedgerOptions): MeshLedgerEntry[] {
668
+ let entries = getCachedRawEntries(meshId);
485
669
 
486
- // Apply filters
487
670
  if (opts?.since) {
488
671
  const sinceDate = new Date(opts.since).getTime();
489
- if (!isNaN(sinceDate)) {
490
- entries = entries.filter(e => new Date(e.timestamp).getTime() >= sinceDate);
491
- }
672
+ if (!isNaN(sinceDate)) entries = entries.filter(e => new Date(e.timestamp).getTime() >= sinceDate);
492
673
  }
493
-
494
674
  if (opts?.kind?.length) {
495
675
  const kindSet = new Set(opts.kind);
496
676
  entries = entries.filter(e => kindSet.has(e.kind));
497
677
  }
498
-
499
- // Apply tail (return last N entries)
500
678
  if (opts?.tail && opts.tail > 0 && entries.length > opts.tail) {
501
679
  entries = entries.slice(-opts.tail);
502
680
  }
503
-
504
681
  return entries;
505
682
  }
506
683
 
507
684
  /**
508
- * Read a bounded, cursor-addressable ledger slice for local-first/P2P replication.
509
- * The result is intentionally small and self-describing so coordinators can query
510
- * remote daemons on demand without Cloud/D1 becoming a ledger data-plane.
511
- */
512
- export function readLedgerSlice(meshId: string, opts?: ReadLedgerSliceOptions): MeshLedgerSlice {
513
- const limit = clampLedgerSliceLimit(opts?.limit);
514
- let entries = readLedgerEntries(meshId, { since: opts?.since, kind: opts?.kind });
515
- const afterId = typeof opts?.afterId === 'string' && opts.afterId.trim() ? opts.afterId.trim() : null;
516
- if (afterId) {
517
- const index = entries.findIndex(entry => entry.id === afterId);
518
- entries = index >= 0 ? entries.slice(index + 1) : entries;
519
- }
520
- const bounded = entries.slice(0, limit);
521
- return {
522
- protocol: 'adhdev.mesh.ledger.slice.v1',
523
- meshId,
524
- entries: bounded,
525
- cursor: {
526
- afterId,
527
- nextAfterId: bounded.length ? bounded[bounded.length - 1].id : afterId,
528
- limit,
529
- hasMore: entries.length > bounded.length,
530
- },
531
- summary: getLedgerSummary(meshId),
532
- sourceOfTruth: {
533
- kind: 'local_jsonl',
534
- path: getLedgerPath(meshId),
535
- bounded: true,
536
- maxLimit: MAX_LEDGER_SLICE_LIMIT,
537
- },
538
- };
539
- }
540
-
541
- /**
542
- * Get a summary of mesh activity from the ledger.
685
+ * Build a ledger summary from pre-loaded entries. Used by both getLedgerSummary
686
+ * and readLedgerSlice so they share a single getCachedRawEntries() call.
543
687
  */
544
- export function getLedgerSummary(meshId: string): MeshLedgerSummary {
545
- const entries = readLedgerEntries(meshId);
688
+ function buildLedgerSummary(meshId: string, entries: MeshLedgerEntry[]): MeshLedgerSummary {
689
+ const archived = readArchivedCounts(meshId);
546
690
  const now = Date.now();
547
691
  const recentFailureCutoff = now - RECENT_FAILURE_WINDOW_MS;
548
692
 
549
693
  const summary: MeshLedgerSummary = {
550
694
  meshId,
551
- totalEntries: entries.length,
695
+ totalEntries: entries.length + archived.totalArchived,
552
696
  taskDispatched: 0,
553
- taskCompleted: 0,
554
- taskFailed: 0,
555
- taskStalled: 0,
697
+ taskCompleted: archived.taskCompleted,
698
+ taskFailed: archived.taskFailed,
699
+ taskStalled: archived.taskStalled,
556
700
  sessionLaunched: 0,
557
701
  checkpointCreated: 0,
558
702
  lastActivityAt: null,
@@ -587,6 +731,59 @@ export function getLedgerSummary(meshId: string): MeshLedgerSummary {
587
731
  return summary;
588
732
  }
589
733
 
734
+ /**
735
+ * Read a bounded, cursor-addressable ledger slice for local-first/P2P replication.
736
+ * The result is intentionally small and self-describing so coordinators can query
737
+ * remote daemons on demand without Cloud/D1 becoming a ledger data-plane.
738
+ */
739
+ export function readLedgerSlice(meshId: string, opts?: ReadLedgerSliceOptions): MeshLedgerSlice {
740
+ const limit = clampLedgerSliceLimit(opts?.limit);
741
+ // Load raw entries once and share between filtering, pagination, and summary.
742
+ const rawEntries = getCachedRawEntries(meshId);
743
+
744
+ let entries: MeshLedgerEntry[] = rawEntries;
745
+ if (opts?.since) {
746
+ const sinceDate = new Date(opts.since).getTime();
747
+ if (!isNaN(sinceDate)) entries = entries.filter(e => new Date(e.timestamp).getTime() >= sinceDate);
748
+ }
749
+ if (opts?.kind?.length) {
750
+ const kindSet = new Set(opts.kind);
751
+ entries = entries.filter(e => kindSet.has(e.kind));
752
+ }
753
+
754
+ const afterId = typeof opts?.afterId === 'string' && opts.afterId.trim() ? opts.afterId.trim() : null;
755
+ if (afterId) {
756
+ const index = entries.findIndex(entry => entry.id === afterId);
757
+ entries = index >= 0 ? entries.slice(index + 1) : entries;
758
+ }
759
+ const bounded = entries.slice(0, limit);
760
+ return {
761
+ protocol: 'adhdev.mesh.ledger.slice.v1',
762
+ meshId,
763
+ entries: bounded,
764
+ cursor: {
765
+ afterId,
766
+ nextAfterId: bounded.length ? bounded[bounded.length - 1].id : afterId,
767
+ limit,
768
+ hasMore: entries.length > bounded.length,
769
+ },
770
+ summary: buildLedgerSummary(meshId, rawEntries),
771
+ sourceOfTruth: {
772
+ kind: 'local_jsonl',
773
+ path: getLedgerPath(meshId),
774
+ bounded: true,
775
+ maxLimit: MAX_LEDGER_SLICE_LIMIT,
776
+ },
777
+ };
778
+ }
779
+
780
+ /**
781
+ * Get a summary of mesh activity from the ledger.
782
+ */
783
+ export function getLedgerSummary(meshId: string): MeshLedgerSummary {
784
+ return buildLedgerSummary(meshId, getCachedRawEntries(meshId));
785
+ }
786
+
590
787
  // ─── Recovery Context ───────────────────────────
591
788
 
592
789
  export interface SessionRecoveryContext {
@@ -621,46 +818,64 @@ export function getSessionRecoveryContext(
621
818
  },
622
819
  ): SessionRecoveryContext {
623
820
  const maxRetries = opts.maxRetries ?? 1;
624
- const entries = readLedgerEntries(meshId);
821
+ // tail:500 is sufficient — task_dispatched is never archived (only terminal kinds are),
822
+ // so dispatch history is always present. The 30-min failure window means we never need
823
+ // more than a few dozen recent entries for consecutiveNodeFailures. Bounding to 500
824
+ // avoids a full O(n) scan for meshes with many historical entries.
825
+ const entries = readLedgerEntries(meshId, { tail: 500 });
625
826
 
626
- // Find the last task_dispatched for this session or node
827
+ // Single backward pass: find last task_dispatched AND count consecutive recent failures.
828
+ const now = Date.now();
829
+ const recentWindow = now - RECENT_FAILURE_WINDOW_MS;
627
830
  let lastDispatch: MeshLedgerEntry | null = null;
831
+ let consecutiveNodeFailures = 0;
832
+ let failureCountDone = false;
628
833
  for (let i = entries.length - 1; i >= 0; i--) {
629
834
  const e = entries[i];
630
- if (e.kind !== 'task_dispatched') continue;
631
- if (opts.sessionId && e.sessionId === opts.sessionId) { lastDispatch = e; break; }
632
- if (opts.nodeId && e.nodeId === opts.nodeId) { lastDispatch = e; break; }
835
+ const ts = new Date(e.timestamp).getTime();
836
+
837
+ // Failure counting: scan until we exit the recent window or hit a chain-breaker
838
+ if (!failureCountDone) {
839
+ if (ts < recentWindow) {
840
+ failureCountDone = true;
841
+ } else if (opts.nodeId && e.nodeId !== opts.nodeId) {
842
+ // Entry for a different node — skip for failure counting but continue scanning for dispatch
843
+ } else if (e.kind === 'task_failed') {
844
+ if (!isIntentionalCleanupStopEntry(e)) consecutiveNodeFailures++;
845
+ } else if (e.kind === 'task_completed' || e.kind === 'task_dispatched') {
846
+ // A completion or new dispatch breaks the consecutive failure chain
847
+ failureCountDone = true;
848
+ }
849
+ }
850
+
851
+ // Dispatch search: find the last dispatch matching this session or node
852
+ if (lastDispatch === null && e.kind === 'task_dispatched') {
853
+ if (opts.sessionId && e.sessionId === opts.sessionId) { lastDispatch = e; }
854
+ else if (!opts.sessionId && opts.nodeId && e.nodeId === opts.nodeId) { lastDispatch = e; }
855
+ }
856
+
857
+ // Stop once both tasks are done
858
+ if (lastDispatch !== null && failureCountDone) break;
633
859
  }
634
860
 
635
861
  const lastTaskMessage = typeof lastDispatch?.payload?.message === 'string'
636
862
  ? lastDispatch.payload.message
637
863
  : null;
638
864
 
639
- // Count consecutive recent failures for this node (within 30 min window)
640
- const now = Date.now();
641
- const recentWindow = now - RECENT_FAILURE_WINDOW_MS;
642
- let consecutiveNodeFailures = 0;
643
- for (let i = entries.length - 1; i >= 0; i--) {
644
- const e = entries[i];
645
- if (new Date(e.timestamp).getTime() < recentWindow) break;
646
- if (opts.nodeId && e.nodeId !== opts.nodeId) continue;
647
- if (e.kind === 'task_failed') {
648
- if (isIntentionalCleanupStopEntry(e)) continue;
649
- consecutiveNodeFailures++;
650
- } else if (e.kind === 'task_completed' || e.kind === 'task_dispatched') {
651
- // A completion or new dispatch breaks the consecutive failure chain
652
- break;
653
- }
654
- }
655
-
656
- // Count how many times the same task was attempted (match by message prefix)
865
+ // Count how many times the same task was attempted.
866
+ // Prefer exact taskId match (payload.taskId) to avoid 200-char prefix collisions.
657
867
  let taskAttemptCount = 0;
658
- if (lastTaskMessage) {
659
- const prefix = lastTaskMessage.slice(0, 200);
660
- for (const e of entries) {
661
- if (e.kind === 'task_dispatched' && typeof e.payload?.message === 'string') {
662
- if (e.payload.message.startsWith(prefix)) {
663
- taskAttemptCount++;
868
+ if (lastDispatch) {
869
+ const taskId = typeof lastDispatch.payload?.taskId === 'string' ? lastDispatch.payload.taskId : null;
870
+ if (taskId) {
871
+ for (const e of entries) {
872
+ if (e.kind === 'task_dispatched' && e.payload?.taskId === taskId) taskAttemptCount++;
873
+ }
874
+ } else if (lastTaskMessage) {
875
+ const prefix = lastTaskMessage.slice(0, 200);
876
+ for (const e of entries) {
877
+ if (e.kind === 'task_dispatched' && typeof e.payload?.message === 'string') {
878
+ if (e.payload.message.startsWith(prefix)) taskAttemptCount++;
664
879
  }
665
880
  }
666
881
  }
@@ -710,7 +925,8 @@ function rotateLedgerFile(meshId: string, currentPath: string): void {
710
925
 
711
926
  try {
712
927
  renameSync(currentPath, getRotatedPath(meshId, index));
713
- } catch {
928
+ } catch (e: any) {
714
929
  // Rotation failed — the next append will just grow the file
930
+ process.stderr.write(`[adhdev-mesh] Ledger rotation failed for mesh ${meshId}: ${e?.message || e}. File will continue to grow.\n`);
715
931
  }
716
932
  }
@@ -1,9 +1,10 @@
1
1
  import type { MeshLedgerEntry } from './mesh-ledger.js';
2
2
  import type { PendingMeshCoordinatorEvent } from './mesh-events.js';
3
+ import type { MeshAsyncJobLifecycle } from '../repo-mesh-types.js';
3
4
 
4
5
  export type MeshAsyncRefineJobStatus = 'accepted' | 'running' | 'completed' | 'failed';
5
6
 
6
- export interface MeshAsyncRefineJobSummary {
7
+ export interface MeshAsyncRefineJobSummary extends MeshAsyncJobLifecycle {
7
8
  jobId: string;
8
9
  interactionId?: string;
9
10
  status: MeshAsyncRefineJobStatus;
@@ -14,8 +15,6 @@ export interface MeshAsyncRefineJobSummary {
14
15
  workspace?: string;
15
16
  branch?: string;
16
17
  into?: string;
17
- startedAt?: string;
18
- completedAt?: string;
19
18
  retryOfJobId?: string;
20
19
  lastEvent?: string;
21
20
  lastLedgerKind?: string;