@adhdev/daemon-core 0.9.82-rc.326 → 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;
@@ -84,6 +84,8 @@ export declare function buildMeshNodeCapabilityTags(node: {
84
84
  isLocalWorktree?: unknown;
85
85
  worktreeBranch?: unknown;
86
86
  userOverrides?: unknown;
87
+ reportedPlatform?: unknown;
88
+ reportedArch?: unknown;
87
89
  } | undefined, providerType?: string): string[];
88
90
  export declare function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags: unknown): boolean;
89
91
  /**
@@ -418,6 +418,18 @@ export interface LocalMeshNodeEntry {
418
418
  /** Operator-defined capability tags used by mesh queue matching. */
419
419
  capabilities?: string[];
420
420
  userOverrides: Partial<RepoMeshNodeCapabilities>;
421
+ /**
422
+ * Live, self-healed platform/arch reported by the daemon that owns this
423
+ * node's workspace (its own process.platform/process.arch), carried on the
424
+ * git_status envelope and persisted by the coordinator on each direct git
425
+ * probe. This is auto-detected truth, kept DISTINCT from `userOverrides`
426
+ * (operator intent) so capability-tag derivation can prefer an explicit
427
+ * operator override while still self-correcting auto-detected nodes — and so
428
+ * a stale value is overwritten by the next report rather than sticking.
429
+ * Absent until the first direct probe succeeds.
430
+ */
431
+ reportedPlatform?: string;
432
+ reportedArch?: string;
421
433
  policy: RepoMeshNodePolicy;
422
434
  /**
423
435
  * Per-node instruction surfaced in the coordinator prompt so the LLM
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.326",
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.326",
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",
@@ -407,8 +407,10 @@ function recordInlineMeshDirectGitTruth(
407
407
  node: any,
408
408
  git: Record<string, unknown>,
409
409
  source: 'selected_coordinator_local_git' | 'selected_coordinator_mesh_p2p_git',
410
- ): void {
411
- if (!node || typeof node !== 'object' || Array.isArray(node)) return;
410
+ ): { reporterPlatform: string | null; reporterArch: string | null } {
411
+ if (!node || typeof node !== 'object' || Array.isArray(node)) {
412
+ return { reporterPlatform: null, reporterArch: null };
413
+ }
412
414
  const checkedAt = readNumberValue(git.lastCheckedAt) ?? Date.now();
413
415
  const updatedAt = new Date(checkedAt).toISOString();
414
416
  const nextGit: Record<string, unknown> = {
@@ -439,6 +441,13 @@ function recordInlineMeshDirectGitTruth(
439
441
  const reporterPlatform = readStringValue(git.reporterPlatform) ?? (isLocalSource ? process.platform : null);
440
442
  const reporterArch = readStringValue(git.reporterArch) ?? (isLocalSource ? process.arch : null);
441
443
  stampNodeReporterPlatform(node, reporterPlatform, reporterArch);
444
+ // Mirror onto the in-memory node's dedicated reporter fields too (distinct
445
+ // from userOverrides). For a local_config mesh the caller also persists these
446
+ // to meshes.json via updateNode so the value survives a coordinator restart;
447
+ // for an inline/cache mesh this keeps the runtime object self-consistent.
448
+ if (reporterPlatform) node.reportedPlatform = reporterPlatform;
449
+ if (reporterArch) node.reportedArch = reporterArch;
450
+ return { reporterPlatform, reporterArch };
442
451
  }
443
452
 
444
453
  /**
@@ -460,6 +469,34 @@ function stampNodeReporterPlatform(node: any, platform: string | null, arch: str
460
469
  if (changed) node.userOverrides = overrides;
461
470
  }
462
471
 
472
+ /**
473
+ * Persist the live self-reported platform/arch onto the local meshes.json node
474
+ * record so capability-tag os=/arch= self-heals across coordinator restarts.
475
+ *
476
+ * The in-memory stamp done by recordInlineMeshDirectGitTruth lives on the
477
+ * mesh_status assembly object and is discarded after the response; only a
478
+ * `local_config` mesh has a backing meshes.json node to write through to. Inline
479
+ * cache/bootstrap meshes have no local node to update, so we no-op for them.
480
+ * Fire-and-forget (same pattern as the worktreeBootstrap writer) — a persistence
481
+ * failure must never block the status response.
482
+ */
483
+ function persistNodeReporterPlatform(
484
+ meshSource: 'inline_cache' | 'inline_bootstrap' | 'local_config',
485
+ mesh: any,
486
+ nodeId: string | undefined,
487
+ reporter: { reporterPlatform: string | null; reporterArch: string | null },
488
+ ): void {
489
+ if (meshSource !== 'local_config') return;
490
+ const meshId = readStringValue(mesh?.id);
491
+ if (!meshId || !nodeId) return;
492
+ const reportedPlatform = reporter.reporterPlatform ?? undefined;
493
+ const reportedArch = reporter.reporterArch ?? undefined;
494
+ if (!reportedPlatform && !reportedArch) return;
495
+ void import('../config/mesh-config.js')
496
+ .then(({ updateNode }) => updateNode(meshId, nodeId, { reportedPlatform, reportedArch }))
497
+ .catch(() => { /* best-effort self-heal; never block status assembly */ });
498
+ }
499
+
463
500
  function buildCachedInlineMeshGitStatus(node: any): Record<string, unknown> | undefined {
464
501
  const liveGit = buildInlineMeshTransitGitStatus(node);
465
502
  if (liveGit) return liveGit;
@@ -1323,7 +1360,8 @@ async function hydrateInlineMeshDirectTruth(args: {
1323
1360
  try {
1324
1361
  const localGit = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
1325
1362
  if (localGit?.isGitRepo) {
1326
- recordInlineMeshDirectGitTruth(node, localGit as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
1363
+ const reporter = recordInlineMeshDirectGitTruth(node, localGit as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
1364
+ persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
1327
1365
  localConfirmedCount += 1;
1328
1366
  continue;
1329
1367
  }
@@ -1375,7 +1413,8 @@ async function hydrateInlineMeshDirectTruth(args: {
1375
1413
  ? await args.probeCache.probe(daemonId, workspace, runProbe)
1376
1414
  : await runProbe();
1377
1415
  if (remoteGit) {
1378
- recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
1416
+ const reporter = recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
1417
+ persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
1379
1418
  peerConfirmedCount += 1;
1380
1419
  continue;
1381
1420
  }
@@ -9299,7 +9338,8 @@ export class DaemonCommandRouter {
9299
9338
  if (!connectionReported || connectionState === 'unknown') {
9300
9339
  status.connection = buildLivePeerGitConnection(connection, refreshedAt);
9301
9340
  }
9302
- recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
9341
+ const reporter = recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
9342
+ persistNodeReporterPlatform(meshRecord.source, mesh, nodeId, reporter);
9303
9343
  remoteProbeApplied = true;
9304
9344
  }
9305
9345
  }
@@ -9340,7 +9380,8 @@ export class DaemonCommandRouter {
9340
9380
  try {
9341
9381
  const gitStatus = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
9342
9382
  status.git = gitStatus;
9343
- recordInlineMeshDirectGitTruth(node, gitStatus as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
9383
+ const reporter = recordInlineMeshDirectGitTruth(node, gitStatus as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
9384
+ persistNodeReporterPlatform(meshRecord.source, mesh, nodeId, reporter);
9344
9385
  if (gitStatus.isGitRepo) {
9345
9386
  status.health = deriveMeshNodeHealthFromGit(gitStatus as unknown as Record<string, unknown>);
9346
9387
  } else {
@@ -604,6 +604,11 @@ export function updateNode(
604
604
  /** Per-node instruction surfaced in the coordinator prompt. Pass an
605
605
  * empty string or undefined to clear it. */
606
606
  systemPrompt?: string;
607
+ /** Live self-reported platform/arch from the owning daemon's git_status
608
+ * envelope. Persisted distinctly from userOverrides (auto-detected, not
609
+ * operator intent) so capability-tag os=/arch= self-heals across loads. */
610
+ reportedPlatform?: string;
611
+ reportedArch?: string;
607
612
  },
608
613
  ): LocalMeshNodeEntry | undefined {
609
614
  const config = loadMeshConfig();
@@ -614,6 +619,8 @@ export function updateNode(
614
619
  if (!node) return undefined;
615
620
 
616
621
  if (opts.userOverrides) node.userOverrides = { ...node.userOverrides, ...opts.userOverrides };
622
+ if (opts.reportedPlatform && opts.reportedPlatform.trim()) node.reportedPlatform = opts.reportedPlatform.trim();
623
+ if (opts.reportedArch && opts.reportedArch.trim()) node.reportedArch = opts.reportedArch.trim();
617
624
  if (opts.policy) node.policy = { ...node.policy, ...opts.policy };
618
625
  if (opts.worktreeBootstrap) node.worktreeBootstrap = opts.worktreeBootstrap;
619
626
  if (Object.prototype.hasOwnProperty.call(opts, 'systemPrompt')) {
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') {
@@ -385,8 +385,20 @@ function readNodeOverride(node: { userOverrides?: unknown } | undefined, key: 'p
385
385
  return typeof value === 'string' && value.trim() ? value.trim() : null;
386
386
  }
387
387
 
388
+ /**
389
+ * Live, self-reported platform/arch the owning daemon stamped onto the node from
390
+ * its own process.platform/process.arch via the git_status envelope. Kept on a
391
+ * field DISTINCT from userOverrides so capability-tag derivation can prefer an
392
+ * explicit operator override while still self-healing auto-detected nodes — and
393
+ * so the value reflects the node's real OS rather than the coordinator's.
394
+ */
395
+ function readNodeReporter(node: { reportedPlatform?: unknown; reportedArch?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
396
+ const value = key === 'platform' ? node?.reportedPlatform : node?.reportedArch;
397
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
398
+ }
399
+
388
400
  export function buildMeshNodeCapabilityTags(
389
- node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown; userOverrides?: unknown } | undefined,
401
+ node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown; userOverrides?: unknown; reportedPlatform?: unknown; reportedArch?: unknown } | undefined,
390
402
  providerType?: string,
391
403
  ): string[] {
392
404
  const provider = typeof providerType === 'string' && providerType.trim()
@@ -395,17 +407,25 @@ export function buildMeshNodeCapabilityTags(
395
407
  const worktreeBranch = typeof node?.worktreeBranch === 'string' && node.worktreeBranch.trim()
396
408
  ? node.worktreeBranch.trim()
397
409
  : null;
398
- // Per-node platform/arch: prefer the value the remote daemon stamped into
399
- // its node record (userOverrides.platform/arch) so a Windows member advertises
400
- // os=win32 even though the COORDINATOR computing these tags runs on darwin.
401
- // Fall back to process.platform/process.arch only when absent — that covers the
402
- // local coordinator node and local worktree nodes, where process.* IS correct.
410
+ // Per-node platform/arch precedence (highest lowest):
411
+ // 1. userOverrides.platform/arch an EXPLICIT operator override always wins.
412
+ // 2. reportedPlatform/reportedArch the live OS the owning daemon
413
+ // self-reported (its own process.platform/process.arch via the git_status
414
+ // envelope), persisted to the node record on each direct probe. This is
415
+ // why a Windows member advertises os=win32 even though the COORDINATOR
416
+ // computing these tags runs on darwin — without it the consumer reads the
417
+ // persistent node (operator userOverrides empty) and would fall straight
418
+ // through to the coordinator's own process.platform, mislabeling every
419
+ // node os=darwin. We prefer this LIVE value over any stale auto-stamp.
420
+ // 3. process.platform/process.arch — last-resort fallback, correct only for
421
+ // the local coordinator node / local worktree nodes that have not yet
422
+ // been probed (their workspace lives on THIS machine anyway).
403
423
  // Vocabulary is raw process.platform/process.arch ("darwin"/"win32"/"linux",
404
424
  // "arm64"/"x64") on both the advertiser and the required_tags matcher, which
405
425
  // compares with plain string equality (nodeSatisfiesRequiredTags) — so this
406
426
  // keeps the win32/darwin/linux vocabulary the matcher already expects.
407
- const os = readNodeOverride(node, 'platform') ?? process.platform;
408
- const arch = readNodeOverride(node, 'arch') ?? process.arch;
427
+ const os = readNodeOverride(node, 'platform') ?? readNodeReporter(node, 'platform') ?? process.platform;
428
+ const arch = readNodeOverride(node, 'arch') ?? readNodeReporter(node, 'arch') ?? process.arch;
409
429
  return normalizeMeshCapabilityTags([
410
430
  ...(Array.isArray(node?.capabilities) ? node.capabilities : []),
411
431
  `os=${os}`,