@adhdev/daemon-core 0.9.82-rc.19 → 0.9.82-rc.191

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.
Files changed (231) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +1 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/chat/source-machine.d.ts +166 -0
  4. package/dist/chat/source-resolver.d.ts +104 -0
  5. package/dist/chat/subscription-updates.d.ts +1 -0
  6. package/dist/cli-adapter-types.d.ts +22 -2
  7. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  8. package/dist/cli-adapters/cli-state-engine.d.ts +178 -0
  9. package/dist/cli-adapters/provider-cli-adapter.d.ts +106 -63
  10. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  11. package/dist/cli-adapters/provider-cli-shared.d.ts +29 -0
  12. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  13. package/dist/commands/cli-manager.d.ts +2 -1
  14. package/dist/commands/handler.d.ts +110 -0
  15. package/dist/commands/mesh-coordinator.d.ts +85 -1
  16. package/dist/commands/router.d.ts +26 -1
  17. package/dist/config/chat-history.d.ts +9 -0
  18. package/dist/config/config.d.ts +5 -0
  19. package/dist/config/mesh-config.d.ts +72 -1
  20. package/dist/git/git-commands.d.ts +7 -1
  21. package/dist/git/git-types.d.ts +2 -0
  22. package/dist/index.d.ts +37 -10
  23. package/dist/index.js +32864 -16401
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +32584 -16191
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/installer.d.ts +1 -4
  28. package/dist/ipc/local-ipc-server.d.ts +91 -0
  29. package/dist/launch.d.ts +1 -1
  30. package/dist/logging/async-batch-writer.d.ts +10 -0
  31. package/dist/mesh/beads-db.d.ts +83 -0
  32. package/dist/mesh/contracts.d.ts +164 -0
  33. package/dist/mesh/coordinator-prompt.d.ts +30 -0
  34. package/dist/mesh/coordinator-registry.d.ts +59 -0
  35. package/dist/mesh/mesh-active-work.d.ts +90 -0
  36. package/dist/mesh/mesh-events.d.ts +143 -6
  37. package/dist/mesh/mesh-fast-forward.d.ts +41 -0
  38. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  39. package/dist/mesh/mesh-ledger.d.ts +58 -1
  40. package/dist/mesh/mesh-refine-status.d.ts +26 -0
  41. package/dist/mesh/mesh-work-queue.d.ts +55 -6
  42. package/dist/mesh/preview-freshness.d.ts +18 -0
  43. package/dist/mesh/refine-config.d.ts +193 -0
  44. package/dist/mesh/worktree-bootstrap-config.d.ts +113 -0
  45. package/dist/providers/approval-utils.d.ts +13 -0
  46. package/dist/providers/cli-provider-instance.d.ts +39 -3
  47. package/dist/providers/contracts.d.ts +130 -6
  48. package/dist/providers/external-sources.d.ts +71 -0
  49. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  50. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  51. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  52. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  53. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  54. package/dist/providers/native-history/index.d.ts +13 -0
  55. package/dist/providers/provider-instance-manager.d.ts +12 -0
  56. package/dist/providers/provider-instance.d.ts +12 -0
  57. package/dist/providers/provider-loader.d.ts +26 -4
  58. package/dist/providers/provider-trust.d.ts +31 -0
  59. package/dist/providers/read-chat-contract.d.ts +29 -0
  60. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  61. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  62. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  63. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  64. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  65. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  66. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  67. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  68. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  69. package/dist/providers/sdk/v1/index.d.ts +30 -0
  70. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  71. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  72. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  73. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  74. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  75. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  76. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  77. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  78. package/dist/providers/spec/adapter.d.ts +60 -0
  79. package/dist/providers/spec/cli-adapter.d.ts +98 -0
  80. package/dist/providers/spec/driver.d.ts +168 -0
  81. package/dist/providers/spec/evaluator.d.ts +55 -0
  82. package/dist/providers/spec/loader.d.ts +14 -0
  83. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  84. package/dist/providers/spec/route.d.ts +4 -0
  85. package/dist/providers/spec/schema.gen.d.ts +549 -0
  86. package/dist/providers/spec/types.d.ts +237 -0
  87. package/dist/providers/transcript-v2.d.ts +176 -0
  88. package/dist/providers/types/interactive-prompt.d.ts +48 -0
  89. package/dist/repo-mesh-types.d.ts +108 -1
  90. package/dist/sessions/registry.d.ts +3 -0
  91. package/dist/shared-types.d.ts +42 -1
  92. package/dist/status/reporter.d.ts +2 -0
  93. package/dist/status/snapshot.d.ts +1 -0
  94. package/dist/types.d.ts +5 -0
  95. package/package.json +7 -2
  96. package/src/agent-stream/poller.ts +2 -3
  97. package/src/boot/daemon-lifecycle.ts +19 -10
  98. package/src/boot/process-hardening.ts +89 -0
  99. package/src/chat/source-machine.ts +534 -0
  100. package/src/chat/source-resolver.ts +0 -0
  101. package/src/chat/subscription-updates.ts +20 -1
  102. package/src/cli-adapter-types.d.ts +1 -0
  103. package/src/cli-adapter-types.ts +21 -2
  104. package/src/cli-adapters/cli-script-runner.ts +421 -0
  105. package/src/cli-adapters/cli-state-engine.ts +1086 -0
  106. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  107. package/src/cli-adapters/provider-cli-adapter.ts +670 -1137
  108. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  109. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  110. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  111. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  112. package/src/cli-adapters/provider-cli-shared.ts +59 -11
  113. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  114. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  115. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  116. package/src/commands/chat-commands.ts +1787 -60
  117. package/src/commands/cli-manager.ts +283 -14
  118. package/src/commands/handler.ts +809 -2
  119. package/src/commands/mesh-coordinator.ts +331 -122
  120. package/src/commands/router.ts +3600 -510
  121. package/src/config/chat-history.ts +95 -24
  122. package/src/config/config.ts +12 -0
  123. package/src/config/mesh-config.ts +280 -2
  124. package/src/config/recent-activity.ts +8 -2
  125. package/src/daemon/dev-cli-debug.ts +10 -1
  126. package/src/detection/ide-detector.ts +26 -16
  127. package/src/git/git-commands.ts +37 -7
  128. package/src/git/git-status.ts +35 -6
  129. package/src/git/git-types.ts +2 -0
  130. package/src/git/git-worktree.ts +8 -1
  131. package/src/index.ts +115 -9
  132. package/src/installer.d.ts +1 -1
  133. package/src/installer.ts +8 -6
  134. package/src/ipc/local-ipc-server.ts +278 -0
  135. package/src/launch.d.ts +1 -1
  136. package/src/launch.ts +37 -28
  137. package/src/logging/async-batch-writer.ts +55 -0
  138. package/src/logging/logger.ts +2 -1
  139. package/src/mesh/beads-db.ts +527 -0
  140. package/src/mesh/contracts.ts +329 -0
  141. package/src/mesh/coordinator-prompt.ts +204 -30
  142. package/src/mesh/coordinator-registry.ts +121 -0
  143. package/src/mesh/mesh-active-work.ts +437 -0
  144. package/src/mesh/mesh-events.ts +1275 -108
  145. package/src/mesh/mesh-fast-forward.ts +438 -0
  146. package/src/mesh/mesh-host-ownership.ts +73 -0
  147. package/src/mesh/mesh-ledger.ts +457 -104
  148. package/src/mesh/mesh-refine-status.ts +144 -0
  149. package/src/mesh/mesh-work-queue.ts +267 -159
  150. package/src/mesh/preview-freshness.ts +118 -0
  151. package/src/mesh/refine-config.ts +366 -0
  152. package/src/mesh/worktree-bootstrap-config.ts +247 -0
  153. package/src/providers/approval-utils.d.ts +4 -0
  154. package/src/providers/approval-utils.ts +47 -5
  155. package/src/providers/chat-message-normalization.ts +4 -11
  156. package/src/providers/cli-provider-instance.ts +763 -60
  157. package/src/providers/contracts.d.ts +55 -0
  158. package/src/providers/contracts.ts +141 -6
  159. package/src/providers/external-sources.ts +218 -0
  160. package/src/providers/ide-provider-instance.ts +19 -5
  161. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  162. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  163. package/src/providers/native-history/codex-cli-transcript.ts +419 -0
  164. package/src/providers/native-history/dispatcher.ts +340 -0
  165. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  166. package/src/providers/native-history/index.ts +30 -0
  167. package/src/providers/provider-instance-manager.ts +30 -0
  168. package/src/providers/provider-instance.ts +9 -0
  169. package/src/providers/provider-loader.ts +582 -50
  170. package/src/providers/provider-schema.ts +87 -14
  171. package/src/providers/provider-trust.ts +114 -0
  172. package/src/providers/read-chat-contract.ts +76 -16
  173. package/src/providers/sdk/README.md +49 -0
  174. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  175. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  176. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  177. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  178. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  179. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  180. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  181. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  182. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  183. package/src/providers/sdk/v1/index.ts +152 -0
  184. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  185. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  186. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  187. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  188. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  189. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  190. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  191. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  192. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  193. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  194. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  195. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  196. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  197. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  198. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  199. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  200. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  201. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  202. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  203. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  204. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  205. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  206. package/src/providers/sdk/v1/validators/index.ts +19 -0
  207. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  208. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  209. package/src/providers/spec/adapter.ts +176 -0
  210. package/src/providers/spec/cli-adapter.ts +534 -0
  211. package/src/providers/spec/driver.ts +612 -0
  212. package/src/providers/spec/evaluator.ts +344 -0
  213. package/src/providers/spec/loader.ts +130 -0
  214. package/src/providers/spec/native-history-executor.ts +939 -0
  215. package/src/providers/spec/route.ts +51 -0
  216. package/src/providers/spec/schema.gen.ts +538 -0
  217. package/src/providers/spec/schema.json +235 -0
  218. package/src/providers/spec/types.ts +256 -0
  219. package/src/providers/transcript-v2.ts +567 -0
  220. package/src/providers/types/interactive-prompt.ts +408 -0
  221. package/src/providers/version-archive.ts +38 -20
  222. package/src/repo-mesh-types.ts +118 -1
  223. package/src/sessions/registry.ts +3 -0
  224. package/src/shared-types.ts +45 -1
  225. package/src/status/builders.ts +24 -6
  226. package/src/status/reporter.ts +15 -0
  227. package/src/status/snapshot.ts +84 -25
  228. package/src/system/host-memory.ts +29 -12
  229. package/src/types.ts +5 -0
  230. package/dist/mesh/mesh-sync.d.ts +0 -53
  231. package/src/mesh/mesh-sync.ts +0 -111
@@ -1,31 +1,68 @@
1
+ import { appendFileSync, existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } 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
- import { claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch } from './mesh-work-queue.js';
10
+ import { buildMeshNodeCapabilityTags, claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches } from './mesh-work-queue.js';
11
+ import { BeadsDB } from './beads-db.js';
12
+ import { fastForwardMeshNode } from './mesh-fast-forward.js';
9
13
 
10
14
  // ---------------------------------------------------------------------------
11
15
  // Remote Node Idle Session Tracking
12
16
  // ---------------------------------------------------------------------------
13
- // Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
14
- // can assign tasks to them.
17
+ // Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
18
+ // can assign tasks to them. Each entry carries an expiresAt timestamp;
19
+ // entries are swept on insertion to prevent unbounded growth.
15
20
  // ---------------------------------------------------------------------------
16
- interface RemoteIdleSession {
17
- nodeId: string;
18
- sessionId: string;
19
- providerType: string;
21
+ const REMOTE_IDLE_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Workspace-to-mesh lookup cache
25
+ // ---------------------------------------------------------------------------
26
+ // getMeshByRepo is called on every coordinator event for sessions without
27
+ // meshNodeFor. Cache results for 5 seconds to avoid repeated config reads.
28
+ const meshByWorkspaceCache = new Map<string, { mesh: any; cachedAt: number }>();
29
+ const MESH_WORKSPACE_CACHE_TTL_MS = 5_000;
30
+ const IDLE_AUTO_FAST_FORWARD_THROTTLE_MS = 30 * 60 * 1000;
31
+ const idleAutoFastForwardLastAttempt = new Map<string, number>();
32
+
33
+ function getCachedMeshByWorkspace(workspace: string): any {
34
+ const now = Date.now();
35
+ const cached = meshByWorkspaceCache.get(workspace);
36
+ if (cached && now - cached.cachedAt < MESH_WORKSPACE_CACHE_TTL_MS) return cached.mesh;
37
+ const mesh = getMeshByRepo(workspace);
38
+ meshByWorkspaceCache.set(workspace, { mesh, cachedAt: now });
39
+ return mesh;
40
+ }
41
+
42
+ function readWorkerResultMetadata(event: Record<string, unknown>): Record<string, unknown> | undefined {
43
+ return readRecord(event.workerResult) || readRecord(event.meshWorkerResult) || readRecord(event.structuredResult);
44
+ }
45
+
46
+ export function __resetIdleAutoFastForwardForTests(): void {
47
+ idleAutoFastForwardLastAttempt.clear();
48
+ }
49
+
50
+ function sweepExpiredRemoteIdleSessions(): void {
51
+ try {
52
+ BeadsDB.getInstance().pruneExpiredRemoteIdleSessions();
53
+ } catch { /* best-effort */ }
20
54
  }
21
- const remoteIdleSessions = new Map<string, RemoteIdleSession>(); // key: `${nodeId}:${sessionId}`
22
55
 
23
56
  // ---------------------------------------------------------------------------
24
- // MCP coordinator pending-event queue
57
+ // MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
25
58
  // ---------------------------------------------------------------------------
26
59
  // 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.
60
+ // the coordinator is Claude Code running via MCP), we persist the event to a
61
+ // per-mesh JSONL file so it survives daemon restarts. The 50-entry hard cap
62
+ // is removed; the file is drained atomically on each get_pending_mesh_events
63
+ // call and limited to 100 KB to prevent runaway growth.
64
+ //
65
+ // File: <ledgerDir>/<meshId>.pending-events.jsonl
29
66
  // ---------------------------------------------------------------------------
30
67
 
31
68
  export interface PendingMeshCoordinatorEvent {
@@ -35,39 +72,272 @@ export interface PendingMeshCoordinatorEvent {
35
72
  nodeId?: string;
36
73
  workspace?: string;
37
74
  metadataEvent: Record<string, unknown>;
75
+ coordinatorMessage?: string;
38
76
  queuedAt: number;
77
+ /**
78
+ * When set, this event is intended for a specific coordinator daemon.
79
+ * Coordinators on other daemons should ignore it during drain.
80
+ * Absent on legacy events — treated as broadcast to any coordinator.
81
+ */
82
+ targetCoordinatorDaemonId?: string;
83
+ }
84
+
85
+ const REFINE_TERMINAL_EVENTS = new Set(['refine:completed', 'refine:failed']);
86
+
87
+ function readRefineJobId(event: { metadataEvent?: Record<string, unknown> } | Record<string, unknown>): string {
88
+ const metadata = readRecord((event as any).metadataEvent) || event as Record<string, unknown>;
89
+ const result = readRecord(metadata.result);
90
+ const refineJob = readRecord(result?.refineJob);
91
+ return readNonEmptyString(metadata.jobId) || readNonEmptyString(refineJob?.jobId);
92
+ }
93
+
94
+ function buildRefineTerminalEventFingerprint(meshId: string, eventName: string, metadataEvent: Record<string, unknown>): string {
95
+ const jobId = readRefineJobId({ metadataEvent });
96
+ return jobId && REFINE_TERMINAL_EVENTS.has(eventName) ? `${meshId}::${eventName}::${jobId}` : '';
97
+ }
98
+
99
+ function hasPendingRefineTerminalEventDuplicate(event: PendingMeshCoordinatorEvent): boolean {
100
+ if (!REFINE_TERMINAL_EVENTS.has(event.event)) return false;
101
+ const jobId = readRefineJobId(event);
102
+ if (!jobId) return false;
103
+ return readPendingMeshCoordinatorEventsFromDisk(event.meshId).some((pending) =>
104
+ pending.event === event.event && readRefineJobId(pending) === jobId,
105
+ );
106
+ }
107
+
108
+ function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent): string {
109
+ const metadata = readRecord(event.metadataEvent) || {};
110
+ const sessionId = resolveEventSessionId(metadata);
111
+ const providerSessionId = readNonEmptyString(metadata.providerSessionId);
112
+ const taskId = readNonEmptyString(metadata.taskId) || readNonEmptyString(readRecord(metadata.payload)?.taskId);
113
+ const jobId = readRefineJobId(event);
114
+ const timestamp = metadata.timestamp !== undefined && metadata.timestamp !== null ? String(metadata.timestamp) : '';
115
+ return [
116
+ event.meshId,
117
+ event.event,
118
+ event.nodeId || '',
119
+ sessionId || '',
120
+ providerSessionId || '',
121
+ taskId || '',
122
+ jobId || '',
123
+ timestamp || '',
124
+ ].join('::');
125
+ }
126
+
127
+ function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean {
128
+ const fingerprint = buildPendingEventFingerprint(event);
129
+ if (!fingerprint.trim()) return false;
130
+ return readPendingMeshCoordinatorEventsFromDisk(event.meshId).some((pending) => buildPendingEventFingerprint(pending) === fingerprint);
131
+ }
132
+
133
+ function getPendingEventsPath(meshId: string, coordinatorDaemonId?: string): string {
134
+ const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
135
+ if (coordinatorDaemonId) {
136
+ const safeDaemon = coordinatorDaemonId.replace(/[^a-zA-Z0-9_-]/g, '_');
137
+ return join(getLedgerDir(), `${safe}-${safeDaemon}.pending-events.jsonl`);
138
+ }
139
+ return join(getLedgerDir(), `${safe}.pending-events.jsonl`);
140
+ }
141
+
142
+ function readPendingMeshCoordinatorEventsFromDisk(meshId?: string, coordinatorDaemonId?: string): PendingMeshCoordinatorEvent[] {
143
+ if (!meshId) return [];
144
+ // Read coordinator-scoped file first; fall back to legacy shared file.
145
+ const paths = coordinatorDaemonId
146
+ ? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
147
+ : [getPendingEventsPath(meshId)];
148
+ const events: PendingMeshCoordinatorEvent[] = [];
149
+ for (const path of paths) {
150
+ if (!existsSync(path)) continue;
151
+ try {
152
+ const raw = readFileSync(path, 'utf-8');
153
+ const parsed = raw.split('\n').filter(Boolean).flatMap(line => {
154
+ try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
155
+ });
156
+ // If reading the shared file, filter to events that target this coordinator or are unscoped.
157
+ const filtered = (coordinatorDaemonId && path === getPendingEventsPath(meshId))
158
+ ? parsed.filter(e => !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId)
159
+ : parsed;
160
+ events.push(...filtered);
161
+ } catch { /* skip unreadable files */ }
162
+ }
163
+ return events;
164
+ }
165
+
166
+ function refineTerminalEventFromLedger(meshId: string, pending: readonly PendingMeshCoordinatorEvent[]): PendingMeshCoordinatorEvent[] {
167
+ const acceptedJobIds = new Set(
168
+ pending
169
+ .filter(event => event.event === 'refine:accepted')
170
+ .map(event => readRefineJobId(event))
171
+ .filter(Boolean),
172
+ );
173
+ if (acceptedJobIds.size === 0) return [];
174
+ const existingTerminalJobIds = new Set(
175
+ pending
176
+ .filter(event => REFINE_TERMINAL_EVENTS.has(event.event))
177
+ .map(event => `${event.event}:${readRefineJobId(event)}`)
178
+ .filter(value => !value.endsWith(':')),
179
+ );
180
+ const backfilled: PendingMeshCoordinatorEvent[] = [];
181
+ const entries = readLedgerEntries(meshId, { tail: 200 });
182
+ for (let i = entries.length - 1; i >= 0; i--) {
183
+ const entry = entries[i];
184
+ if (entry.kind !== 'task_completed' && entry.kind !== 'task_failed') continue;
185
+ const payload = readRecord(entry.payload);
186
+ if (payload?.source !== 'refine_mesh_node_async_job') continue;
187
+ const refineJob = readRecord(payload.refineJob);
188
+ const jobId = readNonEmptyString(refineJob?.jobId);
189
+ if (!jobId || !acceptedJobIds.has(jobId)) continue;
190
+ const eventName = entry.kind === 'task_completed' ? 'refine:completed' : 'refine:failed';
191
+ if (existingTerminalJobIds.has(`${eventName}:${jobId}`)) continue;
192
+ existingTerminalJobIds.add(`${eventName}:${jobId}`);
193
+ const result = readRecord(payload.result);
194
+ const metadataEvent = {
195
+ source: 'refine_mesh_node_async_job',
196
+ jobId,
197
+ interactionId: readNonEmptyString(refineJob?.interactionId),
198
+ meshId,
199
+ nodeId: readNonEmptyString(refineJob?.nodeId) || entry.nodeId,
200
+ targetDaemonId: readNonEmptyString(refineJob?.targetDaemonId),
201
+ workspace: readNonEmptyString(refineJob?.workspace),
202
+ status: eventName === 'refine:completed' ? 'completed' : 'failed',
203
+ startedAt: readNonEmptyString(refineJob?.startedAt),
204
+ completedAt: readNonEmptyString(refineJob?.completedAt) || entry.timestamp,
205
+ retryOfJobId: readNonEmptyString(refineJob?.retryOfJobId) || readNonEmptyString(payload.retryOfJobId),
206
+ ...(result ? { result } : {}),
207
+ };
208
+ backfilled.push({
209
+ event: eventName,
210
+ meshId,
211
+ nodeLabel: readNonEmptyString(refineJob?.nodeId) || entry.nodeId || 'refine job',
212
+ nodeId: readNonEmptyString(refineJob?.nodeId) || entry.nodeId,
213
+ workspace: readNonEmptyString(refineJob?.workspace),
214
+ metadataEvent,
215
+ coordinatorMessage: buildMeshSystemMessage({
216
+ event: eventName,
217
+ nodeLabel: readNonEmptyString(refineJob?.nodeId) || entry.nodeId || 'refine job',
218
+ metadataEvent,
219
+ }),
220
+ queuedAt: Date.now(),
221
+ });
222
+ }
223
+ return backfilled.reverse();
224
+ }
225
+
226
+ function reconcilePendingMeshCoordinatorEvents(meshId: string, events: PendingMeshCoordinatorEvent[]): PendingMeshCoordinatorEvent[] {
227
+ const backfilled = refineTerminalEventFromLedger(meshId, events);
228
+ if (backfilled.length === 0) return events;
229
+ const terminalJobIds = new Set(backfilled.map(event => readRefineJobId(event)).filter(Boolean));
230
+ return [
231
+ ...events.filter(event => !(event.event === 'refine:accepted' && terminalJobIds.has(readRefineJobId(event)))),
232
+ ...backfilled,
233
+ ];
39
234
  }
40
235
 
41
- const MAX_PENDING_EVENTS = 50;
42
- const pendingMeshCoordinatorEvents: PendingMeshCoordinatorEvent[] = [];
236
+ const MAX_PENDING_EVENTS_BYTES = 100 * 1024; // 100 KB — keep the pending file small
237
+ const MAX_PENDING_EVENTS_KEEP = 50; // keep the last 50 events when trimming
238
+
239
+ function trimPendingEventsIfNeeded(path: string): void {
240
+ try {
241
+ if (!existsSync(path)) return;
242
+ if (statSync(path).size <= MAX_PENDING_EVENTS_BYTES) return;
243
+ const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
244
+ if (lines.length <= MAX_PENDING_EVENTS_KEEP) return;
245
+ writeFileSync(path, lines.slice(-MAX_PENDING_EVENTS_KEEP).join('\n') + '\n', 'utf-8');
246
+ } catch { /* best-effort; if trim fails, append still proceeds */ }
247
+ }
43
248
 
44
249
  export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
45
- if (pendingMeshCoordinatorEvents.length >= MAX_PENDING_EVENTS) {
250
+ try {
251
+ if (hasPendingRefineTerminalEventDuplicate(event)) {
252
+ LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for refine job ${readRefineJobId(event)}`);
253
+ return true;
254
+ }
255
+ if (hasPendingCoordinatorEventDuplicate(event)) {
256
+ LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for mesh ${event.meshId}`);
257
+ return true;
258
+ }
259
+ // Write to the coordinator-scoped file when the target coordinator is known;
260
+ // fall back to the shared file for legacy/unscoped events.
261
+ const path = getPendingEventsPath(event.meshId, event.targetCoordinatorDaemonId);
262
+ trimPendingEventsIfNeeded(path);
263
+ appendFileSync(path, JSON.stringify(event) + '\n', 'utf-8');
264
+ return true;
265
+ } catch (e: any) {
266
+ LOG.warn('MeshEvents', `Failed to persist pending coordinator event: ${e?.message || e}`);
46
267
  return false;
47
268
  }
48
- pendingMeshCoordinatorEvents.push(event);
49
- return true;
50
269
  }
51
270
 
52
- /** Drain and return all pending coordinator events, clearing the queue. */
53
- export function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[] {
54
- return pendingMeshCoordinatorEvents.splice(0);
271
+ // Atomically rename the file before reading so concurrent drains can't both consume
272
+ // the same events. renameSync is atomic on POSIX (same filesystem); only one caller
273
+ // wins the rename — the other gets ENOENT and returns null, preventing duplicate delivery.
274
+ function atomicDrainFile(path: string): string | null {
275
+ const tmpPath = `${path}.draining`;
276
+ try {
277
+ renameSync(path, tmpPath);
278
+ } catch {
279
+ return null; // another drain already renamed it, or file doesn't exist
280
+ }
281
+ try {
282
+ const content = readFileSync(tmpPath, 'utf-8');
283
+ try { unlinkSync(tmpPath); } catch { /* already cleaned up */ }
284
+ return content;
285
+ } catch {
286
+ try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
287
+ return null;
288
+ }
289
+ }
290
+
291
+ /** Drain and return all pending coordinator events for meshId, removing them from disk. */
292
+ export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): PendingMeshCoordinatorEvent[] {
293
+ if (!meshId) return [];
294
+ const paths = coordinatorDaemonId
295
+ ? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
296
+ : [getPendingEventsPath(meshId)];
297
+ const all: PendingMeshCoordinatorEvent[] = [];
298
+ for (const path of paths) {
299
+ const content = atomicDrainFile(path);
300
+ if (!content) continue;
301
+ const parsed = content.split('\n').filter(Boolean).flatMap(line => {
302
+ try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
303
+ });
304
+ // If reading the shared file, filter to events that target this coordinator or are unscoped.
305
+ const filtered = (coordinatorDaemonId && path === getPendingEventsPath(meshId))
306
+ ? parsed.filter(e => !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId)
307
+ : parsed;
308
+ all.push(...filtered);
309
+ }
310
+ if (all.length === 0) return [];
311
+ return reconcilePendingMeshCoordinatorEvents(meshId, all);
55
312
  }
56
313
 
57
314
  /** Peek at pending coordinator events without draining (non-destructive). */
58
- export function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[] {
59
- return pendingMeshCoordinatorEvents.slice();
315
+ export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): readonly PendingMeshCoordinatorEvent[] {
316
+ if (!meshId) return [];
317
+ return reconcilePendingMeshCoordinatorEvents(meshId, readPendingMeshCoordinatorEventsFromDisk(meshId, coordinatorDaemonId));
60
318
  }
61
319
 
62
- /** Explicitly clear all pending coordinator events. */
63
- export function clearPendingMeshCoordinatorEvents(): void {
64
- pendingMeshCoordinatorEvents.splice(0);
320
+ /** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
321
+ export function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void {
322
+ if (!meshId) return;
323
+ const paths = coordinatorDaemonId
324
+ ? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
325
+ : [getPendingEventsPath(meshId)];
326
+ for (const path of paths) {
327
+ if (existsSync(path)) try { unlinkSync(path); } catch { /* already removed */ }
328
+ }
65
329
  }
66
330
 
67
331
  function readNonEmptyString(value: unknown): string {
68
332
  return typeof value === 'string' && value.trim() ? value.trim() : '';
69
333
  }
70
334
 
335
+ function readRecord(value: unknown): Record<string, unknown> | undefined {
336
+ return value && typeof value === 'object' && !Array.isArray(value)
337
+ ? value as Record<string, unknown>
338
+ : undefined;
339
+ }
340
+
71
341
  function resolveEventSessionId(event: Record<string, unknown>, fallback?: unknown): string {
72
342
  return readNonEmptyString(event.targetSessionId)
73
343
  || readNonEmptyString(event.sessionId)
@@ -82,6 +352,9 @@ const MESH_COORDINATOR_EVENTS = new Set([
82
352
  'agent:stopped',
83
353
  'agent:ready',
84
354
  'monitor:long_generating',
355
+ 'refine:accepted',
356
+ 'refine:completed',
357
+ 'refine:failed',
85
358
  ]);
86
359
 
87
360
  const EVENT_TO_LEDGER_KIND: Record<string, MeshLedgerKind> = {
@@ -96,10 +369,21 @@ function isMeshCoordinatorEvent(eventName: unknown): eventName is string {
96
369
  }
97
370
 
98
371
  function formatCompletionMetadata(event: Record<string, unknown>): string {
372
+ const completionDiagnostic = event.completionDiagnostic && typeof event.completionDiagnostic === 'object'
373
+ ? event.completionDiagnostic as Record<string, unknown>
374
+ : null;
375
+ const diagnosticReason = completionDiagnostic
376
+ ? readNonEmptyString(completionDiagnostic.blockReason) || 'present'
377
+ : '';
378
+ const finalAssistantPresent = typeof completionDiagnostic?.finalAssistantPresent === 'boolean'
379
+ ? String(completionDiagnostic.finalAssistantPresent)
380
+ : '';
99
381
  const parts = [
100
382
  readNonEmptyString(event.targetSessionId) ? `session_id=${readNonEmptyString(event.targetSessionId)}` : '',
101
383
  readNonEmptyString(event.providerType) ? `provider=${readNonEmptyString(event.providerType)}` : '',
102
384
  readNonEmptyString(event.providerSessionId) ? `provider_session_id=${readNonEmptyString(event.providerSessionId)}` : '',
385
+ diagnosticReason ? `completion_diagnostic=${diagnosticReason}` : '',
386
+ finalAssistantPresent ? `final_assistant=${finalAssistantPresent}` : '',
103
387
  ].filter(Boolean);
104
388
  return parts.length > 0 ? ` (${parts.join('; ')})` : '';
105
389
  }
@@ -126,7 +410,7 @@ function isIntentionalCleanupStopMetadata(event: Record<string, unknown>): boole
126
410
  function hasRecentIntentionalCleanupStop(meshId: string, sessionId?: string, nodeId?: string): boolean {
127
411
  if (!sessionId && !nodeId) return false;
128
412
  const cutoff = Date.now() - INTENTIONAL_CLEANUP_STOP_SUPPRESSION_MS;
129
- const entries = readLedgerEntries(meshId);
413
+ const entries = readLedgerEntries(meshId, { tail: 200 });
130
414
  for (let i = entries.length - 1; i >= 0; i--) {
131
415
  const entry = entries[i];
132
416
  const timestamp = new Date(entry.timestamp).getTime();
@@ -150,6 +434,386 @@ function shouldSuppressIntentionalCleanupStop(args: {
150
434
  return hasRecentIntentionalCleanupStop(args.meshId, args.sessionId, args.nodeId);
151
435
  }
152
436
 
437
+ const RECENT_COMPLETION_FINGERPRINT_TTL_MS = 10 * 60 * 1000;
438
+
439
+ function hasFingerprintSeen(fingerprint: string): boolean {
440
+ try {
441
+ return BeadsDB.getInstance().hasCompletionFingerprint(fingerprint);
442
+ } catch {
443
+ return false;
444
+ }
445
+ }
446
+
447
+ function recordFingerprintSeen(fingerprint: string): void {
448
+ try {
449
+ const db = BeadsDB.getInstance();
450
+ db.recordCompletionFingerprint(fingerprint, RECENT_COMPLETION_FINGERPRINT_TTL_MS);
451
+ db.sweepExpiredFingerprints();
452
+ } catch { /* best-effort; duplicate events are preferable to a crash */ }
453
+ }
454
+
455
+ function readEventTimestamp(value: unknown): number | null {
456
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
457
+ if (typeof value === 'string' && value.trim()) {
458
+ const numeric = Number(value);
459
+ if (Number.isFinite(numeric)) return numeric;
460
+ const parsed = Date.parse(value);
461
+ if (Number.isFinite(parsed)) return parsed;
462
+ }
463
+ return null;
464
+ }
465
+
466
+ function buildMeshCompletionFingerprint(args: {
467
+ meshId: string;
468
+ event: string;
469
+ sessionId: string;
470
+ providerType?: string;
471
+ providerSessionId?: string;
472
+ timestamp?: number | null;
473
+ finalSummary?: string;
474
+ /** When set, scopes the fingerprint to a specific coordinator daemon so
475
+ * two coordinators processing events from their respective workers don't
476
+ * suppress each other's completion events. */
477
+ coordinatorDaemonId?: string;
478
+ }): string {
479
+ const timestampPart = Number.isFinite(args.timestamp)
480
+ ? String(args.timestamp)
481
+ : readNonEmptyString(args.finalSummary).slice(0, 200);
482
+ return [
483
+ args.meshId,
484
+ args.event,
485
+ args.sessionId,
486
+ args.providerType || '',
487
+ args.providerSessionId || '',
488
+ timestampPart,
489
+ args.coordinatorDaemonId || '',
490
+ ].join('::');
491
+ }
492
+
493
+ function isDuplicateMeshCompletionEvent(args: {
494
+ meshId: string;
495
+ event: string;
496
+ sessionId: string;
497
+ providerType?: string;
498
+ providerSessionId?: string;
499
+ timestamp?: number | null;
500
+ finalSummary?: string;
501
+ coordinatorDaemonId?: string;
502
+ }): boolean {
503
+ const fingerprint = buildMeshCompletionFingerprint(args);
504
+ if (!fingerprint) return false;
505
+ if (hasFingerprintSeen(fingerprint)) return true;
506
+ recordFingerprintSeen(fingerprint);
507
+ return false;
508
+ }
509
+
510
+ function isDuplicateMeshApprovalEvent(args: {
511
+ meshId: string;
512
+ sessionId: string;
513
+ providerType?: string;
514
+ timestamp?: number | null;
515
+ modalMessage?: string;
516
+ modalButtons?: unknown;
517
+ }): boolean {
518
+ const modalButtons = Array.isArray(args.modalButtons)
519
+ ? args.modalButtons.map(button => String(button).trim()).filter(Boolean)
520
+ : [];
521
+ const approvalIdentity = Number.isFinite(args.timestamp)
522
+ ? String(args.timestamp)
523
+ : JSON.stringify({ message: args.modalMessage || '', buttons: modalButtons });
524
+ if (!approvalIdentity || approvalIdentity === '{"message":"","buttons":[]}') return false;
525
+ const fingerprint = [
526
+ args.meshId,
527
+ 'agent:waiting_approval',
528
+ args.sessionId,
529
+ args.providerType || '',
530
+ approvalIdentity,
531
+ ].join('::');
532
+ if (hasFingerprintSeen(fingerprint)) return true;
533
+ recordFingerprintSeen(fingerprint);
534
+ return false;
535
+ }
536
+
537
+ function isDuplicateRefineTerminalEvent(meshId: string, eventName: string, metadataEvent: Record<string, unknown>): boolean {
538
+ const fingerprint = buildRefineTerminalEventFingerprint(meshId, eventName, metadataEvent);
539
+ if (!fingerprint) return false;
540
+ if (hasFingerprintSeen(fingerprint)) return true;
541
+ recordFingerprintSeen(fingerprint);
542
+ return false;
543
+ }
544
+
545
+ function findRecentTerminalLedgerEvidence(args: {
546
+ meshId: string;
547
+ sessionId?: string;
548
+ nodeId?: string;
549
+ }): { id: string; kind: MeshLedgerKind; payload: Record<string, unknown>; timestamp: string } | null {
550
+ if (!args.sessionId && !args.nodeId) return null;
551
+ // Tail-limit: 200 entries gives a wide enough window to catch terminal events for active
552
+ // sessions while avoiding a full O(n) scan. If a terminal is older than 200 entries,
553
+ // the BeadsDB fingerprint dedup will still block duplicate processing downstream.
554
+ const entries = readLedgerEntries(args.meshId, { tail: 200 });
555
+ for (let i = entries.length - 1; i >= 0; i--) {
556
+ const entry = entries[i];
557
+ if (entry.kind !== 'task_completed' && entry.kind !== 'task_failed' && entry.kind !== 'task_stalled') continue;
558
+ if (args.sessionId && entry.sessionId === args.sessionId) {
559
+ return { id: entry.id, kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
560
+ }
561
+ if (!args.sessionId && args.nodeId && entry.nodeId === args.nodeId) {
562
+ return { id: entry.id, kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
563
+ }
564
+ }
565
+ return null;
566
+ }
567
+
568
+ // Returns true when a task_dispatched entry for the given session appears AFTER the terminal
569
+ // entry (identified by terminalId) in ledger order. Positional (append) order is used rather
570
+ // than timestamp comparison because both entries may share the same millisecond.
571
+ function hasDispatchAfterTerminal(meshId: string, sessionId: string, terminalId: string): boolean {
572
+ // 200-entry window matches findRecentTerminalLedgerEvidence for consistency.
573
+ const entries = readLedgerEntries(meshId, { tail: 200 });
574
+ let pastTerminal = false;
575
+ for (const entry of entries) {
576
+ if (!pastTerminal) {
577
+ if (entry.id === terminalId) pastTerminal = true;
578
+ continue;
579
+ }
580
+ if (entry.kind === 'task_dispatched' && entry.sessionId === sessionId) return true;
581
+ }
582
+ return false;
583
+ }
584
+
585
+ function hasUnterminalDirectDispatchLedgerEntry(meshId: string, sessionId: string): boolean {
586
+ // Some dispatch paths can persist task_dispatched before the direct-dispatch DB row is
587
+ // available. Recover routing from ledger order so coordinator self-targets still emit
588
+ // task_completed and pendingCoordinatorEvents.
589
+ const entries = readLedgerEntries(meshId, { tail: 200 });
590
+ for (let i = entries.length - 1; i >= 0; i--) {
591
+ const entry = entries[i];
592
+ if (entry.sessionId !== sessionId) continue;
593
+ if (entry.kind === 'task_completed' || entry.kind === 'task_failed' || entry.kind === 'task_stalled') {
594
+ return false;
595
+ }
596
+ if (entry.kind === 'task_dispatched' && entry.payload?.source === 'direct') {
597
+ return true;
598
+ }
599
+ }
600
+ return false;
601
+ }
602
+
603
+ function findDirectDispatchLedgerEntry(args: {
604
+ meshId: string;
605
+ taskId: string;
606
+ sessionId?: string;
607
+ }): { id: string; timestamp: string; nodeId?: string; sessionId?: string; providerType?: string; payload: Record<string, unknown> } | null {
608
+ const entries = readLedgerEntries(args.meshId, { tail: 500 });
609
+ for (let i = entries.length - 1; i >= 0; i--) {
610
+ const entry = entries[i];
611
+ if (entry.kind !== 'task_dispatched') continue;
612
+ const payloadTaskId = readNonEmptyString(entry.payload?.taskId);
613
+ if (payloadTaskId !== args.taskId) continue;
614
+ if (args.sessionId && entry.sessionId && entry.sessionId !== args.sessionId) continue;
615
+ return {
616
+ id: entry.id,
617
+ timestamp: entry.timestamp,
618
+ nodeId: entry.nodeId,
619
+ sessionId: entry.sessionId,
620
+ providerType: entry.providerType,
621
+ payload: entry.payload || {},
622
+ };
623
+ }
624
+ return null;
625
+ }
626
+
627
+ function hasTerminalLedgerAfterDispatch(args: {
628
+ meshId: string;
629
+ taskId: string;
630
+ sessionId?: string;
631
+ dispatchEntryId?: string;
632
+ dispatchTimestamp?: string;
633
+ }): boolean {
634
+ const entries = readLedgerEntries(args.meshId, { tail: 500 });
635
+ let afterDispatch = !args.dispatchEntryId && !args.dispatchTimestamp;
636
+ const dispatchTime = args.dispatchTimestamp ? new Date(args.dispatchTimestamp).getTime() : Number.NaN;
637
+ for (const entry of entries) {
638
+ if (!afterDispatch) {
639
+ if (args.dispatchEntryId && entry.id === args.dispatchEntryId) {
640
+ afterDispatch = true;
641
+ continue;
642
+ }
643
+ if (!args.dispatchEntryId && Number.isFinite(dispatchTime)) {
644
+ const entryTime = new Date(entry.timestamp).getTime();
645
+ if (Number.isFinite(entryTime) && entryTime >= dispatchTime) afterDispatch = true;
646
+ }
647
+ if (!afterDispatch) continue;
648
+ }
649
+ if (entry.kind !== 'task_completed' && entry.kind !== 'task_failed' && entry.kind !== 'task_stalled') continue;
650
+ const terminalTaskId = readNonEmptyString(entry.payload?.taskId);
651
+ if (terminalTaskId && terminalTaskId === args.taskId) return true;
652
+ if (terminalTaskId && terminalTaskId !== args.taskId) continue;
653
+ if (args.sessionId && entry.sessionId === args.sessionId) return true;
654
+ }
655
+ return false;
656
+ }
657
+
658
+ export function reconcileDirectDispatchCompletionFromTranscript(args: {
659
+ meshId: string;
660
+ nodeId?: string;
661
+ sessionId: string;
662
+ providerType?: string;
663
+ providerSessionId?: string;
664
+ taskId: string;
665
+ finalSummary: string;
666
+ transcriptMessageAt?: string;
667
+ completedAt?: string;
668
+ targetCoordinatorDaemonId?: string;
669
+ source?: string;
670
+ }): { reconciled: boolean; kind?: MeshLedgerKind; alreadyTerminal?: boolean; workerResult?: unknown; ledgerEntryId?: string; reason?: string } {
671
+ const finalSummary = readNonEmptyString(args.finalSummary);
672
+ if (!args.meshId || !args.taskId || !args.sessionId || !finalSummary) {
673
+ return { reconciled: false, reason: 'missing_required_completion_evidence' };
674
+ }
675
+
676
+ const dispatch = findDirectDispatchLedgerEntry({
677
+ meshId: args.meshId,
678
+ taskId: args.taskId,
679
+ sessionId: args.sessionId,
680
+ });
681
+ if (hasTerminalLedgerAfterDispatch({
682
+ meshId: args.meshId,
683
+ taskId: args.taskId,
684
+ sessionId: args.sessionId,
685
+ dispatchEntryId: dispatch?.id,
686
+ dispatchTimestamp: dispatch?.timestamp,
687
+ })) {
688
+ return { reconciled: false, alreadyTerminal: true, reason: 'terminal_ledger_entry_exists' };
689
+ }
690
+
691
+ const nodeId = readNonEmptyString(args.nodeId) || dispatch?.nodeId;
692
+ const providerType = readNonEmptyString(args.providerType) || dispatch?.providerType || readNonEmptyString(dispatch?.payload.providerType);
693
+ const completedAt = args.completedAt || new Date().toISOString();
694
+ const evidence = buildTaskCompletionEvidence({
695
+ event: 'agent:generating_completed',
696
+ nodeId: nodeId || 'unknown',
697
+ sessionId: args.sessionId,
698
+ providerType,
699
+ providerSessionId: readNonEmptyString(args.providerSessionId),
700
+ finalSummary,
701
+ completedAt,
702
+ });
703
+ const workerResult = evidence.workerResult;
704
+ const dispatchTime = dispatch?.timestamp ? new Date(dispatch.timestamp).getTime() : Number.NaN;
705
+ const transcriptTime = args.transcriptMessageAt ? new Date(args.transcriptMessageAt).getTime() : Number.NaN;
706
+ const transcriptAfterDispatch = Number.isFinite(dispatchTime) && Number.isFinite(transcriptTime) && transcriptTime >= dispatchTime;
707
+ if (workerResult.source !== 'final_summary_json' && !transcriptAfterDispatch) {
708
+ return { reconciled: false, reason: 'transcript_not_proven_after_dispatch' };
709
+ }
710
+ const workerFailed = workerResult.status === 'failed' || (workerResult.status !== 'completed' && workerResult.errors.length > 0);
711
+ const kind: MeshLedgerKind = workerFailed ? 'task_failed' : 'task_completed';
712
+
713
+ const entry = appendLedgerEntry(args.meshId, {
714
+ kind,
715
+ nodeId: nodeId || undefined,
716
+ sessionId: args.sessionId,
717
+ providerType: providerType || undefined,
718
+ payload: {
719
+ event: 'agent:generating_completed',
720
+ source: args.source || 'direct_task_transcript_reconciliation',
721
+ taskId: args.taskId,
722
+ providerSessionId: readNonEmptyString(args.providerSessionId),
723
+ finalSummary,
724
+ workerResult,
725
+ completionDiagnostic: {
726
+ reason: 'direct_task_transcript_reconciliation',
727
+ dispatchEntryId: dispatch?.id,
728
+ dispatchTimestamp: dispatch?.timestamp,
729
+ transcriptMessageAt: readNonEmptyString(args.transcriptMessageAt),
730
+ transcriptFinalAssistantPresent: true,
731
+ },
732
+ evidence,
733
+ },
734
+ });
735
+ updateDirectDispatchStatus(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed');
736
+ setImmediate(() => cleanupTerminalDirectDispatches());
737
+ queuePendingMeshCoordinatorEvent({
738
+ event: kind === 'task_completed' ? 'agent:generating_completed' : 'agent:stopped',
739
+ meshId: args.meshId,
740
+ nodeLabel: nodeId ? `Node '${nodeId}'` : 'Remote agent',
741
+ nodeId: nodeId || undefined,
742
+ metadataEvent: {
743
+ targetSessionId: args.sessionId,
744
+ providerType: providerType || undefined,
745
+ providerSessionId: readNonEmptyString(args.providerSessionId),
746
+ finalSummary,
747
+ taskId: args.taskId,
748
+ workerResult,
749
+ completionDiagnostic: {
750
+ reason: 'direct_task_transcript_reconciliation',
751
+ terminalLedgerKind: kind,
752
+ terminalLedgerId: entry.id,
753
+ },
754
+ },
755
+ coordinatorMessage: undefined,
756
+ queuedAt: Date.now(),
757
+ ...(readNonEmptyString(args.targetCoordinatorDaemonId) ? { targetCoordinatorDaemonId: readNonEmptyString(args.targetCoordinatorDaemonId) } : {}),
758
+ });
759
+
760
+ return { reconciled: true, kind, workerResult, ledgerEntryId: entry.id };
761
+ }
762
+
763
+ function buildLongGeneratingCompletionReconciliation(args: {
764
+ meshId: string;
765
+ nodeId?: string;
766
+ nodeLabel: string;
767
+ metadataEvent: Record<string, unknown>;
768
+ sourceInstanceId?: string;
769
+ }): Record<string, unknown> | null {
770
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
771
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
772
+ const providerType = readNonEmptyString(args.metadataEvent.providerType);
773
+ const providerSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
774
+ const workerResult = readWorkerResultMetadata(args.metadataEvent);
775
+ const completionDiagnostic = readRecord(args.metadataEvent.completionDiagnostic);
776
+ const finalSummary = readNonEmptyString(args.metadataEvent.finalSummary);
777
+ const status = readNonEmptyString(args.metadataEvent.status).toLowerCase();
778
+ const explicitCompletionEvidence = Boolean(
779
+ finalSummary
780
+ || workerResult
781
+ || completionDiagnostic?.finalAssistantPresent === true
782
+ || status === 'idle'
783
+ || status === 'ready'
784
+ || status === 'completed',
785
+ );
786
+ if (explicitCompletionEvidence) {
787
+ return {
788
+ ...args.metadataEvent,
789
+ targetSessionId: sessionId,
790
+ providerType,
791
+ providerSessionId,
792
+ finalSummary,
793
+ source: 'long_generating_reconciliation',
794
+ reconciledFromEvent: 'monitor:long_generating',
795
+ timestamp: args.metadataEvent.timestamp ?? Date.now(),
796
+ completionDiagnostic: {
797
+ ...(completionDiagnostic || {}),
798
+ reconciliationReason: 'provider_completion_evidence',
799
+ },
800
+ };
801
+ }
802
+
803
+ const terminal = findRecentTerminalLedgerEvidence({
804
+ meshId: args.meshId,
805
+ sessionId: sessionId || undefined,
806
+ nodeId: nodeId || undefined,
807
+ });
808
+ if (!terminal) return null;
809
+ return {
810
+ ...args.metadataEvent,
811
+ source: 'long_generating_terminal_ledger_suppression',
812
+ terminalLedgerKind: terminal.kind,
813
+ terminalLedgerAt: terminal.timestamp,
814
+ };
815
+ }
816
+
153
817
 
154
818
  export function tryAssignQueueTask(
155
819
  components: DaemonComponents,
@@ -158,7 +822,10 @@ export function tryAssignQueueTask(
158
822
  sessionId: string,
159
823
  providerType: string
160
824
  ): boolean {
161
- const task = claimNextTask(meshId, nodeId, sessionId);
825
+ const mesh = getMeshWithCache(components, meshId);
826
+ const node = mesh?.nodes.find((n: any) => n.id === nodeId);
827
+ const capabilityTags = buildMeshNodeCapabilityTags(node, providerType);
828
+ const task = claimNextTask(meshId, nodeId, sessionId, capabilityTags);
162
829
  if (!task) {
163
830
  return false;
164
831
  }
@@ -166,9 +833,6 @@ export function tryAssignQueueTask(
166
833
  LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
167
834
 
168
835
  // Check if the node is remote
169
- const mesh = getMeshWithCache(components, meshId);
170
- const node = mesh?.nodes.find((n: any) => n.id === nodeId);
171
-
172
836
  // If the node is explicitly remote and we have a dispatch mechanism, route via P2P
173
837
  if (node?.daemonId && components.dispatchMeshCommand) {
174
838
  const isLocalNode = components.cliManager.adapters.has(sessionId);
@@ -180,7 +844,16 @@ export function tryAssignQueueTask(
180
844
  message: task.message,
181
845
  }).catch((e: any) => {
182
846
  LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
183
- updateTaskStatus(meshId, task.id, 'failed');
847
+ // Revert to pending so the task can be retried rather than permanently failing
848
+ updateTaskStatus(meshId, task.id, 'pending');
849
+ try {
850
+ appendLedgerEntry(meshId, {
851
+ kind: 'dispatch_failed' as any,
852
+ nodeId,
853
+ sessionId,
854
+ payload: { taskId: task.id, error: e?.message, retryable: true },
855
+ });
856
+ } catch { /* ledger write is best-effort */ }
184
857
  });
185
858
  return true;
186
859
  }
@@ -204,6 +877,13 @@ const autoLaunchInProgress = new Set<string>();
204
877
  const autoLaunchCooldownUntil = new Map<string, number>();
205
878
  const AUTO_LAUNCH_COOLDOWN_MS = 5_000;
206
879
 
880
+ function sweepExpiredCooldowns(): void {
881
+ const now = Date.now();
882
+ for (const [key, until] of autoLaunchCooldownUntil) {
883
+ if (now >= until) autoLaunchCooldownUntil.delete(key);
884
+ }
885
+ }
886
+
207
887
  function normalizeProviderPriority(policy: unknown): string[] {
208
888
  const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
209
889
  ? (policy as Record<string, unknown>).providerPriority
@@ -277,6 +957,10 @@ function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
277
957
  return getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedNodeId === nodeId);
278
958
  }
279
959
 
960
+ function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
961
+ return getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId);
962
+ }
963
+
280
964
  function liveSessionCountForNode(components: DaemonComponents, meshId: string, nodeId: string): number {
281
965
  return components.instanceManager.getByCategory('cli').filter((inst: any) => {
282
966
  const state = inst.getState();
@@ -406,12 +1090,14 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
406
1090
  const nodeId = readNonEmptyString(node?.id);
407
1091
  if (!nodeId) continue;
408
1092
  const launchKey = `${meshId}:${nodeId}`;
1093
+ const now = Date.now();
409
1094
  const cooldownUntil = autoLaunchCooldownUntil.get(launchKey) || 0;
1095
+ if (cooldownUntil > 0 && now >= cooldownUntil) autoLaunchCooldownUntil.delete(launchKey);
410
1096
  if (autoLaunchInProgress.has(launchKey)) {
411
1097
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'auto_launch_in_progress', nodeId });
412
1098
  continue;
413
1099
  }
414
- if (Date.now() < cooldownUntil) {
1100
+ if (now < cooldownUntil) {
415
1101
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'auto_launch_cooldown', nodeId });
416
1102
  continue;
417
1103
  }
@@ -461,13 +1147,13 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
461
1147
  if (!launchResult?.success) {
462
1148
  const reason = launchResult?.error || 'launch_cli_failed';
463
1149
  markAutoLaunch(meshId, task.id, { status: 'failed', reason, nodeId, providerType: resolved.providerType });
464
- autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS);
1150
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
465
1151
  return false;
466
1152
  }
467
1153
  const sessionId = readNonEmptyString(launchResult.sessionId) || readNonEmptyString(launchResult.id) || readNonEmptyString(launchResult.runtimeSessionId);
468
1154
  if (!sessionId) {
469
1155
  markAutoLaunch(meshId, task.id, { status: 'failed', reason: 'launch_missing_session_id', nodeId, providerType: resolved.providerType });
470
- autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS);
1156
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
471
1157
  return false;
472
1158
  }
473
1159
  markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId });
@@ -485,13 +1171,69 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
485
1171
  return false;
486
1172
  }
487
1173
 
1174
+ export interface MeshQueueTriggerResult {
1175
+ success: true;
1176
+ meshId: string;
1177
+ pendingBefore: number;
1178
+ assignedBefore: number;
1179
+ pendingAfter: number;
1180
+ assignedAfter: number;
1181
+ claimed: boolean;
1182
+ newlyAssignedTasks: Array<{
1183
+ id: string;
1184
+ nodeId?: string;
1185
+ sessionId?: string;
1186
+ }>;
1187
+ localIdleSessionsChecked: number;
1188
+ remoteIdleSessionsChecked: number;
1189
+ skippedSessions: Array<{
1190
+ nodeId?: string;
1191
+ sessionId?: string;
1192
+ reason: string;
1193
+ status?: string;
1194
+ }>;
1195
+ autoLaunchStarted: boolean;
1196
+ noIdleMeshSessionAvailable?: boolean;
1197
+ }
1198
+
1199
+ function countQueueStatus(meshId: string, status: 'pending' | 'assigned'): number {
1200
+ return getQueue(meshId, { status: [status] as any }).length;
1201
+ }
1202
+
1203
+ function getQueueStatusById(meshId: string): Map<string, string> {
1204
+ return new Map(getQueue(meshId).map(task => [task.id, task.status]));
1205
+ }
1206
+
488
1207
  /**
489
1208
  * Triggers a queue check for all nodes in the mesh.
490
1209
  * Called when a new task is enqueued, in case nodes are already idle.
491
1210
  */
492
- export async function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<void> {
1211
+ export async function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<MeshQueueTriggerResult> {
493
1212
  const mesh = getMeshWithCache(components, meshId);
494
- if (!mesh) return;
1213
+ const pendingBefore = countQueueStatus(meshId, 'pending');
1214
+ const assignedBefore = countQueueStatus(meshId, 'assigned');
1215
+ const beforeStatus = getQueueStatusById(meshId);
1216
+ const skippedSessions: MeshQueueTriggerResult['skippedSessions'] = [];
1217
+ let localIdleSessionsChecked = 0;
1218
+ let remoteIdleSessionsChecked = 0;
1219
+ let autoLaunchStarted = false;
1220
+ if (!mesh) {
1221
+ return {
1222
+ success: true,
1223
+ meshId,
1224
+ pendingBefore,
1225
+ assignedBefore,
1226
+ pendingAfter: pendingBefore,
1227
+ assignedAfter: assignedBefore,
1228
+ claimed: false,
1229
+ newlyAssignedTasks: [],
1230
+ localIdleSessionsChecked,
1231
+ remoteIdleSessionsChecked,
1232
+ skippedSessions: [{ reason: 'mesh_not_found' }],
1233
+ autoLaunchStarted,
1234
+ noIdleMeshSessionAvailable: true,
1235
+ };
1236
+ }
495
1237
 
496
1238
  // Find all CLI instances that belong to this mesh and are idle
497
1239
  const cliInstances = components.instanceManager.getByCategory('cli');
@@ -508,30 +1250,147 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
508
1250
  // Only genuinely idle live sessions can pull work. Restored/stopped
509
1251
  // records are kept for transcript/recovery visibility, but assigning
510
1252
  // queue items to them strands tasks in assigned/pending without chat.
511
- if (!isIdleSessionState(state)) continue;
1253
+ if (!isIdleSessionState(state)) {
1254
+ const status = readNonEmptyString(state.status).toLowerCase();
1255
+ skippedSessions.push({
1256
+ nodeId,
1257
+ sessionId: readNonEmptyString(state.instanceId),
1258
+ reason: isTerminalSessionStatus(status) ? 'terminal_session' : 'session_not_idle',
1259
+ status: status || undefined,
1260
+ });
1261
+ continue;
1262
+ }
512
1263
 
513
1264
  const sessionId = state.instanceId;
514
1265
  const providerType = state.type || readNonEmptyString(settings.providerType);
515
1266
 
516
1267
  if (providerType) {
517
1268
  // Try to assign a task to this idle node
1269
+ localIdleSessionsChecked += 1;
518
1270
  tryAssignQueueTask(components, meshId, nodeId, sessionId, providerType);
1271
+ } else {
1272
+ skippedSessions.push({
1273
+ nodeId,
1274
+ sessionId,
1275
+ reason: 'provider_type_missing',
1276
+ });
519
1277
  }
520
1278
  }
521
1279
 
522
1280
  // Also check known idle remote sessions
523
- for (const [key, idle] of remoteIdleSessions.entries()) {
1281
+ let remoteSessions: Array<{ nodeId: string; sessionId: string; providerType: string }> = [];
1282
+ try {
1283
+ remoteSessions = BeadsDB.getInstance().getRemoteIdleSessions();
1284
+ } catch { /* best-effort */ }
1285
+
1286
+ for (const idle of remoteSessions) {
524
1287
  // Find if this node is in the same mesh
525
1288
  const node = mesh.nodes.find((n: any) => n.id === idle.nodeId);
526
1289
  if (node) {
1290
+ remoteIdleSessionsChecked += 1;
527
1291
  const assigned = tryAssignQueueTask(components, meshId, idle.nodeId, idle.sessionId, idle.providerType);
528
1292
  if (assigned) {
529
- remoteIdleSessions.delete(key);
1293
+ try {
1294
+ BeadsDB.getInstance().deleteRemoteIdleSession(idle.nodeId, idle.sessionId);
1295
+ } catch { /* best-effort */ }
530
1296
  }
531
1297
  }
532
1298
  }
533
1299
 
534
- await maybeAutoLaunchOneQueueSession(components, meshId, mesh);
1300
+ autoLaunchStarted = await maybeAutoLaunchOneQueueSession(components, meshId, mesh);
1301
+ const afterQueue = getQueue(meshId);
1302
+ const pendingAfter = afterQueue.filter(task => task.status === 'pending').length;
1303
+ const assignedAfter = afterQueue.filter(task => task.status === 'assigned').length;
1304
+ const newlyAssignedTasks = afterQueue
1305
+ .filter(task => task.status === 'assigned' && beforeStatus.get(task.id) !== 'assigned')
1306
+ .map(task => ({
1307
+ id: task.id,
1308
+ nodeId: task.assignedNodeId,
1309
+ sessionId: task.assignedSessionId,
1310
+ }));
1311
+ return {
1312
+ success: true,
1313
+ meshId,
1314
+ pendingBefore,
1315
+ assignedBefore,
1316
+ pendingAfter,
1317
+ assignedAfter,
1318
+ claimed: newlyAssignedTasks.length > 0,
1319
+ newlyAssignedTasks,
1320
+ localIdleSessionsChecked,
1321
+ remoteIdleSessionsChecked,
1322
+ skippedSessions,
1323
+ autoLaunchStarted,
1324
+ ...(pendingAfter > 0 && newlyAssignedTasks.length === 0 && localIdleSessionsChecked === 0 && remoteIdleSessionsChecked === 0 && !autoLaunchStarted
1325
+ ? { noIdleMeshSessionAvailable: true }
1326
+ : {}),
1327
+ };
1328
+ }
1329
+
1330
+ async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args: {
1331
+ meshId: string;
1332
+ nodeId: string;
1333
+ sessionId?: string;
1334
+ providerType?: string;
1335
+ }): Promise<void> {
1336
+ const mesh = getMeshWithCache(components, args.meshId);
1337
+ const node = mesh?.nodes?.find((candidate: any) => candidate?.id === args.nodeId || candidate?.nodeId === args.nodeId);
1338
+ const workspace = readNonEmptyString(node?.workspace);
1339
+ if (!workspace) return;
1340
+ if (!existsSync(workspace)) return;
1341
+
1342
+ const throttleKey = `${args.meshId}:${args.nodeId}`;
1343
+ const now = Date.now();
1344
+ const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
1345
+ if (now - lastAttempt < IDLE_AUTO_FAST_FORWARD_THROTTLE_MS) return;
1346
+ idleAutoFastForwardLastAttempt.set(throttleKey, now);
1347
+
1348
+ const submoduleIgnorePaths = Array.isArray(node?.policy?.submoduleIgnorePaths)
1349
+ ? node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
1350
+ : undefined;
1351
+ try {
1352
+ const dryRun = await fastForwardMeshNode({
1353
+ meshId: args.meshId,
1354
+ nodeId: args.nodeId,
1355
+ workspace,
1356
+ execute: false,
1357
+ dryRun: true,
1358
+ updateSubmodules: false,
1359
+ submoduleIgnorePaths,
1360
+ trigger: 'idle_auto',
1361
+ });
1362
+ if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return;
1363
+ await fastForwardMeshNode({
1364
+ meshId: args.meshId,
1365
+ nodeId: args.nodeId,
1366
+ workspace,
1367
+ execute: true,
1368
+ dryRun: false,
1369
+ updateSubmodules: false,
1370
+ submoduleIgnorePaths,
1371
+ trigger: 'idle_auto',
1372
+ });
1373
+ } catch (e: any) {
1374
+ LOG.warn('MeshFastForward', `Idle auto fast-forward check failed for ${args.nodeId}: ${e?.message || e}`);
1375
+ }
1376
+ }
1377
+
1378
+ function runIdleMaintenanceThenAssignQueue(components: DaemonComponents, args: {
1379
+ meshId: string;
1380
+ nodeId: string;
1381
+ sessionId: string;
1382
+ providerType: string;
1383
+ }): void {
1384
+ setImmediate(() => {
1385
+ maybeAutoFastForwardIdleNode(components, args)
1386
+ .finally(() => {
1387
+ try {
1388
+ tryAssignQueueTask(components, args.meshId, args.nodeId, args.sessionId, args.providerType);
1389
+ } catch (e: any) {
1390
+ LOG.warn('MeshQueue', `Failed to assign idle queue task after maintenance for ${args.nodeId}: ${e?.message || e}`);
1391
+ }
1392
+ });
1393
+ });
535
1394
  }
536
1395
 
537
1396
  function buildMeshSystemMessage(args: {
@@ -542,6 +1401,9 @@ function buildMeshSystemMessage(args: {
542
1401
  }): string {
543
1402
  const metadata = formatCompletionMetadata(args.metadataEvent);
544
1403
  if (args.event === 'agent:generating_completed') {
1404
+ if (args.metadataEvent.source === 'long_generating_reconciliation') {
1405
+ return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The long-generating monitor reconciled the terminal handoff and marked the session complete; wait for the queued completion event/status refresh before doing any manual transcript check.`;
1406
+ }
545
1407
  return `[System] ${args.nodeLabel} has completed its task and is now idle${metadata}. This completion came from the agent status event path; use mesh_read_chat once to review its final progress, but do not poll repeatedly.`;
546
1408
  }
547
1409
  if (args.event === 'agent:waiting_approval') {
@@ -573,7 +1435,72 @@ function buildMeshSystemMessage(args: {
573
1435
  return `[System] ${args.nodeLabel} has stopped${metadata}. Use mesh_read_chat once if you need to inspect its last output.`;
574
1436
  }
575
1437
  if (args.event === 'monitor:long_generating') {
576
- return `[System] ${args.nodeLabel} has been generating for a long time${metadata}. Use mesh_read_chat once for a status check, but do not poll repeatedly.`;
1438
+ return `[System] ${args.nodeLabel} is still reported as generating after a long interval${metadata}. Wait for pendingCoordinatorEvents or a completion/status event; if the user explicitly asks for status, make one bounded status check and then wait again.`;
1439
+ }
1440
+ if (args.event === 'refine:accepted') {
1441
+ const jobId = readRefineJobId({ metadataEvent: args.metadataEvent });
1442
+ return `[System] Refinery accepted async job${jobId ? ` ${jobId}` : ''} for ${args.nodeLabel}. Completion/failure will be delivered as a terminal refine event; do not poll repeatedly.`;
1443
+ }
1444
+ if (args.event === 'refine:completed') {
1445
+ const jobId = readRefineJobId({ metadataEvent: args.metadataEvent });
1446
+ const result = readRecord(args.metadataEvent.result);
1447
+ const validationSummary = readRecord(result?.validationSummary);
1448
+ const patchEquivalence = readRecord(result?.patchEquivalence);
1449
+ const finalConvergence = readRecord(result?.finalBranchConvergenceState);
1450
+ const validationStatus = readNonEmptyString(validationSummary?.status);
1451
+ const patchStatus = readNonEmptyString(patchEquivalence?.status)
1452
+ || (patchEquivalence?.equivalent === true ? 'passed' : '');
1453
+ const into = readNonEmptyString(result?.into);
1454
+ const branch = readNonEmptyString(result?.branch);
1455
+ const mergeStatus = result?.merged === true ? 'merged' : readNonEmptyString(finalConvergence?.status);
1456
+ const convergenceStatus = readNonEmptyString(finalConvergence?.status);
1457
+ const nextStep = readNonEmptyString(result?.nextStep)
1458
+ || readNonEmptyString(finalConvergence?.nextStep)
1459
+ || 'Continue from the updated mesh state.';
1460
+ const details = [
1461
+ jobId ? `job_id=${jobId}` : '',
1462
+ branch && into ? `${branch}→${into}` : '',
1463
+ validationStatus ? `validation=${validationStatus}` : '',
1464
+ patchStatus ? `patch_equivalence=${patchStatus}` : '',
1465
+ mergeStatus ? `merge=${mergeStatus}` : '',
1466
+ convergenceStatus ? `final_convergence=${convergenceStatus}` : '',
1467
+ ].filter(Boolean).join('; ');
1468
+ return `[System] Refinery async job for ${args.nodeLabel} completed successfully${details ? ` (${details})` : ''}.\nNext step: ${nextStep}`;
1469
+ }
1470
+ if (args.event === 'refine:failed') {
1471
+ const jobId = readRefineJobId({ metadataEvent: args.metadataEvent });
1472
+ const result = readRecord(args.metadataEvent.result);
1473
+ const validationSummary = readRecord(result?.validationSummary);
1474
+ const patchEquivalence = readRecord(result?.patchEquivalence);
1475
+ const finalConvergence = readRecord(result?.finalBranchConvergenceState);
1476
+ const code = readNonEmptyString(result?.code);
1477
+ const error = readNonEmptyString(result?.error);
1478
+ const validationStatus = readNonEmptyString(validationSummary?.status);
1479
+ const patchStatus = readNonEmptyString(patchEquivalence?.status)
1480
+ || (patchEquivalence?.equivalent === true ? 'passed' : '');
1481
+ const mergeStatus = result?.merged === true
1482
+ ? 'merged'
1483
+ : finalConvergence?.merged === false
1484
+ ? 'not_merged'
1485
+ : '';
1486
+ const convergenceStatus = readNonEmptyString(result?.convergenceStatus)
1487
+ || readNonEmptyString(finalConvergence?.status);
1488
+ const blockedReason = readNonEmptyString(result?.blockedReason);
1489
+ const nextStep = readNonEmptyString(result?.nextStep) || readNonEmptyString(finalConvergence?.nextStep);
1490
+ const details = [
1491
+ jobId ? `job_id=${jobId}` : '',
1492
+ code ? `code=${code}` : '',
1493
+ validationStatus ? `validation=${validationStatus}` : '',
1494
+ patchStatus ? `patch_equivalence=${patchStatus}` : '',
1495
+ mergeStatus ? `merge=${mergeStatus}` : '',
1496
+ convergenceStatus ? `convergence=${convergenceStatus}` : '',
1497
+ blockedReason ? `reason=${blockedReason}` : '',
1498
+ ].filter(Boolean).join('; ');
1499
+ const parts = [
1500
+ `[System] Refinery async job for ${args.nodeLabel} failed${details ? ` (${details})` : ''}${error ? `: ${error}` : '.'}`,
1501
+ nextStep ? `Next step: ${nextStep}` : 'Review the terminal refine event/ledger before retrying.',
1502
+ ];
1503
+ return parts.join('\n');
577
1504
  }
578
1505
  return '';
579
1506
  }
@@ -588,6 +1515,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
588
1515
  }) {
589
1516
  const eventSessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
590
1517
  const eventNodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
1518
+
1519
+ // Resolve coordinator ownership early — used in fingerprinting and coordinator routing.
1520
+ const sourceSession = args.sourceInstanceId
1521
+ ? components.instanceManager.getInstance(args.sourceInstanceId)
1522
+ : undefined;
1523
+ const workerCoordinatorDaemonId = readNonEmptyString(
1524
+ (sourceSession?.getState()?.settings as Record<string, unknown>)?.meshCoordinatorDaemonId,
1525
+ );
1526
+ const localDaemonId = readNonEmptyString(loadConfig().machineId);
591
1527
  const intentionalCleanupStop = shouldSuppressIntentionalCleanupStop({
592
1528
  event: args.event,
593
1529
  meshId: args.meshId,
@@ -597,89 +1533,210 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
597
1533
  });
598
1534
  if (intentionalCleanupStop) {
599
1535
  if (eventSessionId && eventNodeId) {
600
- remoteIdleSessions.delete(`${eventNodeId}:${eventSessionId}`);
1536
+ try {
1537
+ BeadsDB.getInstance().deleteRemoteIdleSession(eventNodeId, eventSessionId);
1538
+ } catch { /* best-effort */ }
601
1539
  }
602
1540
  LOG.info('MeshEvents', `Suppressed ${args.event} for intentionally cleanup-stopped session ${eventSessionId || '(unknown session)'}`);
603
1541
  return { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true };
604
1542
  }
605
1543
 
1544
+ if (args.event === 'monitor:long_generating') {
1545
+ const reconciledCompletion = buildLongGeneratingCompletionReconciliation({
1546
+ meshId: args.meshId,
1547
+ nodeId: args.nodeId,
1548
+ nodeLabel: args.nodeLabel,
1549
+ metadataEvent: args.metadataEvent,
1550
+ sourceInstanceId: args.sourceInstanceId,
1551
+ });
1552
+ if (reconciledCompletion?.source === 'long_generating_reconciliation') {
1553
+ LOG.info('MeshEvents', `Reconciled long-generating monitor to completion for session ${eventSessionId || '(unknown session)'}`);
1554
+ return injectMeshSystemMessage(components, {
1555
+ ...args,
1556
+ event: 'agent:generating_completed',
1557
+ metadataEvent: reconciledCompletion,
1558
+ });
1559
+ }
1560
+ if (reconciledCompletion?.source === 'long_generating_terminal_ledger_suppression') {
1561
+ LOG.info('MeshEvents', `Suppressed long-generating monitor because terminal ledger evidence already exists for session ${eventSessionId || '(unknown session)'}`);
1562
+ return {
1563
+ success: true,
1564
+ forwarded: 0,
1565
+ suppressed: true,
1566
+ terminalLedgerEvidence: true,
1567
+ terminalLedgerKind: reconciledCompletion.terminalLedgerKind,
1568
+ };
1569
+ }
1570
+ }
1571
+
1572
+ if (isDuplicateRefineTerminalEvent(args.meshId, args.event, args.metadataEvent)) {
1573
+ LOG.info('MeshEvents', `Suppressed duplicate ${args.event} for refine job ${readRefineJobId({ metadataEvent: args.metadataEvent })}`);
1574
+ return { success: true, forwarded: 0, suppressed: true, duplicateRefineTerminalEvent: true };
1575
+ }
1576
+
1577
+ const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
1578
+ if (args.event === 'agent:waiting_approval' && eventSessionId) {
1579
+ const duplicateApproval = isDuplicateMeshApprovalEvent({
1580
+ meshId: args.meshId,
1581
+ sessionId: eventSessionId,
1582
+ providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
1583
+ timestamp: eventTimestamp,
1584
+ modalMessage: readNonEmptyString(args.metadataEvent.modalMessage) || undefined,
1585
+ modalButtons: args.metadataEvent.modalButtons,
1586
+ });
1587
+ if (duplicateApproval) {
1588
+ LOG.info('MeshEvents', `Suppressed duplicate approval event for mesh ${args.meshId} session ${eventSessionId}`);
1589
+ return { success: true, forwarded: 0, suppressed: true, duplicateApproval: true };
1590
+ }
1591
+ }
1592
+ if (args.event === 'agent:generating_completed' && eventSessionId) {
1593
+ const terminal = findRecentTerminalLedgerEvidence({
1594
+ meshId: args.meshId,
1595
+ sessionId: eventSessionId,
1596
+ nodeId: eventNodeId || undefined,
1597
+ });
1598
+ if (terminal?.kind === 'task_completed' && !sessionHasActiveAssignment(args.meshId, eventSessionId)) {
1599
+ // If a new task_dispatched was recorded for this session after the prior terminal,
1600
+ // this completion belongs to the new task — never suppress it as a duplicate.
1601
+ const newDispatchAfterTerminal = hasDispatchAfterTerminal(args.meshId, eventSessionId, terminal.id);
1602
+ if (!newDispatchAfterTerminal) {
1603
+ const terminalProviderSessionId = readNonEmptyString(terminal.payload.providerSessionId);
1604
+ const terminalFinalSummary = readNonEmptyString(terminal.payload.finalSummary);
1605
+ const eventProviderSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
1606
+ const eventFinalSummary = readNonEmptyString(args.metadataEvent.finalSummary);
1607
+ if (
1608
+ (terminalProviderSessionId && terminalProviderSessionId === eventProviderSessionId)
1609
+ || (terminalFinalSummary && terminalFinalSummary === eventFinalSummary)
1610
+ || args.metadataEvent.source === 'long_generating_reconciliation'
1611
+ ) {
1612
+ LOG.info('MeshEvents', `Suppressed duplicate completion with existing terminal ledger evidence for mesh ${args.meshId} session ${eventSessionId}`);
1613
+ return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true, terminalLedgerEvidence: true };
1614
+ }
1615
+ }
1616
+ }
1617
+ const duplicateCompletion = isDuplicateMeshCompletionEvent({
1618
+ meshId: args.meshId,
1619
+ event: args.event,
1620
+ sessionId: eventSessionId,
1621
+ providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
1622
+ providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
1623
+ timestamp: eventTimestamp,
1624
+ finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
1625
+ // Scope dedup to the coordinator daemon so two coordinators for the same mesh
1626
+ // don't suppress each other's completion events via shared fingerprint table.
1627
+ coordinatorDaemonId: workerCoordinatorDaemonId || undefined,
1628
+ });
1629
+ if (duplicateCompletion) {
1630
+ LOG.info('MeshEvents', `Suppressed duplicate completion for mesh ${args.meshId} session ${eventSessionId}`);
1631
+ return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true };
1632
+ }
1633
+ }
1634
+
606
1635
  // ── Task Queue & Ledger ──
1636
+ // Helpers that keep queue and direct-dispatch status transitions symmetric.
1637
+ // Both paths must move together so buildMeshActiveWork and the coordinator
1638
+ // view stay consistent regardless of which dispatch path was used.
1639
+ function markSessionTerminal(sessionId: string, outcome: 'completed' | 'failed', occurredAtMs?: number | null): { id?: string } | null {
1640
+ const task = updateSessionTaskStatus(args.meshId, sessionId, outcome, {
1641
+ occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1642
+ });
1643
+ updateDirectDispatchStatus(args.meshId, sessionId, outcome);
1644
+ setImmediate(() => cleanupTerminalDirectDispatches());
1645
+ return task ? { id: task.id } : null;
1646
+ }
1647
+
607
1648
  let completedTaskForLedger: { id?: string } | null = null;
608
1649
  if (args.event === 'agent:generating_completed') {
609
1650
  const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
610
1651
  const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
611
1652
  const providerType = readNonEmptyString(args.metadataEvent.providerType);
612
-
1653
+
613
1654
  if (sessionId) {
614
- const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed');
615
- completedTaskForLedger = completedTask ? { id: completedTask.id } : null;
1655
+ completedTaskForLedger = markSessionTerminal(sessionId, 'completed', eventTimestamp);
616
1656
  if (nodeId && providerType) {
617
- // Short delay to allow completion event to propagate before pulling next
618
- setTimeout(() => {
619
- tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
620
- }, 500);
1657
+ runIdleMaintenanceThenAssignQueue(components, { meshId: args.meshId, nodeId, sessionId, providerType });
621
1658
  }
622
1659
  }
623
1660
  } else if (args.event === 'agent:ready') {
624
1661
  const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
625
1662
  const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
626
1663
  const providerType = readNonEmptyString(args.metadataEvent.providerType);
627
- const completedTask = sessionId
628
- ? updateSessionTaskStatus(args.meshId, sessionId, 'completed')
629
- : null;
630
- if (completedTask) {
631
- completedTaskForLedger = { id: completedTask.id };
632
- try {
633
- appendLedgerEntry(args.meshId, {
634
- kind: 'task_completed',
635
- nodeId: nodeId || undefined,
636
- sessionId,
637
- providerType: providerType || undefined,
638
- payload: {
639
- event: args.event,
640
- nodeLabel: args.nodeLabel,
641
- taskId: completedTask.id,
642
- completedViaReady: true,
643
- providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
644
- finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
645
- evidence: buildTaskCompletionEvidence({
646
- event: 'agent:ready',
647
- nodeId,
648
- sessionId,
649
- providerType: providerType || undefined,
650
- providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
651
- finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
652
- }),
653
- },
654
- });
655
- } catch (e: any) {
656
- LOG.warn('MeshLedger', `Failed to record task_completed from ready: ${e?.message || e}`);
1664
+ const providerSessionId = readNonEmptyString(args.metadataEvent.providerSessionId) || undefined;
1665
+ const finalSummary = readNonEmptyString(args.metadataEvent.finalSummary) || undefined;
1666
+ const workerResult = readWorkerResultMetadata(args.metadataEvent);
1667
+ const hasCompletionEvidence = !!finalSummary || !!workerResult;
1668
+ if (sessionId && hasCompletionEvidence) {
1669
+ completedTaskForLedger = markSessionTerminal(sessionId, 'completed');
1670
+ if (completedTaskForLedger) {
1671
+ try {
1672
+ appendLedgerEntry(args.meshId, {
1673
+ kind: 'task_completed',
1674
+ nodeId: nodeId || undefined,
1675
+ sessionId,
1676
+ providerType: providerType || undefined,
1677
+ payload: {
1678
+ event: args.event,
1679
+ nodeLabel: args.nodeLabel,
1680
+ taskId: completedTaskForLedger.id,
1681
+ completedViaReady: true,
1682
+ providerSessionId,
1683
+ finalSummary,
1684
+ workerResult,
1685
+ evidence: buildTaskCompletionEvidence({
1686
+ event: 'agent:ready',
1687
+ nodeId,
1688
+ sessionId,
1689
+ providerType: providerType || undefined,
1690
+ providerSessionId,
1691
+ finalSummary,
1692
+ workerResult,
1693
+ }),
1694
+ },
1695
+ });
1696
+ } catch (e: any) {
1697
+ LOG.warn('MeshLedger', `Failed to record task_completed from ready: ${e?.message || e}`);
1698
+ }
657
1699
  }
658
1700
  }
659
-
1701
+
660
1702
  if (sessionId && nodeId && providerType) {
661
- remoteIdleSessions.set(`${nodeId}:${sessionId}`, { nodeId, sessionId, providerType });
662
- setTimeout(() => {
663
- const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
664
- if (assigned) {
665
- remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
666
- }
667
- }, 500);
1703
+ sweepExpiredRemoteIdleSessions();
1704
+ try {
1705
+ BeadsDB.getInstance().setRemoteIdleSession(nodeId, sessionId, providerType, Date.now() + REMOTE_IDLE_SESSION_TTL_MS);
1706
+ } catch { /* best-effort */ }
1707
+ setImmediate(() => {
1708
+ maybeAutoFastForwardIdleNode(components, { meshId: args.meshId, nodeId, sessionId, providerType })
1709
+ .finally(() => {
1710
+ try {
1711
+ const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
1712
+ if (assigned) BeadsDB.getInstance().deleteRemoteIdleSession(nodeId, sessionId);
1713
+ } catch (e: any) {
1714
+ LOG.warn('MeshQueue', `Failed to assign idle queue task after maintenance for ${nodeId}: ${e?.message || e}`);
1715
+ }
1716
+ });
1717
+ });
668
1718
  }
669
1719
  } else if (args.event === 'agent:generating_started') {
670
1720
  const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
671
1721
  const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
672
1722
  if (sessionId && nodeId) {
673
- remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
1723
+ try {
1724
+ BeadsDB.getInstance().deleteRemoteIdleSession(nodeId, sessionId);
1725
+ } catch { /* best-effort */ }
1726
+ }
1727
+ if (sessionId) {
1728
+ updateDirectDispatchStatus(args.meshId, sessionId, 'acked');
674
1729
  }
675
1730
  } else if (args.event === 'agent:stopped') {
676
1731
  const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
677
1732
  const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
678
1733
  if (sessionId && nodeId) {
679
- remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
1734
+ try {
1735
+ BeadsDB.getInstance().deleteRemoteIdleSession(nodeId, sessionId);
1736
+ } catch { /* best-effort */ }
680
1737
  }
681
1738
  if (sessionId) {
682
- updateSessionTaskStatus(args.meshId, sessionId, 'failed');
1739
+ completedTaskForLedger = markSessionTerminal(sessionId, 'failed');
683
1740
  }
684
1741
  }
685
1742
 
@@ -689,14 +1746,18 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
689
1746
  const ledgerNodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId) || undefined;
690
1747
  const ledgerSessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId) || undefined;
691
1748
  const ledgerProviderType = readNonEmptyString(args.metadataEvent.providerType) || undefined;
1749
+ const providerSessionId = readNonEmptyString(args.metadataEvent.providerSessionId) || undefined;
1750
+ const finalSummary = readNonEmptyString(args.metadataEvent.finalSummary) || undefined;
1751
+ const workerResult = readWorkerResultMetadata(args.metadataEvent);
692
1752
  const completionEvidence = ledgerKind === 'task_completed' && ledgerNodeId && ledgerSessionId
693
1753
  ? buildTaskCompletionEvidence({
694
1754
  event: 'agent:generating_completed',
695
1755
  nodeId: ledgerNodeId,
696
1756
  sessionId: ledgerSessionId,
697
1757
  providerType: ledgerProviderType,
698
- providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
699
- finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
1758
+ providerSessionId,
1759
+ finalSummary,
1760
+ workerResult,
700
1761
  })
701
1762
  : undefined;
702
1763
  appendLedgerEntry(args.meshId, {
@@ -708,8 +1769,12 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
708
1769
  event: args.event,
709
1770
  nodeLabel: args.nodeLabel,
710
1771
  taskId: completedTaskForLedger?.id || undefined,
711
- providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
712
- finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
1772
+ providerSessionId,
1773
+ finalSummary,
1774
+ workerResult,
1775
+ completionDiagnostic: args.metadataEvent.completionDiagnostic && typeof args.metadataEvent.completionDiagnostic === 'object'
1776
+ ? args.metadataEvent.completionDiagnostic
1777
+ : undefined,
713
1778
  evidence: completionEvidence,
714
1779
  },
715
1780
  });
@@ -782,15 +1847,33 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
782
1847
  }
783
1848
  }
784
1849
 
1850
+ const messageText = buildMeshSystemMessage({
1851
+ event: args.event,
1852
+ nodeLabel: args.nodeLabel,
1853
+ metadataEvent: args.metadataEvent,
1854
+ recoveryContext,
1855
+ });
1856
+ if (!messageText) return { success: false, error: 'unsupported mesh event' };
1857
+
785
1858
  const coordinatorInstances = components.instanceManager.getByCategory('cli').filter((inst) => {
786
1859
  const instState = inst.getState();
787
1860
  if (instState.settings?.meshCoordinatorFor !== args.meshId) return false;
788
1861
  if (args.sourceInstanceId && instState.instanceId === args.sourceInstanceId) return false;
1862
+ // If the worker knows which coordinator daemon launched it, only route to coordinators
1863
+ // on that specific daemon. This prevents cross-contamination when multiple coordinator
1864
+ // sessions run simultaneously for the same mesh on different daemons.
1865
+ if (workerCoordinatorDaemonId && localDaemonId && workerCoordinatorDaemonId !== localDaemonId) return false;
789
1866
  return true;
790
1867
  });
791
1868
 
1869
+ // Refine terminal events (refine:completed, refine:failed) are coordinator-delivered
1870
+ // synchronously; only buffer them for MCP when no CLI coordinator is present.
1871
+ // Agent runtime events (agent:*) use dual delivery so both CLI and MCP coordinators
1872
+ // receive them regardless of whether a live CLI coordinator session is active.
1873
+ const isRefineTerminalEvent = REFINE_TERMINAL_EVENTS.has(args.event);
1874
+
792
1875
  if (coordinatorInstances.length === 0) {
793
- // No CLI coordinator session found — buffer for MCP-based coordinators.
1876
+ // No local CLI coordinator — buffer for MCP-based coordinator on the target daemon.
794
1877
  if (queuePendingMeshCoordinatorEvent({
795
1878
  event: args.event,
796
1879
  meshId: args.meshId,
@@ -801,20 +1884,69 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
801
1884
  ...args.metadataEvent,
802
1885
  ...(recoveryContext ? { recoveryContext } : {}),
803
1886
  },
1887
+ coordinatorMessage: messageText,
804
1888
  queuedAt: Date.now(),
1889
+ // Scope to the coordinator daemon that launched this worker so drain
1890
+ // by other coordinators on the same daemon doesn't consume this event.
1891
+ ...(workerCoordinatorDaemonId ? { targetCoordinatorDaemonId: workerCoordinatorDaemonId } : {}),
805
1892
  })) {
806
- LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
1893
+ LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId}${workerCoordinatorDaemonId ? `, coordinator daemon ${workerCoordinatorDaemonId}` : ''})`);
807
1894
  }
808
1895
  return { success: true, forwarded: 0 };
809
1896
  }
810
1897
 
811
- const messageText = buildMeshSystemMessage({
812
- event: args.event,
813
- nodeLabel: args.nodeLabel,
814
- metadataEvent: args.metadataEvent,
815
- recoveryContext,
1898
+ // CLI coordinator is present. For non-refine events, also buffer for MCP coordinators
1899
+ // that poll via get_pending_mesh_events (dual delivery). Refine terminal events are
1900
+ // forwarded directly only — they must not accumulate in the pending queue when a live
1901
+ // coordinator already received them.
1902
+ //
1903
+ // Exception: if ALL live CLI coordinator instances are in a generating/active state
1904
+ // when a refine terminal event fires (e.g. a Codex CLI coordinator that triggered an
1905
+ // async refine job and is still in the generating turn that sent it), the coordinator
1906
+ // cannot immediately receive send_message input. Buffer the event to the pending queue
1907
+ // so it is available via get_pending_mesh_events when the coordinator returns to idle.
1908
+ // Critically: do NOT attempt send_message injection into a generating PTY coordinator for
1909
+ // terminal refine events — injecting text into an active PTY can corrupt the input stream
1910
+ // and leave the coordinator stuck in generating state, unable to process the refine result.
1911
+ const allCoordinatorsGenerating = isRefineTerminalEvent && coordinatorInstances.every((inst) => {
1912
+ const s = inst.getState();
1913
+ const status = readNonEmptyString(s.status).toLowerCase();
1914
+ const activeChatStatus = readNonEmptyString(s.activeChat?.status).toLowerCase();
1915
+ return status === 'generating' || status === 'streaming' || status === 'long_generating'
1916
+ || activeChatStatus === 'generating' || activeChatStatus === 'streaming';
816
1917
  });
817
- if (!messageText) return { success: false, error: 'unsupported mesh event' };
1918
+
1919
+ if (!isRefineTerminalEvent || allCoordinatorsGenerating) {
1920
+ if (queuePendingMeshCoordinatorEvent({
1921
+ event: args.event,
1922
+ meshId: args.meshId,
1923
+ nodeLabel: args.nodeLabel,
1924
+ nodeId: args.nodeId || undefined,
1925
+ workspace: readNonEmptyString(args.metadataEvent.workspace),
1926
+ metadataEvent: {
1927
+ ...args.metadataEvent,
1928
+ ...(recoveryContext ? { recoveryContext } : {}),
1929
+ },
1930
+ coordinatorMessage: messageText,
1931
+ queuedAt: Date.now(),
1932
+ ...(workerCoordinatorDaemonId ? { targetCoordinatorDaemonId: workerCoordinatorDaemonId } : {}),
1933
+ })) {
1934
+ if (allCoordinatorsGenerating) {
1935
+ LOG.info('MeshEvents', `Queued ${args.event} for generating CLI coordinator (mesh ${args.meshId}) — will be delivered via get_pending_mesh_events when coordinator returns to idle`);
1936
+ } else {
1937
+ LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
1938
+ }
1939
+ }
1940
+ }
1941
+
1942
+ // When all CLI coordinators are actively generating and a terminal refine event fires,
1943
+ // skip send_message injection entirely. The event is already buffered to the pending queue.
1944
+ // Injecting into a generating PTY coordinator can corrupt its input stream and cause it to
1945
+ // remain stuck in generating state, never processing the refine result.
1946
+ // The coordinator will drain pending events via get_pending_mesh_events on its next idle cycle.
1947
+ if (allCoordinatorsGenerating) {
1948
+ return { success: true, forwarded: 0, bufferedForGeneratingCoordinator: true };
1949
+ }
818
1950
 
819
1951
  for (const coord of coordinatorInstances) {
820
1952
  const coordState = coord.getState();
@@ -835,6 +1967,11 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
835
1967
  const nodeId = readNonEmptyString(payload.nodeId);
836
1968
  const workspace = readNonEmptyString(payload.workspace);
837
1969
  const nodeLabel = nodeId ? `Node '${nodeId}'` : workspace ? `Agent at ${workspace}` : 'Remote agent';
1970
+ const relayModalMessage = readNonEmptyString(payload.modalMessage);
1971
+ const relayModalButtons = Array.isArray(payload.modalButtons)
1972
+ ? (payload.modalButtons as unknown[]).filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
1973
+ : null;
1974
+
838
1975
  return injectMeshSystemMessage(components, {
839
1976
  meshId,
840
1977
  nodeId,
@@ -845,6 +1982,21 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
845
1982
  providerType: readNonEmptyString(payload.providerType),
846
1983
  providerSessionId: readNonEmptyString(payload.providerSessionId),
847
1984
  finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
1985
+ jobId: readNonEmptyString(payload.jobId),
1986
+ interactionId: readNonEmptyString(payload.interactionId),
1987
+ status: readNonEmptyString(payload.status),
1988
+ targetDaemonId: readNonEmptyString(payload.targetDaemonId),
1989
+ startedAt: readNonEmptyString(payload.startedAt),
1990
+ completedAt: readNonEmptyString(payload.completedAt),
1991
+ retryOfJobId: readNonEmptyString(payload.retryOfJobId),
1992
+ ...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
1993
+ ...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
1994
+ ...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
1995
+ ...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
1996
+ ...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
1997
+ ...(payload.meshWorkerResult && typeof payload.meshWorkerResult === 'object' && !Array.isArray(payload.meshWorkerResult) ? { meshWorkerResult: payload.meshWorkerResult } : {}),
1998
+ ...(payload.structuredResult && typeof payload.structuredResult === 'object' && !Array.isArray(payload.structuredResult) ? { structuredResult: payload.structuredResult } : {}),
1999
+ ...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
848
2000
  intentional: payload.intentional === true,
849
2001
  intentionalStop: payload.intentionalStop === true,
850
2002
  operatorCleanup: payload.operatorCleanup === true,
@@ -872,21 +2024,36 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
872
2024
  if (!workspace) return;
873
2025
  const settings = state.settings && typeof state.settings === 'object' ? state.settings as Record<string, unknown> : {};
874
2026
 
875
- // Coordinator sessions must never inject events into themselves.
876
- // A coordinator instance carries meshCoordinatorFor but not meshNodeFor/launchedByCoordinator.
877
- if (readNonEmptyString(settings.meshCoordinatorFor)) return;
2027
+ // A coordinator session normally must not inject events into itself. However,
2028
+ // a coordinator can also be the direct-dispatch target of mesh_send_task from
2029
+ // another coordinator. In that case the completion event must flow through
2030
+ // injectMeshSystemMessage so the ledger records task_completed and the other
2031
+ // coordinator's pendingCoordinatorEvents queue is populated. Skip only when
2032
+ // this session has no in-flight direct dispatch.
2033
+ const coordinatorMeshId = readNonEmptyString(settings.meshCoordinatorFor);
2034
+ let meshIdFromDirectDispatch = '';
2035
+ if (coordinatorMeshId) {
2036
+ try {
2037
+ const hasActiveDispatch =
2038
+ getActiveDirectDispatches(coordinatorMeshId).some(d => d.sessionId === instanceId)
2039
+ || hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, instanceId);
2040
+ if (hasActiveDispatch) meshIdFromDirectDispatch = coordinatorMeshId;
2041
+ } catch { /* best-effort */ }
2042
+ if (!meshIdFromDirectDispatch) return;
2043
+ }
878
2044
 
879
- const meshIdFromRuntime = readNonEmptyString(settings.meshNodeFor);
2045
+ const meshIdFromRuntime = readNonEmptyString(settings.meshNodeFor) || meshIdFromDirectDispatch;
880
2046
 
881
2047
  // Only forward events for sessions that were explicitly launched as mesh-node delegates
882
- // (meshNodeFor set by mesh_launch_session) or that carry the launchedByCoordinator flag.
2048
+ // (meshNodeFor set by mesh_launch_session), carry the launchedByCoordinator flag, or
2049
+ // have an active direct-dispatch entry (mesh_send_task to a pre-existing session).
883
2050
  // Do NOT fall back to workspace-based mesh lookup: that would pick up coordinator sessions
884
2051
  // and any other CLI session that happens to share the same workspace, causing spurious
885
2052
  // system-message injection into the coordinator's own conversation.
886
2053
  const isMeshDelegate = Boolean(meshIdFromRuntime || settings.launchedByCoordinator);
887
2054
  if (!isMeshDelegate) return;
888
2055
 
889
- const mesh = meshIdFromRuntime ? getMeshWithCache(components, meshIdFromRuntime) : getMeshByRepo(workspace);
2056
+ const mesh = meshIdFromRuntime ? getMeshWithCache(components, meshIdFromRuntime) : getCachedMeshByWorkspace(workspace);
890
2057
  const meshId = meshIdFromRuntime || readNonEmptyString(mesh?.id);
891
2058
  if (!meshId) return;
892
2059