@adhdev/daemon-core 0.9.82-rc.4 → 0.9.82-rc.40

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.
@@ -62,7 +62,7 @@ export interface GitPushResult extends GitRepoIdentity {
62
62
  }
63
63
 
64
64
  export interface GitCommandServices {
65
- getStatus?: (params: { workspace: string }) => Promise<GitRepoStatus> | GitRepoStatus;
65
+ getStatus?: (params: { workspace: string; refreshUpstream?: boolean }) => Promise<GitRepoStatus> | GitRepoStatus;
66
66
  getDiffSummary?: (params: { workspace: string; staged?: boolean }) => Promise<GitDiffSummary> | GitDiffSummary;
67
67
  getDiffFile?: (params: { workspace: string; path: string; staged?: boolean }) => Promise<GitFileDiff> | GitFileDiff;
68
68
  createSnapshot?: (params: {
@@ -171,7 +171,7 @@ const defaultSnapshotStore = createGitSnapshotStore({
171
171
 
172
172
  export function createDefaultGitCommandServices(): GitCommandServices {
173
173
  return {
174
- getStatus: ({ workspace }) => getGitRepoStatus(workspace),
174
+ getStatus: ({ workspace, refreshUpstream }) => getGitRepoStatus(workspace, { refreshUpstream }),
175
175
  getDiffSummary: ({ workspace }) => getGitDiffSummary(workspace),
176
176
  getDiffFile: ({ workspace, path: filePath }) => getGitFileDiff(workspace, filePath),
177
177
  createSnapshot: ({ workspace, reason, sessionId, turnId }) => defaultSnapshotStore.create({
@@ -290,7 +290,7 @@ export async function handleGitCommand(
290
290
  switch (command) {
291
291
  case 'git_status': {
292
292
  if (!services.getStatus) return serviceNotImplemented(command);
293
- const status = await runService(() => services.getStatus!({ workspace }));
293
+ const status = await runService(() => services.getStatus!({ workspace, refreshUpstream: optionalBoolean(args?.refreshUpstream) }));
294
294
  return 'success' in status ? status : { success: true, status };
295
295
  }
296
296
 
@@ -1,12 +1,25 @@
1
- import type { GitRepoStatus, GitSubmoduleStatus } from './git-types.js';
1
+ import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
2
  import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
3
 
4
+ type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
5
+
4
6
  export interface GitStatusOptions {
5
7
  timeoutMs?: number;
6
8
  /** When true, include submodule status in the result. Defaults to true. */
7
9
  includeSubmodules?: boolean;
8
10
  /** Optional filter to exclude specific submodule paths from status */
9
11
  submoduleIgnorePaths?: string[];
12
+ /**
13
+ * When true, refresh the tracked remote before trusting ahead/behind.
14
+ * Callers should opt into this only for convergence-critical surfaces.
15
+ */
16
+ refreshUpstream?: boolean;
17
+ }
18
+
19
+ interface GitUpstreamProbe {
20
+ upstreamStatus: GitUpstreamFreshness;
21
+ upstreamFetchedAt?: number;
22
+ upstreamFetchError?: string;
10
23
  }
11
24
 
12
25
  export async function getGitRepoStatus(
@@ -18,8 +31,16 @@ export async function getGitRepoStatus(
18
31
 
19
32
  try {
20
33
  const repo = await resolveGitRepository(workspace, options);
21
- const statusOutput = await runGit(repo, ['status', '--porcelain=v2', '--branch'], options);
22
- const parsed = parsePorcelainV2Status(statusOutput.stdout);
34
+ let parsed = await readPorcelainStatus(repo, options);
35
+ let upstreamProbe: GitUpstreamProbe = getInitialUpstreamProbe(parsed);
36
+
37
+ if (options.refreshUpstream) {
38
+ upstreamProbe = await refreshTrackedUpstream(repo, parsed, options);
39
+ if (upstreamProbe.upstreamStatus === 'fresh') {
40
+ parsed = await readPorcelainStatus(repo, options);
41
+ }
42
+ }
43
+
23
44
  const head = await readHead(repo, options);
24
45
  const stashCount = await readStashCount(repo, options);
25
46
 
@@ -36,6 +57,9 @@ export async function getGitRepoStatus(
36
57
  headCommit: head.commit,
37
58
  headMessage: head.message,
38
59
  upstream: parsed.upstream,
60
+ upstreamStatus: parsed.upstream ? upstreamProbe.upstreamStatus : 'no_upstream',
61
+ upstreamFetchedAt: upstreamProbe.upstreamFetchedAt,
62
+ upstreamFetchError: upstreamProbe.upstreamFetchError,
39
63
  ahead: parsed.ahead,
40
64
  behind: parsed.behind,
41
65
  staged: parsed.staged,
@@ -74,6 +98,72 @@ interface ParsedPorcelainStatus {
74
98
  conflictFiles: string[];
75
99
  }
76
100
 
101
+ async function readPorcelainStatus(repo: ResolvedGitRepo, options: GitStatusOptions): Promise<ParsedPorcelainStatus> {
102
+ const statusOutput = await runGit(repo, ['status', '--porcelain=v2', '--branch'], options);
103
+ return parsePorcelainV2Status(statusOutput.stdout);
104
+ }
105
+
106
+ function getInitialUpstreamProbe(parsed: ParsedPorcelainStatus): GitUpstreamProbe {
107
+ return {
108
+ upstreamStatus: parsed.upstream ? 'unchecked' : 'no_upstream',
109
+ };
110
+ }
111
+
112
+ async function refreshTrackedUpstream(
113
+ repo: ResolvedGitRepo,
114
+ parsed: ParsedPorcelainStatus,
115
+ options: GitStatusOptions,
116
+ ): Promise<GitUpstreamProbe> {
117
+ if (!parsed.upstream || !parsed.branch) {
118
+ return { upstreamStatus: 'no_upstream' };
119
+ }
120
+
121
+ const remoteName = (await readBranchRemote(repo, parsed.branch, options)) ?? inferRemoteName(parsed.upstream);
122
+ if (!remoteName) {
123
+ return {
124
+ upstreamStatus: 'stale',
125
+ upstreamFetchError: `Unable to resolve remote for upstream '${parsed.upstream}'`,
126
+ };
127
+ }
128
+
129
+ try {
130
+ await runGit(repo, ['fetch', '--quiet', '--prune', '--no-tags', remoteName], options);
131
+ return {
132
+ upstreamStatus: 'fresh',
133
+ upstreamFetchedAt: Date.now(),
134
+ };
135
+ } catch (error) {
136
+ return {
137
+ upstreamStatus: 'stale',
138
+ upstreamFetchError: formatGitError(error),
139
+ };
140
+ }
141
+ }
142
+
143
+ async function readBranchRemote(repo: ResolvedGitRepo, branch: string, options: GitStatusOptions): Promise<string | null> {
144
+ try {
145
+ const result = await runGit(repo, ['config', '--get', `branch.${branch}.remote`], options);
146
+ return result.stdout.trim() || null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function inferRemoteName(upstream: string): string | null {
153
+ const [remoteName] = upstream.split('/');
154
+ return remoteName?.trim() || null;
155
+ }
156
+
157
+ function formatGitError(error: unknown): string {
158
+ if (error instanceof GitCommandError) {
159
+ return error.stderr || error.message;
160
+ }
161
+ if (error instanceof Error) {
162
+ return error.message;
163
+ }
164
+ return String(error);
165
+ }
166
+
77
167
  export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
78
168
  const parsed: ParsedPorcelainStatus = {
79
169
  branch: null,
@@ -145,7 +235,7 @@ export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
145
235
  }
146
236
 
147
237
  async function readHead(
148
- repo: { workspace: string; repoRoot: string | null; isGitRepo: boolean },
238
+ repo: ResolvedGitRepo,
149
239
  options: GitStatusOptions,
150
240
  ): Promise<{ commit: string | null; message: string | null }> {
151
241
  try {
@@ -163,7 +253,7 @@ async function readHead(
163
253
  }
164
254
 
165
255
  async function readStashCount(
166
- repo: { workspace: string; repoRoot: string | null; isGitRepo: boolean },
256
+ repo: ResolvedGitRepo,
167
257
  options: GitStatusOptions,
168
258
  ): Promise<number> {
169
259
  try {
@@ -187,6 +277,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
187
277
  headCommit: null,
188
278
  headMessage: null,
189
279
  upstream: null,
280
+ upstreamStatus: 'unavailable',
190
281
  ahead: 0,
191
282
  behind: 0,
192
283
  staged: 0,
@@ -206,7 +297,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
206
297
  // ─── Submodule Status ───────────────────────────
207
298
 
208
299
  async function getSubmoduleStatuses(
209
- repo: { workspace: string; repoRoot: string | null; isGitRepo: boolean },
300
+ repo: ResolvedGitRepo,
210
301
  options: GitStatusOptions,
211
302
  ): Promise<GitSubmoduleStatus[]> {
212
303
  if (!repo.repoRoot) return [];
@@ -22,6 +22,9 @@ export function createGitCompactSummary(status: GitRepoStatus, diffSummary?: Git
22
22
  isGitRepo: status.isGitRepo,
23
23
  repoRoot: status.repoRoot,
24
24
  branch: status.branch,
25
+ upstreamStatus: status.upstreamStatus,
26
+ upstreamFetchedAt: status.upstreamFetchedAt,
27
+ upstreamFetchError: status.upstreamFetchError,
25
28
  dirty:
26
29
  status.staged > 0 ||
27
30
  status.modified > 0 ||
@@ -40,11 +40,19 @@ export interface GitSubmoduleStatus {
40
40
  error?: string;
41
41
  }
42
42
 
43
+ export type GitUpstreamFreshness = 'fresh' | 'unchecked' | 'stale' | 'no_upstream' | 'unavailable';
44
+
43
45
  export interface GitRepoStatus extends GitRepoIdentity {
44
46
  branch: string | null;
45
47
  headCommit: string | null;
46
48
  headMessage: string | null;
47
49
  upstream: string | null;
50
+ /** Whether ahead/behind was verified against a freshly fetched upstream ref. */
51
+ upstreamStatus: GitUpstreamFreshness;
52
+ /** Timestamp for the fetch that refreshed upstream refs when upstreamStatus === 'fresh'. */
53
+ upstreamFetchedAt?: number;
54
+ /** Error from the last refresh attempt when upstreamStatus === 'stale'. */
55
+ upstreamFetchError?: string;
48
56
  ahead: number;
49
57
  behind: number;
50
58
  staged: number;
@@ -134,6 +142,9 @@ export interface GitCompactSummary {
134
142
  isGitRepo: boolean;
135
143
  repoRoot: string | null;
136
144
  branch: string | null;
145
+ upstreamStatus: GitUpstreamFreshness;
146
+ upstreamFetchedAt?: number;
147
+ upstreamFetchError?: string;
137
148
  dirty: boolean;
138
149
  changedFiles: number;
139
150
  ahead: number;
package/src/index.ts CHANGED
@@ -103,6 +103,14 @@ export type {
103
103
  LocalMeshNodeEntry,
104
104
  RepoMeshStatus,
105
105
  RepoMeshNodeStatus,
106
+ RepoMeshSessionStatus,
107
+ RepoMeshQueueTask,
108
+ RepoMeshQueueTaskStatus,
109
+ RepoMeshQueueSummary,
110
+ RepoMeshQueueStatus,
111
+ RepoMeshLedgerEntryStatus,
112
+ RepoMeshLedgerSummaryStatus,
113
+ RepoMeshLedgerStatus,
106
114
  } from './repo-mesh-types.js';
107
115
  export { DEFAULT_MESH_POLICY } from './repo-mesh-types.js';
108
116
 
@@ -168,7 +176,7 @@ export type { MeshWorkQueueEntry, MeshTaskStatus, MeshWorkQueueStats } from './m
168
176
  // export type { MeshGraph, MeshGraphNode, MeshGraphEdge, MeshGraphNodeType, MeshGraphEdgeType } from './mesh/mesh-visualization.js';
169
177
 
170
178
  // ── Mesh Events ──
171
- export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents } from './mesh/mesh-events.js';
179
+ export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, queuePendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
172
180
  export type { PendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
173
181
 
174
182
  // ── Mesh P2P Relay Failure Classification ──
@@ -1,57 +1,106 @@
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 {
32
48
  event: string;
33
49
  meshId: string;
34
50
  nodeLabel: string;
51
+ nodeId?: string;
52
+ workspace?: string;
35
53
  metadataEvent: Record<string, unknown>;
36
54
  queuedAt: number;
37
55
  }
38
56
 
39
- const MAX_PENDING_EVENTS = 50;
40
- 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
+ }
61
+
62
+ export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
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}`);
68
+ return false;
69
+ }
70
+ }
41
71
 
42
- /** Drain and return all pending coordinator events, clearing the queue. */
43
- export function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[] {
44
- 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 []; }
45
84
  }
46
85
 
47
86
  /** Peek at pending coordinator events without draining (non-destructive). */
48
- export function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[] {
49
- 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 []; }
50
97
  }
51
98
 
52
- /** Explicitly clear all pending coordinator events. */
53
- export function clearPendingMeshCoordinatorEvents(): void {
54
- 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 */ }
55
104
  }
56
105
 
57
106
  function readNonEmptyString(value: unknown): string {
@@ -140,6 +189,62 @@ function shouldSuppressIntentionalCleanupStop(args: {
140
189
  return hasRecentIntentionalCleanupStop(args.meshId, args.sessionId, args.nodeId);
141
190
  }
142
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
+
143
248
 
144
249
  export function tryAssignQueueTask(
145
250
  components: DaemonComponents,
@@ -170,7 +275,16 @@ export function tryAssignQueueTask(
170
275
  message: task.message,
171
276
  }).catch((e: any) => {
172
277
  LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
173
- 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 */ }
174
288
  });
175
289
  return true;
176
290
  }
@@ -593,6 +707,23 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
593
707
  return { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true };
594
708
  }
595
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
+
596
727
  // ── Task Queue & Ledger ──
597
728
  let completedTaskForLedger: { id?: string } | null = null;
598
729
  if (args.event === 'agent:generating_completed') {
@@ -601,13 +732,16 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
601
732
  const providerType = readNonEmptyString(args.metadataEvent.providerType);
602
733
 
603
734
  if (sessionId) {
604
- 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
+ });
605
738
  completedTaskForLedger = completedTask ? { id: completedTask.id } : null;
606
739
  if (nodeId && providerType) {
607
- // Short delay to allow completion event to propagate before pulling next
608
- 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(() => {
609
743
  tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
610
- }, 500);
744
+ });
611
745
  }
612
746
  }
613
747
  } else if (args.event === 'agent:ready') {
@@ -648,13 +782,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
648
782
  }
649
783
 
650
784
  if (sessionId && nodeId && providerType) {
651
- remoteIdleSessions.set(`${nodeId}:${sessionId}`, { nodeId, sessionId, providerType });
652
- setTimeout(() => {
785
+ sweepExpiredRemoteIdleSessions();
786
+ remoteIdleSessions.set(`${nodeId}:${sessionId}`, {
787
+ nodeId, sessionId, providerType,
788
+ expiresAt: Date.now() + REMOTE_IDLE_SESSION_TTL_MS,
789
+ });
790
+ setImmediate(() => {
653
791
  const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
654
- if (assigned) {
655
- remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
656
- }
657
- }, 500);
792
+ if (assigned) remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
793
+ });
658
794
  }
659
795
  } else if (args.event === 'agent:generating_started') {
660
796
  const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
@@ -781,17 +917,18 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
781
917
 
782
918
  if (coordinatorInstances.length === 0) {
783
919
  // No CLI coordinator session found — buffer for MCP-based coordinators.
784
- if (pendingMeshCoordinatorEvents.length < MAX_PENDING_EVENTS) {
785
- pendingMeshCoordinatorEvents.push({
920
+ if (queuePendingMeshCoordinatorEvent({
786
921
  event: args.event,
787
922
  meshId: args.meshId,
788
923
  nodeLabel: args.nodeLabel,
924
+ nodeId: args.nodeId || undefined,
925
+ workspace: readNonEmptyString(args.metadataEvent.workspace),
789
926
  metadataEvent: {
790
927
  ...args.metadataEvent,
791
928
  ...(recoveryContext ? { recoveryContext } : {}),
792
929
  },
793
930
  queuedAt: Date.now(),
794
- });
931
+ })) {
795
932
  LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
796
933
  }
797
934
  return { success: true, forwarded: 0 };
@@ -834,6 +971,7 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
834
971
  providerType: readNonEmptyString(payload.providerType),
835
972
  providerSessionId: readNonEmptyString(payload.providerSessionId),
836
973
  finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
974
+ ...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
837
975
  intentional: payload.intentional === true,
838
976
  intentionalStop: payload.intentionalStop === true,
839
977
  operatorCleanup: payload.operatorCleanup === true,