@adhdev/daemon-core 0.9.82-rc.23 → 0.9.82-rc.25

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.
@@ -9,12 +9,12 @@ export interface PendingMeshCoordinatorEvent {
9
9
  queuedAt: number;
10
10
  }
11
11
  export declare function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean;
12
- /** Drain and return all pending coordinator events, clearing the queue. */
13
- export declare function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[];
12
+ /** Drain and return all pending coordinator events for meshId, removing them from disk. */
13
+ export declare function drainPendingMeshCoordinatorEvents(meshId?: string): PendingMeshCoordinatorEvent[];
14
14
  /** Peek at pending coordinator events without draining (non-destructive). */
15
- export declare function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[];
16
- /** Explicitly clear all pending coordinator events. */
17
- export declare function clearPendingMeshCoordinatorEvents(): void;
15
+ export declare function getPendingMeshCoordinatorEvents(meshId?: string): readonly PendingMeshCoordinatorEvent[];
16
+ /** Explicitly clear all pending coordinator events for a mesh. */
17
+ export declare function clearPendingMeshCoordinatorEvents(meshId?: string): void;
18
18
  export declare function tryAssignQueueTask(components: DaemonComponents, meshId: string, nodeId: string, sessionId: string, providerType: string): boolean;
19
19
  /**
20
20
  * Triggers a queue check for all nodes in the mesh.
@@ -26,12 +26,21 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
26
26
  forwarded: number;
27
27
  suppressed: boolean;
28
28
  intentionalCleanupStop: boolean;
29
+ duplicateCompletion?: undefined;
30
+ error?: undefined;
31
+ } | {
32
+ success: boolean;
33
+ forwarded: number;
34
+ suppressed: boolean;
35
+ duplicateCompletion: boolean;
36
+ intentionalCleanupStop?: undefined;
29
37
  error?: undefined;
30
38
  } | {
31
39
  success: boolean;
32
40
  forwarded: number;
33
41
  suppressed?: undefined;
34
42
  intentionalCleanupStop?: undefined;
43
+ duplicateCompletion?: undefined;
35
44
  error?: undefined;
36
45
  } | {
37
46
  success: boolean;
@@ -80,7 +80,9 @@ export declare function requeueTask(meshId: string, taskId: string, opts?: {
80
80
  /**
81
81
  * Update the status of the task currently assigned to a specific session.
82
82
  */
83
- export declare function updateSessionTaskStatus(meshId: string, sessionId: string, status: MeshTaskStatus): MeshWorkQueueEntry | null;
83
+ export declare function updateSessionTaskStatus(meshId: string, sessionId: string, status: MeshTaskStatus, opts?: {
84
+ occurredAt?: string;
85
+ }): MeshWorkQueueEntry | null;
84
86
  export interface MeshWorkQueueStats {
85
87
  total: number;
86
88
  active: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.23",
3
+ "version": "0.9.82-rc.25",
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",
@@ -964,6 +964,8 @@ export interface CommandRouterDeps {
964
964
  sessionHostControl?: SessionHostControlPlane | null;
965
965
  /** Selected-coordinator mesh peer telemetry surface for target daemons, when supported by the runtime. */
966
966
  getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
967
+ /** Dispatch a command to a remote mesh node via P2P/relay. Injected by cloud runtime; absent in standalone. */
968
+ dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
967
969
  }
968
970
 
969
971
  export interface CommandRouterResult {
@@ -1688,7 +1690,8 @@ export class DaemonCommandRouter {
1688
1690
  }
1689
1691
 
1690
1692
  case 'get_pending_mesh_events': {
1691
- const events = drainPendingMeshCoordinatorEvents();
1693
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
1694
+ const events = drainPendingMeshCoordinatorEvents(meshId || undefined);
1692
1695
  return { success: true, events };
1693
1696
  }
1694
1697
 
@@ -3344,29 +3347,52 @@ export class DaemonCommandRouter {
3344
3347
  }
3345
3348
  if (workspace) {
3346
3349
  if (!fs.existsSync(workspace)) {
3347
- if (applyCachedInlineMeshNodeStatus(status, node)) {
3348
- status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
3349
- nodeStatuses.push(status);
3350
- continue;
3351
- }
3352
- if (meshRecord?.source === 'inline_cache' && !isSelfNode) {
3353
- status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
3354
- nodeStatuses.push(status);
3355
- continue;
3350
+ // Workspace not local — attempt a P2P git probe for remote nodes.
3351
+ let remoteProbeApplied = false;
3352
+ if (!isSelfNode && daemonId && this.deps.dispatchMeshCommand) {
3353
+ try {
3354
+ const remoteResult = await Promise.race([
3355
+ this.deps.dispatchMeshCommand(daemonId, 'git_status', { workspace }),
3356
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
3357
+ ]) as any;
3358
+ const remoteGit = remoteResult?.status ?? remoteResult?.git ?? remoteResult;
3359
+ if (remoteGit && typeof remoteGit === 'object' && typeof remoteGit.isGitRepo === 'boolean') {
3360
+ status.git = remoteGit;
3361
+ status.health = remoteGit.isGitRepo
3362
+ ? deriveMeshNodeHealthFromGit(remoteGit as unknown as Record<string, unknown>)
3363
+ : 'degraded';
3364
+ remoteProbeApplied = true;
3365
+ }
3366
+ } catch {
3367
+ // Probe timed out or P2P unavailable — fall back to cached status
3368
+ }
3356
3369
  }
3357
- }
3358
- try {
3359
- const gitStatus = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
3360
- status.git = gitStatus;
3361
- if (gitStatus.isGitRepo) {
3362
- status.health = deriveMeshNodeHealthFromGit(gitStatus as unknown as Record<string, unknown>);
3363
- } else {
3364
- status.health = 'degraded';
3365
- if (gitStatus.error && !status.error) status.error = gitStatus.error;
3370
+ if (!remoteProbeApplied) {
3371
+ if (applyCachedInlineMeshNodeStatus(status, node)) {
3372
+ status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
3373
+ nodeStatuses.push(status);
3374
+ continue;
3375
+ }
3376
+ if (meshRecord?.source === 'inline_cache' && !isSelfNode) {
3377
+ status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
3378
+ nodeStatuses.push(status);
3379
+ continue;
3380
+ }
3366
3381
  }
3367
- } catch {
3368
- if (!applyCachedInlineMeshNodeStatus(status, node)) {
3369
- status.health = 'degraded';
3382
+ } else {
3383
+ try {
3384
+ const gitStatus = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
3385
+ status.git = gitStatus;
3386
+ if (gitStatus.isGitRepo) {
3387
+ status.health = deriveMeshNodeHealthFromGit(gitStatus as unknown as Record<string, unknown>);
3388
+ } else {
3389
+ status.health = 'degraded';
3390
+ if (gitStatus.error && !status.error) status.error = gitStatus.error;
3391
+ }
3392
+ } catch {
3393
+ if (!applyCachedInlineMeshNodeStatus(status, node)) {
3394
+ status.health = 'degraded';
3395
+ }
3370
3396
  }
3371
3397
  }
3372
3398
  } else {
@@ -1,31 +1,47 @@
1
+ import { appendFileSync, existsSync, readFileSync, unlinkSync } from 'fs';
2
+ import { join } from 'path';
1
3
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
4
  import { loadConfig } from '../config/config.js';
3
5
  import { getMesh, getMeshByRepo } from '../config/mesh-config.js';
4
6
  import { detectCLI } from '../detection/cli-detector.js';
5
7
  import { LOG } from '../logging/logger.js';
6
- import { appendLedgerEntry, buildTaskCompletionEvidence, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
8
+ import { appendLedgerEntry, buildTaskCompletionEvidence, getLedgerDir, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
7
9
  import type { MeshLedgerKind, SessionRecoveryContext } from './mesh-ledger.js';
8
10
  import { claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch } from './mesh-work-queue.js';
9
11
 
10
12
  // ---------------------------------------------------------------------------
11
13
  // Remote Node Idle Session Tracking
12
14
  // ---------------------------------------------------------------------------
13
- // Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
14
- // can assign tasks to them.
15
+ // Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
16
+ // can assign tasks to them. Each entry carries an expiresAt timestamp;
17
+ // entries are swept on insertion to prevent unbounded growth.
15
18
  // ---------------------------------------------------------------------------
16
19
  interface RemoteIdleSession {
17
20
  nodeId: string;
18
21
  sessionId: string;
19
22
  providerType: string;
23
+ expiresAt: number;
20
24
  }
25
+ const REMOTE_IDLE_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
21
26
  const remoteIdleSessions = new Map<string, RemoteIdleSession>(); // key: `${nodeId}:${sessionId}`
22
27
 
28
+ function sweepExpiredRemoteIdleSessions(): void {
29
+ const now = Date.now();
30
+ for (const [key, session] of remoteIdleSessions) {
31
+ if (session.expiresAt <= now) remoteIdleSessions.delete(key);
32
+ }
33
+ }
34
+
23
35
  // ---------------------------------------------------------------------------
24
- // MCP coordinator pending-event queue
36
+ // MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
25
37
  // ---------------------------------------------------------------------------
26
38
  // When a mesh event fires but no CLI coordinator session is registered (e.g.
27
- // the coordinator is Claude Code running via MCP), we buffer the event here.
28
- // The MCP server drains this queue on every mesh_status / mesh_send_task poll.
39
+ // the coordinator is Claude Code running via MCP), we persist the event to a
40
+ // per-mesh JSONL file so it survives daemon restarts. The 50-entry hard cap
41
+ // is removed; the file is drained atomically on each get_pending_mesh_events
42
+ // call and limited to 100 KB to prevent runaway growth.
43
+ //
44
+ // File: <ledgerDir>/<meshId>.pending-events.jsonl
29
45
  // ---------------------------------------------------------------------------
30
46
 
31
47
  export interface PendingMeshCoordinatorEvent {
@@ -38,30 +54,53 @@ export interface PendingMeshCoordinatorEvent {
38
54
  queuedAt: number;
39
55
  }
40
56
 
41
- const MAX_PENDING_EVENTS = 50;
42
- const pendingMeshCoordinatorEvents: PendingMeshCoordinatorEvent[] = [];
57
+ function getPendingEventsPath(meshId: string): string {
58
+ const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
59
+ return join(getLedgerDir(), `${safe}.pending-events.jsonl`);
60
+ }
43
61
 
44
62
  export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
45
- if (pendingMeshCoordinatorEvents.length >= MAX_PENDING_EVENTS) {
63
+ try {
64
+ appendFileSync(getPendingEventsPath(event.meshId), JSON.stringify(event) + '\n', 'utf-8');
65
+ return true;
66
+ } catch (e: any) {
67
+ LOG.warn('MeshEvents', `Failed to persist pending coordinator event: ${e?.message || e}`);
46
68
  return false;
47
69
  }
48
- pendingMeshCoordinatorEvents.push(event);
49
- return true;
50
70
  }
51
71
 
52
- /** Drain and return all pending coordinator events, clearing the queue. */
53
- export function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[] {
54
- return pendingMeshCoordinatorEvents.splice(0);
72
+ /** Drain and return all pending coordinator events for meshId, removing them from disk. */
73
+ export function drainPendingMeshCoordinatorEvents(meshId?: string): PendingMeshCoordinatorEvent[] {
74
+ if (!meshId) return [];
75
+ const path = getPendingEventsPath(meshId);
76
+ if (!existsSync(path)) return [];
77
+ try {
78
+ const raw = readFileSync(path, 'utf-8');
79
+ try { unlinkSync(path); } catch { /* concurrent drain already removed it */ }
80
+ return raw.split('\n').filter(Boolean).flatMap(line => {
81
+ try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
82
+ });
83
+ } catch { return []; }
55
84
  }
56
85
 
57
86
  /** Peek at pending coordinator events without draining (non-destructive). */
58
- export function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[] {
59
- return pendingMeshCoordinatorEvents.slice();
87
+ export function getPendingMeshCoordinatorEvents(meshId?: string): readonly PendingMeshCoordinatorEvent[] {
88
+ if (!meshId) return [];
89
+ const path = getPendingEventsPath(meshId);
90
+ if (!existsSync(path)) return [];
91
+ try {
92
+ const raw = readFileSync(path, 'utf-8');
93
+ return raw.split('\n').filter(Boolean).flatMap(line => {
94
+ try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
95
+ });
96
+ } catch { return []; }
60
97
  }
61
98
 
62
- /** Explicitly clear all pending coordinator events. */
63
- export function clearPendingMeshCoordinatorEvents(): void {
64
- pendingMeshCoordinatorEvents.splice(0);
99
+ /** Explicitly clear all pending coordinator events for a mesh. */
100
+ export function clearPendingMeshCoordinatorEvents(meshId?: string): void {
101
+ if (!meshId) return;
102
+ const path = getPendingEventsPath(meshId);
103
+ if (existsSync(path)) try { unlinkSync(path); } catch { /* already removed */ }
65
104
  }
66
105
 
67
106
  function readNonEmptyString(value: unknown): string {
@@ -150,6 +189,62 @@ function shouldSuppressIntentionalCleanupStop(args: {
150
189
  return hasRecentIntentionalCleanupStop(args.meshId, args.sessionId, args.nodeId);
151
190
  }
152
191
 
192
+ const RECENT_COMPLETION_FINGERPRINT_TTL_MS = 10 * 60 * 1000;
193
+ const recentCompletionFingerprints = new Map<string, number>();
194
+
195
+ function readEventTimestamp(value: unknown): number | null {
196
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
197
+ if (typeof value === 'string' && value.trim()) {
198
+ const numeric = Number(value);
199
+ if (Number.isFinite(numeric)) return numeric;
200
+ const parsed = Date.parse(value);
201
+ if (Number.isFinite(parsed)) return parsed;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ function buildMeshCompletionFingerprint(args: {
207
+ meshId: string;
208
+ event: string;
209
+ sessionId: string;
210
+ providerType?: string;
211
+ providerSessionId?: string;
212
+ timestamp?: number | null;
213
+ finalSummary?: string;
214
+ }): string {
215
+ const timestampPart = Number.isFinite(args.timestamp)
216
+ ? String(args.timestamp)
217
+ : readNonEmptyString(args.finalSummary).slice(0, 200);
218
+ return [
219
+ args.meshId,
220
+ args.event,
221
+ args.sessionId,
222
+ args.providerType || '',
223
+ args.providerSessionId || '',
224
+ timestampPart,
225
+ ].join('::');
226
+ }
227
+
228
+ function isDuplicateMeshCompletionEvent(args: {
229
+ meshId: string;
230
+ event: string;
231
+ sessionId: string;
232
+ providerType?: string;
233
+ providerSessionId?: string;
234
+ timestamp?: number | null;
235
+ finalSummary?: string;
236
+ }): boolean {
237
+ const fingerprint = buildMeshCompletionFingerprint(args);
238
+ if (!fingerprint) return false;
239
+ const now = Date.now();
240
+ for (const [key, seenAt] of recentCompletionFingerprints.entries()) {
241
+ if (now - seenAt > RECENT_COMPLETION_FINGERPRINT_TTL_MS) recentCompletionFingerprints.delete(key);
242
+ }
243
+ if (recentCompletionFingerprints.has(fingerprint)) return true;
244
+ recentCompletionFingerprints.set(fingerprint, now);
245
+ return false;
246
+ }
247
+
153
248
 
154
249
  export function tryAssignQueueTask(
155
250
  components: DaemonComponents,
@@ -180,7 +275,16 @@ export function tryAssignQueueTask(
180
275
  message: task.message,
181
276
  }).catch((e: any) => {
182
277
  LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
183
- updateTaskStatus(meshId, task.id, 'failed');
278
+ // Revert to pending so the task can be retried rather than permanently failing
279
+ updateTaskStatus(meshId, task.id, 'pending');
280
+ try {
281
+ appendLedgerEntry(meshId, {
282
+ kind: 'dispatch_failed' as any,
283
+ nodeId,
284
+ sessionId,
285
+ payload: { taskId: task.id, error: e?.message, retryable: true },
286
+ });
287
+ } catch { /* ledger write is best-effort */ }
184
288
  });
185
289
  return true;
186
290
  }
@@ -603,6 +707,23 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
603
707
  return { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true };
604
708
  }
605
709
 
710
+ const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
711
+ if (args.event === 'agent:generating_completed' && eventSessionId) {
712
+ const duplicateCompletion = isDuplicateMeshCompletionEvent({
713
+ meshId: args.meshId,
714
+ event: args.event,
715
+ sessionId: eventSessionId,
716
+ providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
717
+ providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
718
+ timestamp: eventTimestamp,
719
+ finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
720
+ });
721
+ if (duplicateCompletion) {
722
+ LOG.info('MeshEvents', `Suppressed duplicate completion for mesh ${args.meshId} session ${eventSessionId}`);
723
+ return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true };
724
+ }
725
+ }
726
+
606
727
  // ── Task Queue & Ledger ──
607
728
  let completedTaskForLedger: { id?: string } | null = null;
608
729
  if (args.event === 'agent:generating_completed') {
@@ -611,13 +732,16 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
611
732
  const providerType = readNonEmptyString(args.metadataEvent.providerType);
612
733
 
613
734
  if (sessionId) {
614
- const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed');
735
+ const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed', {
736
+ occurredAt: eventTimestamp !== null ? new Date(eventTimestamp).toISOString() : undefined,
737
+ });
615
738
  completedTaskForLedger = completedTask ? { id: completedTask.id } : null;
616
739
  if (nodeId && providerType) {
617
- // Short delay to allow completion event to propagate before pulling next
618
- setTimeout(() => {
740
+ // Queue state is already updated above; setImmediate avoids the
741
+ // 500 ms artificial delay while still deferring past this call frame.
742
+ setImmediate(() => {
619
743
  tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
620
- }, 500);
744
+ });
621
745
  }
622
746
  }
623
747
  } else if (args.event === 'agent:ready') {
@@ -658,13 +782,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
658
782
  }
659
783
 
660
784
  if (sessionId && nodeId && providerType) {
661
- remoteIdleSessions.set(`${nodeId}:${sessionId}`, { nodeId, sessionId, providerType });
662
- setTimeout(() => {
785
+ sweepExpiredRemoteIdleSessions();
786
+ remoteIdleSessions.set(`${nodeId}:${sessionId}`, {
787
+ nodeId, sessionId, providerType,
788
+ expiresAt: Date.now() + REMOTE_IDLE_SESSION_TTL_MS,
789
+ });
790
+ setImmediate(() => {
663
791
  const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
664
- if (assigned) {
665
- remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
666
- }
667
- }, 500);
792
+ if (assigned) remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
793
+ });
668
794
  }
669
795
  } else if (args.event === 'agent:generating_started') {
670
796
  const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
@@ -845,6 +971,7 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
845
971
  providerType: readNonEmptyString(payload.providerType),
846
972
  providerSessionId: readNonEmptyString(payload.providerSessionId),
847
973
  finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
974
+ ...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
848
975
  intentional: payload.intentional === true,
849
976
  intentionalStop: payload.intentionalStop === true,
850
977
  operatorCleanup: payload.operatorCleanup === true,