@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.
- package/dist/index.d.ts +2 -2
- package/dist/index.js +497 -330
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +496 -330
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +66 -0
- package/package.json +2 -2
- package/src/index.ts +2 -2
- package/src/mesh/mesh-active-work.ts +154 -0
- package/src/mesh/mesh-reconcile-loop.ts +144 -1
|
@@ -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.
|
|
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.
|
|
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') {
|