@adhdev/daemon-core 0.9.82-rc.327 → 0.9.82-rc.328

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.
@@ -101,6 +101,72 @@ export type StaleDirectPruneClassification = 'prunable_orphan' | 'prunable_termi
101
101
  export declare function classifyStaleDirectForPrune(record: Pick<MeshActiveWorkRecord, 'staleReason' | 'staleDispatchUnacknowledged' | 'terminal'>, opts?: {
102
102
  includeTerminal?: boolean;
103
103
  }): StaleDirectPruneClassification;
104
+ /**
105
+ * Outcome of one staleDirect prune pass. Pure data — callers (the MCP tool, the
106
+ * daemon reconcile loop) format/log this however they need. The MCP tool wraps it in
107
+ * its JSON response; the reconcile loop logs prunedCount when > 0.
108
+ */
109
+ export interface StaleDirectPruneResult {
110
+ mode: 'execute' | 'dry_run';
111
+ includeTerminal: boolean;
112
+ /** Total staleDirect (+terminal when included) candidates surfaced this pass. */
113
+ candidateCount: number;
114
+ /** Records classified prunable AND (when minAgeMs > 0) old enough to auto-prune. */
115
+ prunable: MeshActiveWorkRecord[];
116
+ prunedCount: number;
117
+ /** Prunable by classification but younger than the age gate — only populated when minAgeMs > 0. */
118
+ skippedTooYoung: MeshActiveWorkRecord[];
119
+ preservedUnacknowledged: MeshActiveWorkRecord[];
120
+ /** Prunable orphans/terminals with no store-backed row to delete (ledger-only audit). */
121
+ preservedLedgerOnly: MeshActiveWorkRecord[];
122
+ preservedNotOrphan: MeshActiveWorkRecord[];
123
+ }
124
+ export interface PruneStaleDirectDispatchesOptions {
125
+ meshId: string;
126
+ /** Active direct dispatches from MeshRuntimeStore (getActiveDirectDispatches). */
127
+ directDispatches: DirectDispatchRecord[];
128
+ /** Ledger tail used to attribute remote/terminal dispatches (readLedgerEntries). */
129
+ ledgerEntries?: MeshLedgerEntry[];
130
+ queue?: MeshWorkQueueEntry[];
131
+ /** Live mesh nodes (decorated with live session details) — drives orphan detection. */
132
+ nodes?: any[];
133
+ /** When true, actually delete + append the audit ledger entry. Default false (dry run). */
134
+ execute?: boolean;
135
+ /** Include terminal (idle/failed) direct rows as prune candidates. Default false. */
136
+ includeTerminal?: boolean;
137
+ /**
138
+ * Minimum age (ms, measured from createdAt/dispatchedAt) before a prunable orphan is
139
+ * eligible. 0 (default) prunes immediately regardless of age — the manual prune behavior.
140
+ * The daemon auto-prune passes a conservative threshold so a node/session that is only
141
+ * transiently invisible is never pruned on the spot.
142
+ */
143
+ minAgeMs?: number;
144
+ /** Audit source string written into the direct_dispatch_pruned ledger payload. */
145
+ source?: string;
146
+ now?: number;
147
+ }
148
+ /**
149
+ * Shared staleDirect prune core. Single source of truth for the prune decision + the
150
+ * mutation (store-row delete + audit-ledger append) used by BOTH the manual MCP tool
151
+ * (mesh_prune_stale_direct, minAgeMs=0) and the daemon reconcile loop's auto-prune
152
+ * PHASE (minAgeMs > 0). Pure decision logic via buildMeshActiveWork + classifyStaleDirectForPrune;
153
+ * the only side effects (on execute) are deleteDirectDispatchesByTaskId and a single
154
+ * direct_dispatch_pruned ledger entry — never touching the append-only audit history of the
155
+ * pruned dispatches themselves.
156
+ *
157
+ * Safety rules (identical for manual + auto):
158
+ * - Only records classified as staleDirectWork against the CURRENT live mesh are eligible.
159
+ * - Of those, only orphans (node/session gone) — and terminals when includeTerminal — are prunable.
160
+ * Fresh unacknowledged dispatch failures (node/session still live) are always preserved.
161
+ * - Only store-backed rows (taskId present in MeshRuntimeStore) are deleted; ledger-only remote
162
+ * entries are preserved.
163
+ * - When minAgeMs > 0, a prunable orphan younger than the gate is held back (skippedTooYoung).
164
+ * This applies ONLY to the auto path; the manual path passes minAgeMs=0 (immediate).
165
+ *
166
+ * Idempotent: a deleted row no longer appears in getActiveDirectDispatches, so a second pass
167
+ * over the same orphan finds nothing to prune.
168
+ */
169
+ export declare function pruneStaleDirectDispatches(opts: PruneStaleDirectDispatchesOptions): StaleDirectPruneResult;
104
170
  export declare function buildCompactStaleDirectWorkSummary(staleDirectWork: MeshActiveWorkRecord[], opts?: {
105
171
  sampleLimit?: number;
106
172
  detailHint?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.327",
3
+ "version": "0.9.82-rc.328",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.327",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.328",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
package/src/index.ts CHANGED
@@ -233,8 +233,8 @@ export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplic
233
233
  // ── Mesh Work Queue (GUPP) ──
234
234
  export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
235
235
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
236
- export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
237
- export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';
236
+ export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, pruneStaleDirectDispatches, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
237
+ export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStaleDirectDispatchesOptions } from './mesh/mesh-active-work.js';
238
238
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
239
239
  export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
240
240
  export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
@@ -1,5 +1,7 @@
1
1
  import type { MeshLedgerEntry } from './mesh-ledger.js';
2
+ import { appendLedgerEntry } from './mesh-ledger.js';
2
3
  import type { MeshWorkQueueEntry, DirectDispatchRecord } from './mesh-work-queue.js';
4
+ import { deleteDirectDispatchesByTaskId } from './mesh-work-queue.js';
3
5
  import { meshNodeIdMatches } from '@adhdev/mesh-shared';
4
6
 
5
7
  export type MeshActiveWorkSource = 'queue' | 'direct';
@@ -440,6 +442,158 @@ export function classifyStaleDirectForPrune(
440
442
  return 'preserve_active';
441
443
  }
442
444
 
445
+ /**
446
+ * Outcome of one staleDirect prune pass. Pure data — callers (the MCP tool, the
447
+ * daemon reconcile loop) format/log this however they need. The MCP tool wraps it in
448
+ * its JSON response; the reconcile loop logs prunedCount when > 0.
449
+ */
450
+ export interface StaleDirectPruneResult {
451
+ mode: 'execute' | 'dry_run';
452
+ includeTerminal: boolean;
453
+ /** Total staleDirect (+terminal when included) candidates surfaced this pass. */
454
+ candidateCount: number;
455
+ /** Records classified prunable AND (when minAgeMs > 0) old enough to auto-prune. */
456
+ prunable: MeshActiveWorkRecord[];
457
+ prunedCount: number;
458
+ /** Prunable by classification but younger than the age gate — only populated when minAgeMs > 0. */
459
+ skippedTooYoung: MeshActiveWorkRecord[];
460
+ preservedUnacknowledged: MeshActiveWorkRecord[];
461
+ /** Prunable orphans/terminals with no store-backed row to delete (ledger-only audit). */
462
+ preservedLedgerOnly: MeshActiveWorkRecord[];
463
+ preservedNotOrphan: MeshActiveWorkRecord[];
464
+ }
465
+
466
+ export interface PruneStaleDirectDispatchesOptions {
467
+ meshId: string;
468
+ /** Active direct dispatches from MeshRuntimeStore (getActiveDirectDispatches). */
469
+ directDispatches: DirectDispatchRecord[];
470
+ /** Ledger tail used to attribute remote/terminal dispatches (readLedgerEntries). */
471
+ ledgerEntries?: MeshLedgerEntry[];
472
+ queue?: MeshWorkQueueEntry[];
473
+ /** Live mesh nodes (decorated with live session details) — drives orphan detection. */
474
+ nodes?: any[];
475
+ /** When true, actually delete + append the audit ledger entry. Default false (dry run). */
476
+ execute?: boolean;
477
+ /** Include terminal (idle/failed) direct rows as prune candidates. Default false. */
478
+ includeTerminal?: boolean;
479
+ /**
480
+ * Minimum age (ms, measured from createdAt/dispatchedAt) before a prunable orphan is
481
+ * eligible. 0 (default) prunes immediately regardless of age — the manual prune behavior.
482
+ * The daemon auto-prune passes a conservative threshold so a node/session that is only
483
+ * transiently invisible is never pruned on the spot.
484
+ */
485
+ minAgeMs?: number;
486
+ /** Audit source string written into the direct_dispatch_pruned ledger payload. */
487
+ source?: string;
488
+ now?: number;
489
+ }
490
+
491
+ /**
492
+ * Shared staleDirect prune core. Single source of truth for the prune decision + the
493
+ * mutation (store-row delete + audit-ledger append) used by BOTH the manual MCP tool
494
+ * (mesh_prune_stale_direct, minAgeMs=0) and the daemon reconcile loop's auto-prune
495
+ * PHASE (minAgeMs > 0). Pure decision logic via buildMeshActiveWork + classifyStaleDirectForPrune;
496
+ * the only side effects (on execute) are deleteDirectDispatchesByTaskId and a single
497
+ * direct_dispatch_pruned ledger entry — never touching the append-only audit history of the
498
+ * pruned dispatches themselves.
499
+ *
500
+ * Safety rules (identical for manual + auto):
501
+ * - Only records classified as staleDirectWork against the CURRENT live mesh are eligible.
502
+ * - Of those, only orphans (node/session gone) — and terminals when includeTerminal — are prunable.
503
+ * Fresh unacknowledged dispatch failures (node/session still live) are always preserved.
504
+ * - Only store-backed rows (taskId present in MeshRuntimeStore) are deleted; ledger-only remote
505
+ * entries are preserved.
506
+ * - When minAgeMs > 0, a prunable orphan younger than the gate is held back (skippedTooYoung).
507
+ * This applies ONLY to the auto path; the manual path passes minAgeMs=0 (immediate).
508
+ *
509
+ * Idempotent: a deleted row no longer appears in getActiveDirectDispatches, so a second pass
510
+ * over the same orphan finds nothing to prune.
511
+ */
512
+ export function pruneStaleDirectDispatches(opts: PruneStaleDirectDispatchesOptions): StaleDirectPruneResult {
513
+ const now = opts.now ?? Date.now();
514
+ const includeTerminal = opts.includeTerminal === true;
515
+ const execute = opts.execute === true;
516
+ const minAgeMs = Math.max(0, opts.minAgeMs ?? 0);
517
+
518
+ const activeWorkEvidence = buildMeshActiveWork({
519
+ meshId: opts.meshId,
520
+ queue: opts.queue,
521
+ ledgerEntries: opts.ledgerEntries,
522
+ directDispatches: opts.directDispatches,
523
+ nodes: opts.nodes,
524
+ now,
525
+ includeTerminalDirect: includeTerminal,
526
+ });
527
+
528
+ const candidates = [
529
+ ...activeWorkEvidence.staleDirectWork,
530
+ ...(includeTerminal ? activeWorkEvidence.terminalDirectWork : []),
531
+ ];
532
+ // Only prune store-backed dispatch rows (taskIds present in MeshRuntimeStore). Ledger-only
533
+ // remote entries have no store row to delete and are pure audit history — leave them alone.
534
+ const storeTaskIds = new Set(opts.directDispatches.map(d => d.taskId));
535
+
536
+ const prunable: MeshActiveWorkRecord[] = [];
537
+ const skippedTooYoung: MeshActiveWorkRecord[] = [];
538
+ const preservedUnacknowledged: MeshActiveWorkRecord[] = [];
539
+ const preservedLedgerOnly: MeshActiveWorkRecord[] = [];
540
+ const preservedNotOrphan: MeshActiveWorkRecord[] = [];
541
+ for (const record of candidates) {
542
+ const classification = classifyStaleDirectForPrune(record, { includeTerminal });
543
+ if (classification === 'preserve_unacknowledged') {
544
+ preservedUnacknowledged.push(record);
545
+ continue;
546
+ }
547
+ if (classification === 'preserve_active') {
548
+ preservedNotOrphan.push(record);
549
+ continue;
550
+ }
551
+ // prunable_orphan | prunable_terminal — only delete store-backed rows; ledger-only remote
552
+ // entries have no store row to delete and are pure audit history.
553
+ if (!storeTaskIds.has(record.taskId)) {
554
+ preservedLedgerOnly.push(record);
555
+ continue;
556
+ }
557
+ // Age gate (auto path only): hold back orphans that are too fresh — a node/session that is
558
+ // only transiently invisible must not be pruned the instant it disappears.
559
+ if (minAgeMs > 0) {
560
+ const ageRef = record.dispatchedAt || record.createdAt;
561
+ const ageMs = elapsedSince(ageRef, now);
562
+ if (ageMs < minAgeMs) {
563
+ skippedTooYoung.push(record);
564
+ continue;
565
+ }
566
+ }
567
+ prunable.push(record);
568
+ }
569
+
570
+ let prunedCount = 0;
571
+ if (execute && prunable.length) {
572
+ prunedCount = deleteDirectDispatchesByTaskId(opts.meshId, prunable.map(r => r.taskId));
573
+ appendLedgerEntry(opts.meshId, {
574
+ kind: 'direct_dispatch_pruned',
575
+ payload: {
576
+ source: opts.source || 'prune_stale_direct',
577
+ prunedCount,
578
+ taskIds: prunable.map(r => r.taskId),
579
+ reasons: Array.from(new Set(prunable.map(r => r.staleReason || (r.terminal ? 'terminal' : 'unknown')))),
580
+ },
581
+ });
582
+ }
583
+
584
+ return {
585
+ mode: execute ? 'execute' : 'dry_run',
586
+ includeTerminal,
587
+ candidateCount: candidates.length,
588
+ prunable,
589
+ prunedCount,
590
+ skippedTooYoung,
591
+ preservedUnacknowledged,
592
+ preservedLedgerOnly,
593
+ preservedNotOrphan,
594
+ };
595
+ }
596
+
443
597
  export function buildCompactStaleDirectWorkSummary(
444
598
  staleDirectWork: MeshActiveWorkRecord[],
445
599
  opts: { sampleLimit?: number; detailHint?: string; note?: string } = {},
@@ -55,7 +55,9 @@ import {
55
55
  expireStaleUnresolvedDelegateForwards,
56
56
  } from './mesh-unresolved-forward-outbox.js';
57
57
  import { readNonEmptyString } from './mesh-events-utils.js';
58
- import { getActiveDirectDispatches } from './mesh-work-queue.js';
58
+ import { getActiveDirectDispatches, getQueue } from './mesh-work-queue.js';
59
+ import { readLedgerEntries } from './mesh-ledger.js';
60
+ import { pruneStaleDirectDispatches } from './mesh-active-work.js';
59
61
  import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
60
62
  import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
61
63
  import type { ChatMessage } from '../types.js';
@@ -64,6 +66,25 @@ import type { ChatMessage } from '../types.js';
64
66
  // coordinator land within at most one interval. Overridable via env for tuning.
65
67
  const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
66
68
 
69
+ // PHASE 5 (auto-prune) conservative age gate. A direct dispatch whose node/session is
70
+ // orphaned (no longer in the live mesh) is only auto-pruned once it is at least this old,
71
+ // measured from its dispatch time. This protects against a node/session that is only
72
+ // *transiently* invisible (a momentary probe failure, a daemon restart) being pruned the
73
+ // instant it disappears. The MANUAL prune (mesh_prune_stale_direct) has no age gate — an
74
+ // operator pruning explicitly wants the orphan gone now. Overridable via env for tuning.
75
+ const DEFAULT_AUTO_PRUNE_MIN_AGE_MS = 24 * 60 * 60_000; // 24h
76
+
77
+ function resolveAutoPruneMinAgeMs(): number {
78
+ const raw = readNonEmptyString(process.env.MESH_AUTO_PRUNE_MIN_AGE_MS);
79
+ if (raw) {
80
+ const parsed = Number.parseInt(raw, 10);
81
+ // Clamp to [1h, 30d] so a mis-set env can't make the gate pathologically aggressive
82
+ // (prune the moment something blinks) or effectively disable it forever.
83
+ if (Number.isFinite(parsed) && parsed >= 60 * 60_000 && parsed <= 30 * 24 * 60 * 60_000) return parsed;
84
+ }
85
+ return DEFAULT_AUTO_PRUNE_MIN_AGE_MS;
86
+ }
87
+
67
88
  function resolveReconcileIntervalMs(): number {
68
89
  const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
69
90
  if (raw) {
@@ -310,6 +331,36 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
310
331
  }
311
332
  }
312
333
 
334
+ // ── PHASE 5: auto-prune orphaned direct dispatch records ───────────────────
335
+ // staleDirectWork (orphaned direct-dispatch rows whose node/session is no longer in the
336
+ // live mesh) otherwise accumulates indefinitely: a removed worktree node or a cleanly
337
+ // terminated session leaves its direct-dispatch row behind, stuck in a non-terminal status
338
+ // (e.g. generating) for days. This is NOT a false-idle bug — it is the separate problem of
339
+ // orphaned records that the only existing cleanup path (manual MCP mesh_prune_stale_direct)
340
+ // never reaches unless an operator runs it by hand.
341
+ //
342
+ // This phase runs the SAME prune core the manual tool calls (pruneStaleDirectDispatches),
343
+ // in execute mode, on the daemon timer. The only difference from the manual path is a
344
+ // conservative age gate (DEFAULT_AUTO_PRUNE_MIN_AGE_MS): a freshly-orphaned record is held
345
+ // back until it is provably stale, so a transient probe miss never auto-prunes live work.
346
+ // Every other safety rule is inherited unchanged from the core — active/pending/generating
347
+ // work and fresh unacknowledged dispatch failures are never pruned, ledger-only audit entries
348
+ // are preserved, and the prune itself is recorded with a direct_dispatch_pruned ledger entry.
349
+ // Idempotent: a pruned row is gone from getActiveDirectDispatches, so the next tick finds
350
+ // nothing to re-prune. Isolated in its own try/catch per mesh so it can never kill the tick.
351
+ {
352
+ const minAgeMs = resolveAutoPruneMinAgeMs();
353
+ for (const mesh of listMeshes()) {
354
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
355
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
356
+ try {
357
+ await autoPruneStaleDirectDispatches(components, mesh, selfIds, localDaemonId, minAgeMs);
358
+ } catch (e: any) {
359
+ LOG.warn('MeshReconcile', `Auto-prune stale direct failed for mesh ${mesh.id}: ${e?.message || e}`);
360
+ }
361
+ }
362
+ }
363
+
313
364
  // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
314
365
  const coordinators = findLiveCoordinators(components);
315
366
  if (coordinators.length === 0) {
@@ -592,6 +643,98 @@ async function reconcileUnterminatedDirectDispatches(
592
643
  }
593
644
  }
594
645
 
646
+ // PHASE 5 helper. Build the live-node view (mesh.nodes decorated with each node's live
647
+ // session list) and run the shared prune core in execute mode with the conservative age gate.
648
+ //
649
+ // Orphan detection needs the SAME live-session evidence the manual MCP prune uses: a node still
650
+ // in mesh.nodes whose session list no longer contains the dispatched sessionId is "session not
651
+ // present" (prunable); a node missing from mesh.nodes entirely is "node no longer in live mesh"
652
+ // (prunable). We obtain live sessions per node via get_status_metadata — local nodes through the
653
+ // local commandHandler, remote nodes over P2P (dispatchMeshCommand) — exactly the transports
654
+ // PHASE 4 already uses. A node we cannot probe (offline) keeps an empty session list; combined
655
+ // with the age gate that only matters once the orphan is genuinely old.
656
+ //
657
+ // O(1) fast exit: when there are no active direct dispatches at all there is nothing to prune,
658
+ // so we skip the (per-node) status probes entirely — an idle mesh costs one indexed query.
659
+ async function autoPruneStaleDirectDispatches(
660
+ components: DaemonComponents,
661
+ mesh: LocalMeshEntry,
662
+ selfIds: string[],
663
+ localDaemonId: string | undefined,
664
+ minAgeMs: number,
665
+ ): Promise<void> {
666
+ const directDispatches = getActiveDirectDispatches(mesh.id);
667
+ if (directDispatches.length === 0) return; // nothing dispatched → nothing to prune
668
+
669
+ const liveNodes = await collectLiveNodesWithSessions(components, mesh, selfIds, localDaemonId);
670
+
671
+ const result = pruneStaleDirectDispatches({
672
+ meshId: mesh.id,
673
+ queue: getQueue(mesh.id),
674
+ ledgerEntries: readLedgerEntries(mesh.id, { tail: 500 }),
675
+ directDispatches,
676
+ nodes: liveNodes,
677
+ execute: true,
678
+ minAgeMs,
679
+ source: 'daemon_reconcile_auto_prune',
680
+ });
681
+
682
+ // Log only when something was actually pruned — silence on the common no-op tick.
683
+ if (result.prunedCount > 0) {
684
+ LOG.info('MeshReconcile', `Auto-pruned ${result.prunedCount} orphaned direct dispatch record(s) for mesh ${mesh.id}`);
685
+ }
686
+ }
687
+
688
+ // Probe each node for its live session list (get_status_metadata) and return mesh.nodes
689
+ // decorated with a `sessions` array — the shape buildMeshActiveWork / sessionStatusFromNodes
690
+ // consume to decide whether a dispatched session is still present. Best-effort: an unreachable
691
+ // node yields an empty session list rather than throwing.
692
+ async function collectLiveNodesWithSessions(
693
+ components: DaemonComponents,
694
+ mesh: LocalMeshEntry,
695
+ selfIds: string[],
696
+ localDaemonId: string | undefined,
697
+ ): Promise<any[]> {
698
+ const dispatchMeshCommand = components.dispatchMeshCommand;
699
+ return Promise.all(mesh.nodes.map(async (node) => {
700
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
701
+ const isLocalNode = !nodeDaemonId
702
+ || selfIds.includes(nodeDaemonId)
703
+ || (localDaemonId !== undefined && nodeDaemonId === localDaemonId);
704
+ let statusResult: unknown;
705
+ try {
706
+ if (isLocalNode) {
707
+ statusResult = await components.commandHandler.handle('get_status_metadata', {});
708
+ } else if (dispatchMeshCommand) {
709
+ statusResult = await dispatchMeshCommand(nodeDaemonId, 'get_status_metadata', {});
710
+ } else {
711
+ return node; // remote node, no P2P transport — leave undecorated
712
+ }
713
+ } catch {
714
+ return node; // unreachable — leave undecorated (empty session list)
715
+ }
716
+ const sessions = extractStatusMetadataSessions(statusResult);
717
+ return sessions.length > 0 ? { ...node, sessions } : node;
718
+ }));
719
+ }
720
+
721
+ // Pull the live session list out of a get_status_metadata result, tolerating the same
722
+ // envelope shapes unwrapReadChatPayload handles (direct CommandResult or { payload }/{ result }).
723
+ function extractStatusMetadataSessions(raw: unknown): any[] {
724
+ let cursor: unknown = raw;
725
+ for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
726
+ const record = cursor as Record<string, unknown>;
727
+ const status = record.status && typeof record.status === 'object' ? record.status as Record<string, unknown> : undefined;
728
+ if (status && Array.isArray(status.sessions)) return status.sessions;
729
+ if (Array.isArray(record.sessions)) return record.sessions;
730
+ if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
731
+ if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
732
+ if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
733
+ break;
734
+ }
735
+ return [];
736
+ }
737
+
595
738
  function extractPendingEvents(raw: unknown): any[] {
596
739
  if (Array.isArray(raw)) return raw;
597
740
  if (raw && typeof raw === 'object') {