@bbigbang/core 0.1.0

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 (175) hide show
  1. package/dist/config.js +380 -0
  2. package/dist/execution/executionDispatcher.js +3810 -0
  3. package/dist/main.js +90 -0
  4. package/dist/nodeEventHistory.js +206 -0
  5. package/dist/scheduler/dreamLogic.js +50 -0
  6. package/dist/scheduler/dreamScheduler.js +65 -0
  7. package/dist/services/agentFileAccessService.js +1913 -0
  8. package/dist/services/agentRuntimeCleanupBroker.js +62 -0
  9. package/dist/services/agentSkillsBroker.js +118 -0
  10. package/dist/services/agentSkillsService.js +83 -0
  11. package/dist/services/agentWorkspaceBroker.js +937 -0
  12. package/dist/services/agentWorkspaceService.js +70 -0
  13. package/dist/services/appVersion.js +14 -0
  14. package/dist/services/auth.js +586 -0
  15. package/dist/services/claudeControlBroker.js +154 -0
  16. package/dist/services/claudeTranscriptBroker.js +100 -0
  17. package/dist/services/claudeTranscriptService.js +359 -0
  18. package/dist/services/codexAppServerBroker.js +155 -0
  19. package/dist/services/codexTranscriptBroker.js +98 -0
  20. package/dist/services/codexTranscriptService.js +961 -0
  21. package/dist/services/droidMissionBroker.js +124 -0
  22. package/dist/services/droidMissionImporter.js +630 -0
  23. package/dist/services/droidModelOptions.js +165 -0
  24. package/dist/services/hubServerRegistrationService.js +268 -0
  25. package/dist/services/libraryManifest.js +43 -0
  26. package/dist/services/libraryScaffold.js +26 -0
  27. package/dist/services/libraryService.js +2263 -0
  28. package/dist/services/memoryService.js +386 -0
  29. package/dist/services/missionEvidence.js +377 -0
  30. package/dist/services/missionService.js +2361 -0
  31. package/dist/services/missionTrace.js +158 -0
  32. package/dist/services/nativeMissionBriefParser.js +120 -0
  33. package/dist/services/nativeMissionOrchestrator.js +2045 -0
  34. package/dist/services/nativeMissionReportGenerator.js +227 -0
  35. package/dist/services/nativeMissionValidationRunner.js +452 -0
  36. package/dist/services/nativeMissionWorkerBroker.js +190 -0
  37. package/dist/services/nodeRegistry.js +34 -0
  38. package/dist/services/nodeStateReconciler.js +97 -0
  39. package/dist/services/panelMediaScanner.js +119 -0
  40. package/dist/services/persistentRuntimeJsonlClient.js +153 -0
  41. package/dist/services/platformAgentPolicy.js +180 -0
  42. package/dist/services/platformAgentService.js +2041 -0
  43. package/dist/services/projectAccessResolver.js +93 -0
  44. package/dist/services/projectService.js +392 -0
  45. package/dist/services/resourceSpaceService.js +140 -0
  46. package/dist/services/scenarioRuntimeService.js +1130 -0
  47. package/dist/services/suggestedPlannerService.js +868 -0
  48. package/dist/services/workbenchGitBroker.js +161 -0
  49. package/dist/services/workbenchGitService.js +69 -0
  50. package/dist/services/workbenchInspectBroker.js +65 -0
  51. package/dist/services/workbenchNodePathService.js +79 -0
  52. package/dist/services/workbenchRegistryService.js +240 -0
  53. package/dist/services/workbenchRootService.js +181 -0
  54. package/dist/services/workbenchTerminalBroker.js +378 -0
  55. package/dist/services/workspaceRunOwnership.js +60 -0
  56. package/dist/services/workspaceScaffold.js +105 -0
  57. package/dist/services/workspaceSessionRuntimeService.js +576 -0
  58. package/dist/services/workspaceSessionService.js +245 -0
  59. package/dist/services/workspaceToolActionRunner.js +1582 -0
  60. package/dist/services/workspaceToolErrors.js +10 -0
  61. package/dist/services/workspaceToolExecutionUtils.js +895 -0
  62. package/dist/services/workspaceToolLatestStateProjector.js +91 -0
  63. package/dist/services/workspaceToolManifest.js +572 -0
  64. package/dist/services/workspaceToolMutationQueue.js +43 -0
  65. package/dist/services/workspaceToolPanelProjection.js +460 -0
  66. package/dist/services/workspaceToolPromotion.js +255 -0
  67. package/dist/services/workspaceToolPromotionState.js +224 -0
  68. package/dist/services/workspaceToolPublishDiagnostics.js +189 -0
  69. package/dist/services/workspaceToolPublishIdentityResolver.js +146 -0
  70. package/dist/services/workspaceToolReadModel.js +378 -0
  71. package/dist/services/workspaceToolRunLedger.js +239 -0
  72. package/dist/services/workspaceToolService.js +3067 -0
  73. package/dist/services/workspaceToolSnapshotPanelSync.js +293 -0
  74. package/dist/services/workspaceToolTerminalLifecycle.js +283 -0
  75. package/dist/services/workspaceToolTypes.js +1 -0
  76. package/dist/services/workspaceToolUploadMaterializer.js +228 -0
  77. package/dist/web/actionCardRoutes.js +129 -0
  78. package/dist/web/actionCards.js +469 -0
  79. package/dist/web/activationContext.js +684 -0
  80. package/dist/web/agentChannelGuards.js +48 -0
  81. package/dist/web/agentMentionCooldowns.js +32 -0
  82. package/dist/web/agentReminders.js +1668 -0
  83. package/dist/web/agentRuntimePresence.js +197 -0
  84. package/dist/web/agentSelfState.js +494 -0
  85. package/dist/web/agentTaskLinks.js +26 -0
  86. package/dist/web/agentVisibility.js +79 -0
  87. package/dist/web/assets.js +95 -0
  88. package/dist/web/channelActivationPrompt.js +395 -0
  89. package/dist/web/channelMemoryNotes.js +127 -0
  90. package/dist/web/channelMentions.js +10 -0
  91. package/dist/web/channelMessageSequences.js +19 -0
  92. package/dist/web/channelSubscriptions.js +26 -0
  93. package/dist/web/clearedTaskRoots.js +10 -0
  94. package/dist/web/collaborationPromptGuidance.js +36 -0
  95. package/dist/web/collaborationSurfaceState.js +140 -0
  96. package/dist/web/contextBundleRanking.js +154 -0
  97. package/dist/web/contextBundleResolver.js +488 -0
  98. package/dist/web/conversationBuiltinSkillRoots.js +50 -0
  99. package/dist/web/conversationControls.js +232 -0
  100. package/dist/web/conversationHandoffs.js +612 -0
  101. package/dist/web/conversationManager.js +2511 -0
  102. package/dist/web/conversationSummaries.js +876 -0
  103. package/dist/web/conversationSurfaceKinds.js +17 -0
  104. package/dist/web/conversationTargets.js +173 -0
  105. package/dist/web/directActivationPrompt.js +122 -0
  106. package/dist/web/directReplyTargets.js +69 -0
  107. package/dist/web/directThreadResolver.js +129 -0
  108. package/dist/web/dmTaskHandoffPrompt.js +120 -0
  109. package/dist/web/dmTaskThreadStatusProjection.js +229 -0
  110. package/dist/web/ftsQuery.js +33 -0
  111. package/dist/web/internalAgentRouter.js +11341 -0
  112. package/dist/web/libraryCuratorScheduler.js +58 -0
  113. package/dist/web/libraryDocumentPromptGuidance.js +8 -0
  114. package/dist/web/messageCheckpoints.js +19 -0
  115. package/dist/web/nodeWsHandler.js +2495 -0
  116. package/dist/web/notificationRounds.js +1061 -0
  117. package/dist/web/panelActionMessages.js +108 -0
  118. package/dist/web/panelActivationPrompt.js +18 -0
  119. package/dist/web/panelAudit.js +273 -0
  120. package/dist/web/panelLifecycle.js +222 -0
  121. package/dist/web/panelMediaPolicy.js +43 -0
  122. package/dist/web/panelPathPolicy.js +63 -0
  123. package/dist/web/panelPreviews.js +175 -0
  124. package/dist/web/panelQueryHandles.js +2749 -0
  125. package/dist/web/panelRoutes.js +2147 -0
  126. package/dist/web/panels.js +904 -0
  127. package/dist/web/peerInboxAggregates.js +1247 -0
  128. package/dist/web/planApprovalState.js +92 -0
  129. package/dist/web/platformAgentScheduler.js +66 -0
  130. package/dist/web/proactiveOpportunities.js +452 -0
  131. package/dist/web/promptContextSections.js +242 -0
  132. package/dist/web/promptHistorySanitizer.js +26 -0
  133. package/dist/web/promptSlashCommands.js +158 -0
  134. package/dist/web/rollingConversationSummary.js +453 -0
  135. package/dist/web/routeHelpers.js +11 -0
  136. package/dist/web/routes/handoff.js +288 -0
  137. package/dist/web/routes/history.js +345 -0
  138. package/dist/web/routes/memory.js +258 -0
  139. package/dist/web/routes/selfState.js +171 -0
  140. package/dist/web/routes/workspace.js +154 -0
  141. package/dist/web/runSurfaceWatermarks.js +431 -0
  142. package/dist/web/runtimeCapabilities.js +48 -0
  143. package/dist/web/sameAgentHandoffs.js +494 -0
  144. package/dist/web/server.js +15567 -0
  145. package/dist/web/sharedCollaborationCapsules.js +163 -0
  146. package/dist/web/soloSessionRelay.js +42 -0
  147. package/dist/web/soloWsHandler.js +138 -0
  148. package/dist/web/suggestedPlannerScheduler.js +56 -0
  149. package/dist/web/surfaceActivationPolicy.js +108 -0
  150. package/dist/web/surfaceCollaborators.js +61 -0
  151. package/dist/web/surfaceSystemStatus.js +263 -0
  152. package/dist/web/targetParticipants.js +77 -0
  153. package/dist/web/taskEvents.js +49 -0
  154. package/dist/web/taskLifecycleMessages.js +165 -0
  155. package/dist/web/taskLoops.js +732 -0
  156. package/dist/web/taskMemoryNotes.js +224 -0
  157. package/dist/web/taskNumbers.js +16 -0
  158. package/dist/web/taskOwnerGuards.js +49 -0
  159. package/dist/web/taskParticipantResolver.js +42 -0
  160. package/dist/web/taskParticipants.js +97 -0
  161. package/dist/web/taskSourceDetails.js +20 -0
  162. package/dist/web/taskStateViews.js +210 -0
  163. package/dist/web/taskStatusTransitions.js +9 -0
  164. package/dist/web/taskThreadFollowups.js +599 -0
  165. package/dist/web/taskThreadRuntimeClosure.js +685 -0
  166. package/dist/web/taskUpdateDelivery.js +104 -0
  167. package/dist/web/threadReplyContentHeuristics.js +30 -0
  168. package/dist/web/threadRoots.js +61 -0
  169. package/dist/web/threadTaskBindings.js +365 -0
  170. package/dist/web/uiPanelPromptGuidance.js +27 -0
  171. package/dist/web/workspaceMemoryHints.js +143 -0
  172. package/dist/web/workspaceToolPromptGuidance.js +30 -0
  173. package/dist/web/wsHandler.js +397 -0
  174. package/dist/web/wsSink.js +116 -0
  175. package/package.json +54 -0
@@ -0,0 +1,2495 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { log, finishRun } from '@bbigbang/runtime-acp';
4
+ import { AgentFileAccessService, getRunEventSeqCutoff } from '../services/agentFileAccessService.js';
5
+ import { hasOpenPendingWorkspaceApproval, isWorkspaceApprovalProgressEvent, promoteWorkspaceSentApprovalResponses, } from '../services/workspaceSessionRuntimeService.js';
6
+ import { getWorkspaceRunDispatchNodeId, WORKSPACE_RUN_DISPATCH_METHOD, } from '../services/workspaceRunOwnership.js';
7
+ import { iterateParsedNodeEvents } from '../nodeEventHistory.js';
8
+ import { insertHandoffRun, markConversationHandoffAccepted, markConversationHandoffCancelled, markConversationHandoffCompleted, markConversationHandoffFailed, } from './conversationHandoffs.js';
9
+ import { refreshConversationSummariesForConversationIds } from './conversationSummaries.js';
10
+ import { resolveConversationReplyTarget, resolveDirectUserIdFromReplyTarget } from './directReplyTargets.js';
11
+ import { allocateNextChannelMessageSeq } from './channelMessageSequences.js';
12
+ import { getBoundTaskForThread } from './threadTaskBindings.js';
13
+ import { scheduleTaskThreadFollowupForRun } from './taskThreadFollowups.js';
14
+ import { clearTaskThreadReopenedRunBlock } from './taskThreadRuntimeClosure.js';
15
+ import { markCodexRotationPendingIfDue, recordCodexCompactEvent } from './rollingConversationSummary.js';
16
+ import { buildPrimaryDmTaskRootUpdateEventForThreadConversation } from './dmTaskThreadStatusProjection.js';
17
+ import { DM_HANDOFF_BOOTSTRAP_STOP_REASON, getExpectedBootstrapStopReason, getSnapshotConversationStatus, } from './planApprovalState.js';
18
+ /** Persist a ServerEvent from a remote run into core DB as a node/event entry */
19
+ function appendNodeEvent(db, runId, seq, event) {
20
+ const persistedEvent = normalizeReplayEventForPersistence(event);
21
+ db.prepare('INSERT OR IGNORE INTO events(run_id, seq, method, payload_json, created_at) VALUES(?, ?, ?, ?, ?)').run(runId, seq, 'node/event', JSON.stringify(persistedEvent), Date.now());
22
+ }
23
+ function nextNodeEventSeq(db, runId, runSeq) {
24
+ const row = db.prepare(`SELECT COALESCE(MAX(seq), 0) as maxSeq
25
+ FROM events
26
+ WHERE run_id = ?
27
+ AND seq > 0`).get(runId);
28
+ const current = runSeq.get(runId) ?? 0;
29
+ return Math.max(current, row?.maxSeq ?? 0) + 1;
30
+ }
31
+ function normalizeReplayEventForPersistence(event) {
32
+ switch (event.type) {
33
+ case 'tool.call':
34
+ return {
35
+ type: 'tool.call',
36
+ toolCallId: event.toolCallId,
37
+ name: event.name,
38
+ input: event.input,
39
+ status: event.status,
40
+ startedAt: event.startedAt,
41
+ };
42
+ case 'tool.result':
43
+ return {
44
+ type: 'tool.result',
45
+ toolCallId: event.toolCallId,
46
+ output: event.output,
47
+ error: event.error,
48
+ status: event.status,
49
+ endedAt: event.endedAt,
50
+ };
51
+ case 'run.usage':
52
+ return {
53
+ type: 'run.usage',
54
+ inputTokens: event.inputTokens,
55
+ cachedInputTokens: event.cachedInputTokens,
56
+ outputTokens: event.outputTokens,
57
+ reasoningOutputTokens: event.reasoningOutputTokens,
58
+ totalTokens: event.totalTokens,
59
+ currentInputTokens: event.currentInputTokens,
60
+ currentCachedInputTokens: event.currentCachedInputTokens,
61
+ modelContextWindow: event.modelContextWindow,
62
+ createdAt: event.createdAt,
63
+ };
64
+ case 'runtime.compact':
65
+ return {
66
+ type: 'runtime.compact',
67
+ threadId: event.threadId,
68
+ turnId: event.turnId,
69
+ itemId: event.itemId,
70
+ source: event.source,
71
+ eventKey: event.eventKey,
72
+ createdAt: event.createdAt,
73
+ };
74
+ default:
75
+ return event;
76
+ }
77
+ }
78
+ function applyRunDebugSnapshot(db, params) {
79
+ const now = Date.now();
80
+ db.prepare(`UPDATE run_debug_inputs
81
+ SET acp_session_id = ?,
82
+ is_fresh_session = ?,
83
+ is_exact = ?,
84
+ system_prompt_text = COALESCE(?, system_prompt_text),
85
+ context_text = ?,
86
+ updated_at = ?
87
+ WHERE run_id = ?`).run(params.acpSessionId, params.isFreshSession ? 1 : 0, params.isExact ? 1 : 0, params.effectiveSystemPromptText ?? null, params.effectiveContextText ?? null, now, params.runId);
88
+ db.prepare(`UPDATE sessions
89
+ SET acp_session_id = ?,
90
+ system_prompt_text = COALESCE(?, system_prompt_text),
91
+ updated_at = ?
92
+ WHERE session_key = ?`).run(params.acpSessionId, params.effectiveSystemPromptText ?? null, now, params.sessionKey);
93
+ }
94
+ /** Event types worth persisting for history replay */
95
+ const REPLAY_EVENT_TYPES = new Set([
96
+ 'content.delta',
97
+ 'activity.delta',
98
+ 'tool.call',
99
+ 'tool.result',
100
+ 'thinking.delta',
101
+ 'plan.update',
102
+ 'plan.phase',
103
+ 'task.update',
104
+ 'run.usage',
105
+ 'runtime.compact',
106
+ 'run.steer.result',
107
+ ]);
108
+ const DM_TASK_HANDOFF_EVENT_METHOD = 'platform/handoff';
109
+ const PLAN_APPROVAL_MESSAGE_SOURCE = 'plan_approval';
110
+ function adoptProvisionedNodeIdentity(db, pendingNodeId, registeredNodeId, hostname, agentTypesJson, version, processInstanceId, now) {
111
+ db.prepare(`UPDATE agents
112
+ SET node_id = ?
113
+ WHERE node_id = ?`).run(registeredNodeId, pendingNodeId);
114
+ db.prepare(`UPDATE conversations
115
+ SET node_id = ?
116
+ WHERE node_id = ?`).run(registeredNodeId, pendingNodeId);
117
+ db.prepare(`UPDATE nodes
118
+ SET node_id = ?,
119
+ hostname = ?,
120
+ agent_types_json = ?,
121
+ version = ?,
122
+ process_instance_id = ?,
123
+ status = 'online',
124
+ last_seen = ?,
125
+ created_at = CASE WHEN created_at = 0 THEN ? ELSE created_at END
126
+ WHERE node_id = ?`).run(registeredNodeId, hostname, agentTypesJson, version, processInstanceId, now, now, pendingNodeId);
127
+ }
128
+ function persistNodeHostSnapshots(db, params) {
129
+ db.transaction(() => {
130
+ if (params.hosts.length === 0) {
131
+ markAssetMaterializationsClearedForNode(db, params.nodeId, params.snapshotAt);
132
+ db.prepare(`DELETE FROM node_host_snapshots WHERE node_id = ?`).run(params.nodeId);
133
+ return;
134
+ }
135
+ const existingHosts = db.prepare(`SELECT host_key as hostKey, host_instance_id as hostInstanceId
136
+ FROM node_host_snapshots
137
+ WHERE node_id = ?`).all(params.nodeId);
138
+ const activeHostKeys = new Set(params.hosts.map((host) => `${host.hostKey}\u0000${host.hostInstanceId ?? ''}`));
139
+ for (const existingHost of existingHosts) {
140
+ if (!activeHostKeys.has(`${existingHost.hostKey}\u0000${existingHost.hostInstanceId ?? ''}`)) {
141
+ markAssetMaterializationsClearedForHost(db, {
142
+ nodeId: params.nodeId,
143
+ hostKey: existingHost.hostKey,
144
+ hostInstanceId: existingHost.hostInstanceId ?? '',
145
+ clearedAt: params.snapshotAt,
146
+ });
147
+ }
148
+ }
149
+ const placeholders = params.hosts.map(() => '?').join(', ');
150
+ db.prepare(`DELETE FROM node_host_snapshots WHERE node_id = ? AND host_key NOT IN (${placeholders})`).run(params.nodeId, ...params.hosts.map((host) => host.hostKey));
151
+ const upsert = db.prepare(`INSERT INTO node_host_snapshots(
152
+ node_id, host_key, host_instance_id, session_key, workspace_root, agent_type, state, current_run_id,
153
+ has_pending_approval, inbox_size, pending_dispatch_count, last_wake_at, last_sleep_at,
154
+ last_error, resumable, snapshot_at
155
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
156
+ ON CONFLICT(node_id, host_key) DO UPDATE SET
157
+ host_instance_id = excluded.host_instance_id,
158
+ session_key = excluded.session_key,
159
+ workspace_root = excluded.workspace_root,
160
+ agent_type = excluded.agent_type,
161
+ state = excluded.state,
162
+ current_run_id = excluded.current_run_id,
163
+ has_pending_approval = excluded.has_pending_approval,
164
+ inbox_size = excluded.inbox_size,
165
+ pending_dispatch_count = excluded.pending_dispatch_count,
166
+ last_wake_at = excluded.last_wake_at,
167
+ last_sleep_at = excluded.last_sleep_at,
168
+ last_error = excluded.last_error,
169
+ resumable = excluded.resumable,
170
+ snapshot_at = excluded.snapshot_at`);
171
+ for (const host of params.hosts) {
172
+ upsert.run(params.nodeId, host.hostKey, host.hostInstanceId ?? '', host.sessionKey, host.workspaceRoot, host.agentType, host.state, host.currentRunId, host.hasPendingApproval ? 1 : 0, host.inboxSize, host.pendingDispatchCount, host.lastWakeAt, host.lastSleepAt, host.lastError, host.resumable ? 1 : 0, params.snapshotAt);
173
+ }
174
+ })();
175
+ }
176
+ function parseConversationHostKey(hostKey) {
177
+ const match = /^conversation:([^:]+):([^:]+)$/.exec(hostKey);
178
+ if (!match)
179
+ return null;
180
+ return { conversationId: match[1], agentType: match[2] };
181
+ }
182
+ function parseDreamHostKey(hostKey) {
183
+ const match = /^dream:([^:]+):claude_sdk$/.exec(hostKey);
184
+ if (!match)
185
+ return null;
186
+ return { agentId: match[1] };
187
+ }
188
+ function validateDreamNodeHostSnapshot(db, params) {
189
+ const parsed = parseDreamHostKey(params.host.hostKey);
190
+ if (!parsed) {
191
+ return { ok: false, host: params.host, conversationId: null, reason: 'invalid dream host key' };
192
+ }
193
+ if (params.host.agentType !== 'claude_sdk') {
194
+ return { ok: false, host: params.host, conversationId: null, reason: 'dream host must use claude_sdk agent type' };
195
+ }
196
+ const row = db.prepare(`SELECT c.id as conversationId,
197
+ c.node_id as nodeId,
198
+ c.session_key as sessionKey,
199
+ a.agent_id as agentId,
200
+ r.run_id as runId,
201
+ r.session_key as runSessionKey,
202
+ rdi.dispatch_mode as dispatchMode
203
+ FROM agents a
204
+ JOIN conversations c ON c.agent_id = a.agent_id
205
+ AND c.thread_kind = 'direct'
206
+ AND c.is_primary_thread = 1
207
+ LEFT JOIN runs r ON r.run_id = ?
208
+ LEFT JOIN run_debug_inputs rdi ON rdi.run_id = r.run_id
209
+ WHERE a.agent_id = ?`).get(params.host.currentRunId ?? '', parsed.agentId);
210
+ if (!row) {
211
+ return { ok: false, host: params.host, conversationId: null, reason: 'dream agent conversation does not exist' };
212
+ }
213
+ if (row.nodeId !== params.nodeId) {
214
+ return { ok: false, host: params.host, conversationId: row.conversationId, reason: 'dream agent belongs to a different node' };
215
+ }
216
+ if (row.sessionKey !== params.host.sessionKey) {
217
+ return { ok: false, host: params.host, conversationId: row.conversationId, reason: 'dream host session mismatch' };
218
+ }
219
+ if (params.host.currentRunId) {
220
+ if (!row.runId) {
221
+ return { ok: false, host: params.host, conversationId: row.conversationId, reason: 'dream current run does not exist' };
222
+ }
223
+ if (row.runSessionKey !== params.host.sessionKey) {
224
+ return { ok: false, host: params.host, conversationId: row.conversationId, reason: 'dream current run session mismatch' };
225
+ }
226
+ if (row.dispatchMode !== 'dream') {
227
+ return { ok: false, host: params.host, conversationId: row.conversationId, reason: 'dream host current run is not dream dispatch' };
228
+ }
229
+ }
230
+ return { ok: true, host: params.host, conversationId: row.conversationId };
231
+ }
232
+ function isPlatformAgentRole(role) {
233
+ return role === 'platform_agent' || role === 'library_curator' || role === 'suggested_planner';
234
+ }
235
+ function validateNodeHostSnapshot(db, params) {
236
+ if (parseDreamHostKey(params.host.hostKey)) {
237
+ return validateDreamNodeHostSnapshot(db, params);
238
+ }
239
+ const parsed = parseConversationHostKey(params.host.hostKey);
240
+ if (!parsed) {
241
+ return { ok: false, host: params.host, conversationId: null, reason: 'host key is not conversation-scoped' };
242
+ }
243
+ if (parsed.agentType !== params.host.agentType) {
244
+ return {
245
+ ok: false,
246
+ host: params.host,
247
+ conversationId: parsed.conversationId,
248
+ reason: 'host key agent type does not match snapshot',
249
+ };
250
+ }
251
+ const row = db.prepare(`SELECT c.id as conversationId,
252
+ c.node_id as nodeId,
253
+ c.agent_type as agentType,
254
+ c.session_key as sessionKey,
255
+ c.agent_id as agentId,
256
+ a.agent_role as agentRole,
257
+ pai.enabled as platformEnabled,
258
+ pai.deleted_at as platformDeletedAt,
259
+ pad.enabled as definitionEnabled,
260
+ r.run_id as runId,
261
+ r.session_key as runSessionKey
262
+ FROM conversations c
263
+ LEFT JOIN agents a ON a.agent_id = c.agent_id
264
+ LEFT JOIN platform_agent_instances pai ON pai.agent_id = c.agent_id
265
+ LEFT JOIN platform_agent_definitions pad ON pad.definition_id = pai.definition_id
266
+ LEFT JOIN runs r ON r.run_id = ?
267
+ WHERE c.id = ?`).get(params.host.currentRunId ?? '', parsed.conversationId);
268
+ if (!row) {
269
+ return {
270
+ ok: false,
271
+ host: params.host,
272
+ conversationId: parsed.conversationId,
273
+ reason: 'conversation does not exist',
274
+ };
275
+ }
276
+ if (row.nodeId !== params.nodeId) {
277
+ return { ok: false, host: params.host, conversationId: parsed.conversationId, reason: 'conversation belongs to a different node' };
278
+ }
279
+ if (row.agentType !== params.host.agentType) {
280
+ return { ok: false, host: params.host, conversationId: parsed.conversationId, reason: 'conversation agent type mismatch' };
281
+ }
282
+ if (row.sessionKey !== params.host.sessionKey) {
283
+ return { ok: false, host: params.host, conversationId: parsed.conversationId, reason: 'conversation session mismatch' };
284
+ }
285
+ if (params.host.currentRunId) {
286
+ if (!row.runId) {
287
+ const queuedDispatch = db.prepare(`SELECT 1
288
+ FROM conversation_prompt_queue
289
+ WHERE conversation_id = ?
290
+ AND dispatching_run_id = ?
291
+ LIMIT 1`).get(parsed.conversationId, params.host.currentRunId);
292
+ if (!queuedDispatch) {
293
+ return { ok: false, host: params.host, conversationId: parsed.conversationId, reason: 'current run does not exist' };
294
+ }
295
+ }
296
+ else if (row.runSessionKey !== params.host.sessionKey) {
297
+ return { ok: false, host: params.host, conversationId: parsed.conversationId, reason: 'current run session mismatch' };
298
+ }
299
+ }
300
+ if (isPlatformAgentRole(row.agentRole)) {
301
+ if (row.platformEnabled !== 1 || row.platformDeletedAt !== null || row.definitionEnabled !== 1) {
302
+ return { ok: false, host: params.host, conversationId: parsed.conversationId, reason: 'platform agent instance is disabled or deleted' };
303
+ }
304
+ }
305
+ return { ok: true, host: params.host, conversationId: parsed.conversationId };
306
+ }
307
+ function validateNodeHostSnapshots(db, params) {
308
+ const validHosts = [];
309
+ const rejectedHosts = [];
310
+ for (const host of params.hosts) {
311
+ const result = validateNodeHostSnapshot(db, { nodeId: params.nodeId, host });
312
+ if (result.ok) {
313
+ validHosts.push(result.host);
314
+ }
315
+ else {
316
+ rejectedHosts.push(result);
317
+ }
318
+ }
319
+ return { validHosts, rejectedHosts };
320
+ }
321
+ function cleanupRejectedNodeHostSnapshot(db, params) {
322
+ const now = Date.now();
323
+ const conversation = params.conversationId
324
+ ? db.prepare(`SELECT id, session_key as sessionKey
325
+ FROM conversations
326
+ WHERE id = ?`).get(params.conversationId)
327
+ : undefined;
328
+ const sessionOwner = params.host.sessionKey
329
+ ? db.prepare(`SELECT id
330
+ FROM conversations
331
+ WHERE session_key = ?
332
+ LIMIT 1`).get(params.host.sessionKey)
333
+ : undefined;
334
+ const sessionBelongsToRejectedConversation = Boolean(conversation && conversation.sessionKey === params.host.sessionKey);
335
+ const sessionIsUnownedInCore = Boolean(params.host.sessionKey && !sessionOwner);
336
+ const shouldCleanupSessionKey = sessionBelongsToRejectedConversation || sessionIsUnownedInCore;
337
+ const runBelongsToRejectedSession = params.host.currentRunId && sessionBelongsToRejectedConversation
338
+ ? Boolean(db.prepare(`SELECT 1
339
+ FROM runs
340
+ WHERE run_id = ?
341
+ AND session_key = ?
342
+ LIMIT 1`).get(params.host.currentRunId, params.host.sessionKey))
343
+ : false;
344
+ db.transaction(() => {
345
+ db.prepare(`DELETE FROM node_dispatch_queue
346
+ WHERE host_key = ?
347
+ OR conversation_id = ?`).run(params.host.hostKey, params.conversationId ?? '');
348
+ if (shouldCleanupSessionKey) {
349
+ db.prepare(`DELETE FROM node_dispatch_queue WHERE session_key = ?`).run(params.host.sessionKey);
350
+ }
351
+ if (runBelongsToRejectedSession) {
352
+ db.prepare(`DELETE FROM node_dispatch_queue WHERE run_id = ?`).run(params.host.currentRunId);
353
+ }
354
+ db.prepare(`DELETE FROM node_host_snapshots WHERE node_id = ? AND host_key = ?`).run(params.nodeId, params.host.hostKey);
355
+ if (params.conversationId) {
356
+ db.prepare(`UPDATE conversations
357
+ SET status = CASE
358
+ WHEN status IN ('queued', 'active', 'recovering', 'awaiting_approval') THEN 'idle'
359
+ ELSE status
360
+ END
361
+ WHERE id = ?`).run(params.conversationId);
362
+ }
363
+ if (runBelongsToRejectedSession) {
364
+ db.prepare(`UPDATE runs
365
+ SET ended_at = COALESCE(ended_at, ?),
366
+ stop_reason = COALESCE(stop_reason, 'error'),
367
+ error = COALESCE(error, ?)
368
+ WHERE run_id = ?`).run(now, `Rejected stale node host snapshot: ${params.reason}`, params.host.currentRunId);
369
+ }
370
+ })();
371
+ void params.agentRuntimeCleanupBroker?.cleanupAgentRuntime({
372
+ nodeId: params.nodeId,
373
+ agentId: 'node-snapshot-validation',
374
+ hostKeys: [params.host.hostKey],
375
+ sessionKeys: shouldCleanupSessionKey ? [params.host.sessionKey] : [],
376
+ }).catch((error) => {
377
+ log.warn('[node-ws] failed to clean rejected host runtime', {
378
+ nodeId: params.nodeId,
379
+ hostKey: params.host.hostKey,
380
+ sessionKey: params.host.sessionKey,
381
+ reason: params.reason,
382
+ error: String(error?.message ?? error),
383
+ });
384
+ });
385
+ log.warn('[node-ws] rejected invalid node host snapshot', {
386
+ nodeId: params.nodeId,
387
+ hostKey: params.host.hostKey,
388
+ sessionKey: params.host.sessionKey,
389
+ currentRunId: params.host.currentRunId,
390
+ reason: params.reason,
391
+ });
392
+ }
393
+ async function cleanupDisabledPlatformAgentRuntimeForNode(db, params) {
394
+ const rows = db.prepare(`SELECT c.id as conversationId,
395
+ c.agent_type as agentType,
396
+ c.session_key as sessionKey,
397
+ c.agent_id as agentId
398
+ FROM conversations c
399
+ JOIN agents a ON a.agent_id = c.agent_id
400
+ LEFT JOIN platform_agent_instances pai ON pai.agent_id = c.agent_id
401
+ LEFT JOIN platform_agent_definitions pad ON pad.definition_id = pai.definition_id
402
+ WHERE c.node_id = ?
403
+ AND a.agent_role IN ('platform_agent', 'library_curator', 'suggested_planner')
404
+ AND (
405
+ pai.agent_id IS NULL
406
+ OR pai.enabled != 1
407
+ OR pai.deleted_at IS NOT NULL
408
+ OR pad.enabled != 1
409
+ )`).all(params.nodeId);
410
+ if (rows.length === 0)
411
+ return;
412
+ const targetsByAgent = new Map();
413
+ const now = Date.now();
414
+ db.transaction(() => {
415
+ for (const row of rows) {
416
+ const hostKey = `conversation:${row.conversationId}:${row.agentType}`;
417
+ db.prepare(`DELETE FROM node_dispatch_queue
418
+ WHERE host_key = ?
419
+ OR session_key = ?
420
+ OR conversation_id = ?`).run(hostKey, row.sessionKey, row.conversationId);
421
+ db.prepare(`DELETE FROM node_host_snapshots
422
+ WHERE node_id = ?
423
+ AND (host_key = ? OR session_key = ?)`).run(params.nodeId, hostKey, row.sessionKey);
424
+ db.prepare(`DELETE FROM conversation_prompt_queue WHERE conversation_id = ?`).run(row.conversationId);
425
+ db.prepare(`UPDATE conversations
426
+ SET status = CASE
427
+ WHEN status IN ('queued', 'active', 'recovering', 'awaiting_approval') THEN 'idle'
428
+ ELSE status
429
+ END
430
+ WHERE id = ?`).run(row.conversationId);
431
+ db.prepare(`UPDATE runs
432
+ SET ended_at = COALESCE(ended_at, ?),
433
+ stop_reason = COALESCE(stop_reason, 'error'),
434
+ error = COALESCE(error, 'Platform agent instance is disabled or deleted.')
435
+ WHERE session_key = ?
436
+ AND ended_at IS NULL`).run(now, row.sessionKey);
437
+ const group = targetsByAgent.get(row.agentId) ?? { hostKeys: new Set(), sessionKeys: new Set() };
438
+ group.hostKeys.add(hostKey);
439
+ group.sessionKeys.add(row.sessionKey);
440
+ targetsByAgent.set(row.agentId, group);
441
+ }
442
+ })();
443
+ if (!params.agentRuntimeCleanupBroker)
444
+ return;
445
+ for (const [agentId, target] of targetsByAgent.entries()) {
446
+ try {
447
+ await params.agentRuntimeCleanupBroker.cleanupAgentRuntime({
448
+ nodeId: params.nodeId,
449
+ agentId,
450
+ hostKeys: [...target.hostKeys],
451
+ sessionKeys: [...target.sessionKeys],
452
+ });
453
+ }
454
+ catch (error) {
455
+ log.warn('[node-ws] disabled platform runtime cleanup during node register failed', {
456
+ nodeId: params.nodeId,
457
+ agentId,
458
+ error: String(error?.message ?? error),
459
+ });
460
+ }
461
+ }
462
+ }
463
+ function recordAssetMaterialized(db, params) {
464
+ db.prepare(`INSERT INTO asset_materializations(
465
+ asset_id, agent_id, conversation_id, node_id, node_process_instance_id,
466
+ host_key, host_instance_id, run_id, local_path, materialized_at, cleared_at
467
+ )
468
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
469
+ ON CONFLICT(asset_id, agent_id, conversation_id, node_id, node_process_instance_id, host_key, host_instance_id) DO UPDATE SET
470
+ run_id = excluded.run_id,
471
+ local_path = excluded.local_path,
472
+ materialized_at = excluded.materialized_at,
473
+ cleared_at = NULL`).run(params.assetId, params.agentId, params.conversationId, params.nodeId, params.nodeProcessInstanceId, params.hostKey, params.hostInstanceId, params.runId ?? null, params.localPath, params.materializedAt);
474
+ }
475
+ function upsertMaterializedHostSnapshot(db, params) {
476
+ const row = db.prepare(`SELECT session_key as sessionKey,
477
+ COALESCE(workspace_path, '') as workspaceRoot,
478
+ agent_type as agentType,
479
+ status
480
+ FROM conversations
481
+ WHERE id = ?`).get(params.conversationId);
482
+ if (!row)
483
+ return;
484
+ const hostState = row.status === 'failed' ? 'failed' : (row.status === 'idle' ? 'idle' : 'active');
485
+ db.prepare(`INSERT INTO node_host_snapshots(
486
+ node_id, host_key, host_instance_id, session_key, workspace_root, agent_type, state, current_run_id,
487
+ has_pending_approval, inbox_size, pending_dispatch_count, last_wake_at, last_sleep_at,
488
+ last_error, resumable, snapshot_at
489
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, NULL, NULL, 1, ?)
490
+ ON CONFLICT(node_id, host_key) DO UPDATE SET
491
+ state = CASE
492
+ WHEN node_host_snapshots.host_instance_id != excluded.host_instance_id THEN excluded.state
493
+ ELSE node_host_snapshots.state
494
+ END,
495
+ host_instance_id = excluded.host_instance_id,
496
+ session_key = excluded.session_key,
497
+ workspace_root = excluded.workspace_root,
498
+ agent_type = excluded.agent_type,
499
+ current_run_id = COALESCE(excluded.current_run_id, node_host_snapshots.current_run_id),
500
+ has_pending_approval = CASE
501
+ WHEN node_host_snapshots.host_instance_id != excluded.host_instance_id THEN excluded.has_pending_approval
502
+ ELSE node_host_snapshots.has_pending_approval
503
+ END,
504
+ inbox_size = CASE
505
+ WHEN node_host_snapshots.host_instance_id != excluded.host_instance_id THEN excluded.inbox_size
506
+ ELSE node_host_snapshots.inbox_size
507
+ END,
508
+ pending_dispatch_count = CASE
509
+ WHEN node_host_snapshots.host_instance_id != excluded.host_instance_id THEN excluded.pending_dispatch_count
510
+ ELSE node_host_snapshots.pending_dispatch_count
511
+ END,
512
+ last_error = CASE
513
+ WHEN node_host_snapshots.host_instance_id != excluded.host_instance_id THEN excluded.last_error
514
+ ELSE node_host_snapshots.last_error
515
+ END,
516
+ resumable = CASE
517
+ WHEN node_host_snapshots.host_instance_id != excluded.host_instance_id THEN excluded.resumable
518
+ ELSE node_host_snapshots.resumable
519
+ END,
520
+ snapshot_at = MAX(node_host_snapshots.snapshot_at, excluded.snapshot_at)
521
+ WHERE node_host_snapshots.host_instance_id = excluded.host_instance_id
522
+ OR node_host_snapshots.snapshot_at <= excluded.snapshot_at`).run(params.nodeId, params.hostKey, params.hostInstanceId, row.sessionKey, row.workspaceRoot, row.agentType, hostState, params.runId ?? null, row.status === 'awaiting_approval' ? 1 : 0, params.snapshotAt);
523
+ }
524
+ function markAssetMaterializationsClearedForHost(db, params) {
525
+ db.prepare(`UPDATE asset_materializations
526
+ SET cleared_at = COALESCE(cleared_at, ?)
527
+ WHERE node_id = ?
528
+ AND host_key = ?
529
+ AND host_instance_id = ?
530
+ AND cleared_at IS NULL`).run(params.clearedAt, params.nodeId, params.hostKey, params.hostInstanceId);
531
+ }
532
+ function markAssetMaterializationsClearedForNode(db, nodeId, clearedAt) {
533
+ db.prepare(`UPDATE asset_materializations
534
+ SET cleared_at = COALESCE(cleared_at, ?)
535
+ WHERE node_id = ?
536
+ AND cleared_at IS NULL`).run(clearedAt, nodeId);
537
+ }
538
+ function persistNodeRuntimeSnapshot(db, params) {
539
+ db.prepare(`INSERT INTO node_runtime_snapshots(
540
+ node_id, process_instance_id, workspace_root, terminal_backend_available, runtime_drivers_json, capabilities_json, snapshot_at
541
+ ) VALUES(?, ?, ?, ?, ?, ?, ?)
542
+ ON CONFLICT(node_id) DO UPDATE SET
543
+ process_instance_id = excluded.process_instance_id,
544
+ workspace_root = excluded.workspace_root,
545
+ terminal_backend_available = excluded.terminal_backend_available,
546
+ runtime_drivers_json = excluded.runtime_drivers_json,
547
+ capabilities_json = excluded.capabilities_json,
548
+ snapshot_at = excluded.snapshot_at`).run(params.nodeId, params.processInstanceId ?? null, params.runtime.workspaceRoot, params.runtime.terminalBackendAvailable ? 1 : 0, JSON.stringify(params.runtime.runtimeDrivers), JSON.stringify(params.runtime.capabilities), params.snapshotAt);
549
+ }
550
+ function requiresMcpReplyContract(db, conversationId) {
551
+ const row = db
552
+ .prepare('SELECT agent_id as agentId FROM conversations WHERE id = ?')
553
+ .get(conversationId);
554
+ return Boolean(row?.agentId);
555
+ }
556
+ function isPlatformDebugRun(db, runId) {
557
+ const row = db.prepare(`SELECT activation_metadata_json as activationMetadataJson
558
+ FROM run_debug_inputs
559
+ WHERE run_id = ?`).get(runId);
560
+ if (!row?.activationMetadataJson)
561
+ return false;
562
+ try {
563
+ const parsed = JSON.parse(row.activationMetadataJson);
564
+ const legacyRole = parsed.platformRun?.role ?? parsed.role;
565
+ if ((legacyRole === 'suggested_planner' || legacyRole === 'library_curator')
566
+ && parsed.platformRun?.mode === 'debug') {
567
+ return true;
568
+ }
569
+ return (typeof parsed.platformRun?.definitionId === 'string'
570
+ && typeof parsed.platformRun?.workflowType === 'string'
571
+ && parsed.platformRun?.mode === 'debug');
572
+ }
573
+ catch {
574
+ return false;
575
+ }
576
+ }
577
+ function hasRunFinalReplyMessage(db, runId) {
578
+ const row = db
579
+ .prepare(`SELECT COUNT(1) as count
580
+ FROM channel_messages
581
+ WHERE run_id = ?
582
+ AND sender_type = 'agent'
583
+ AND message_kind = 'final'`)
584
+ .get(runId);
585
+ return (row?.count ?? 0) > 0;
586
+ }
587
+ function getRunFinalReplyMessage(db, runId) {
588
+ const row = db.prepare(`SELECT message_id as messageId,
589
+ sender_id as senderId,
590
+ sender_name as senderName,
591
+ content,
592
+ seq,
593
+ created_at as createdAt,
594
+ message_source as messageSource,
595
+ tool_ids as toolIds
596
+ FROM channel_messages
597
+ WHERE run_id = ?
598
+ AND sender_type = 'agent'
599
+ AND message_kind = 'final'
600
+ AND deleted_at IS NULL
601
+ ORDER BY created_at DESC, seq DESC
602
+ LIMIT 1`).get(runId);
603
+ return row ?? null;
604
+ }
605
+ function normalizeWorkspaceToolPublishFallbackText(value) {
606
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
607
+ }
608
+ function buildWorkspaceToolPublishFallbackContent(toolRows) {
609
+ if (toolRows.length === 0) {
610
+ return 'Published the workspace tool.';
611
+ }
612
+ if (toolRows.length === 1) {
613
+ const tool = toolRows[0];
614
+ const label = normalizeWorkspaceToolPublishFallbackText(tool.name) || normalizeWorkspaceToolPublishFallbackText(tool.slug) || 'Workspace Tool';
615
+ const description = normalizeWorkspaceToolPublishFallbackText(tool.description);
616
+ const suffix = description ? ` ${description}` : '';
617
+ return `Published Workspace Tool: ${label}.${suffix}`.slice(0, 800);
618
+ }
619
+ const labels = toolRows
620
+ .map((tool) => normalizeWorkspaceToolPublishFallbackText(tool.name) || normalizeWorkspaceToolPublishFallbackText(tool.slug))
621
+ .filter(Boolean);
622
+ return `Published Workspace Tools: ${labels.join(', ')}.`.slice(0, 800);
623
+ }
624
+ function parseWorkspaceToolIdsJson(value) {
625
+ if (!value)
626
+ return [];
627
+ try {
628
+ const parsed = JSON.parse(value);
629
+ if (!Array.isArray(parsed))
630
+ return [];
631
+ const toolIds = [];
632
+ const seen = new Set();
633
+ for (const rawToolId of parsed) {
634
+ const toolId = typeof rawToolId === 'string' ? rawToolId.trim() : '';
635
+ if (!toolId || seen.has(toolId))
636
+ continue;
637
+ seen.add(toolId);
638
+ toolIds.push(toolId);
639
+ }
640
+ return toolIds;
641
+ }
642
+ catch {
643
+ return [];
644
+ }
645
+ }
646
+ function consumePendingWorkspaceToolAttachments(params) {
647
+ const toolIds = [...new Set(params.toolIds.map((toolId) => toolId.trim()).filter(Boolean))];
648
+ if (toolIds.length === 0)
649
+ return 0;
650
+ const placeholders = toolIds.map(() => '?').join(', ');
651
+ const result = params.db.prepare(`DELETE FROM run_pending_tool_attachments
652
+ WHERE run_id = ?
653
+ AND conversation_id = ?
654
+ AND agent_id = ?
655
+ AND tool_id IN (${placeholders})`).run(params.runId, params.conversationId, params.agentId, ...toolIds);
656
+ return Number(result.changes ?? 0);
657
+ }
658
+ function persistPendingWorkspaceToolPublishFallback(params) {
659
+ const emptyResult = { emittedCount: 0, updatedCount: 0, toolIds: [] };
660
+ if (!requiresMcpReplyContract(params.db, params.conversationId))
661
+ return emptyResult;
662
+ const context = getFallbackMessageContext(params.db, params.conversationId, params.manager.getConfig().humanUserName);
663
+ if (!context)
664
+ return emptyResult;
665
+ const toolRows = params.db.prepare(`SELECT pending.tool_id as toolId,
666
+ tools.name as name,
667
+ tools.description as description,
668
+ tools.slug as slug,
669
+ tools.user_id as userId
670
+ FROM run_pending_tool_attachments pending
671
+ JOIN workspace_tools tools ON tools.tool_id = pending.tool_id
672
+ WHERE pending.run_id = ?
673
+ AND pending.conversation_id = ?
674
+ AND pending.agent_id = ?
675
+ AND tools.agent_id = pending.agent_id
676
+ AND tools.deleted_at IS NULL
677
+ ORDER BY pending.created_at ASC`).all(params.runId, params.conversationId, context.agentId);
678
+ if (toolRows.length === 0)
679
+ return emptyResult;
680
+ const allToolIds = toolRows.map((row) => row.toolId);
681
+ const consumeToolIds = (toolIds) => consumePendingWorkspaceToolAttachments({
682
+ db: params.db,
683
+ runId: params.runId,
684
+ conversationId: params.conversationId,
685
+ agentId: context.agentId,
686
+ toolIds,
687
+ });
688
+ if (context.agentType !== 'codex_app_server'
689
+ || !context.channelId.startsWith('dm:')
690
+ || context.threadRootId
691
+ || !context.userId) {
692
+ consumeToolIds(allToolIds);
693
+ return emptyResult;
694
+ }
695
+ const attachableToolRows = toolRows.filter((row) => row.userId === context.userId);
696
+ const attachableToolIds = new Set(attachableToolRows.map((row) => row.toolId));
697
+ const invalidToolIds = allToolIds.filter((toolId) => !attachableToolIds.has(toolId));
698
+ if (invalidToolIds.length > 0) {
699
+ consumeToolIds(invalidToolIds);
700
+ }
701
+ if (attachableToolRows.length === 0)
702
+ return emptyResult;
703
+ const toolIds = attachableToolRows.map((row) => row.toolId);
704
+ const existingFinal = getRunFinalReplyMessage(params.db, params.runId);
705
+ if (existingFinal) {
706
+ const mergedToolIds = parseWorkspaceToolIdsJson(existingFinal.toolIds);
707
+ const seenToolIds = new Set(mergedToolIds);
708
+ for (const toolId of toolIds) {
709
+ if (seenToolIds.has(toolId))
710
+ continue;
711
+ seenToolIds.add(toolId);
712
+ mergedToolIds.push(toolId);
713
+ }
714
+ consumeToolIds(toolIds);
715
+ if (JSON.stringify(mergedToolIds) === (existingFinal.toolIds ?? null)) {
716
+ return emptyResult;
717
+ }
718
+ const toolIdsJson = JSON.stringify(mergedToolIds);
719
+ params.db.prepare(`UPDATE channel_messages
720
+ SET tool_ids = ?
721
+ WHERE message_id = ?`).run(toolIdsJson, existingFinal.messageId);
722
+ params.broadcast(params.conversationId, {
723
+ type: 'channel.message',
724
+ message: {
725
+ id: existingFinal.messageId,
726
+ senderId: existingFinal.senderId,
727
+ senderName: existingFinal.senderName,
728
+ senderType: 'agent',
729
+ content: existingFinal.content,
730
+ createdAt: new Date(existingFinal.createdAt).toISOString(),
731
+ seq: existingFinal.seq,
732
+ runId: params.runId,
733
+ messageSource: existingFinal.messageSource ?? 'agent_send',
734
+ messageKind: 'final',
735
+ toolIds: mergedToolIds,
736
+ },
737
+ });
738
+ refreshConversationSummariesForConversationIds(params.db, (agentId) => params.manager.getAgent(agentId), [params.conversationId]);
739
+ return { emittedCount: 0, updatedCount: 1, toolIds: mergedToolIds };
740
+ }
741
+ const now = Date.now();
742
+ const seq = allocateNextChannelMessageSeq(params.db, context.channelId);
743
+ const messageId = randomUUID();
744
+ const runEventSeqCutoff = getRunEventSeqCutoff(params.db, params.runId);
745
+ const content = buildWorkspaceToolPublishFallbackContent(attachableToolRows);
746
+ const toolIdsJson = JSON.stringify(toolIds);
747
+ params.db.prepare(`INSERT INTO channel_messages(message_id, channel_id, sender_id, sender_name, sender_type, target, content, seq, created_at, run_id, thread_root_id, message_kind, message_source, tool_ids, run_event_seq_cutoff)
748
+ VALUES(?, ?, ?, ?, 'agent', ?, ?, ?, ?, ?, ?, 'final', 'workspace_tool_publish_fallback', ?, ?)`).run(messageId, context.channelId, context.agentId, context.agentName, context.target, content, seq, now, params.runId, context.threadRootId, toolIdsJson, runEventSeqCutoff);
749
+ consumeToolIds(toolIds);
750
+ params.broadcast(params.conversationId, {
751
+ type: 'channel.message',
752
+ message: {
753
+ id: messageId,
754
+ senderId: context.agentId,
755
+ senderName: context.agentName,
756
+ senderType: 'agent',
757
+ content,
758
+ createdAt: new Date(now).toISOString(),
759
+ seq,
760
+ runId: params.runId,
761
+ messageSource: 'workspace_tool_publish_fallback',
762
+ messageKind: 'final',
763
+ toolIds,
764
+ ...(context.threadRootId ? { threadRootId: context.threadRootId } : {}),
765
+ },
766
+ });
767
+ refreshConversationSummariesForConversationIds(params.db, (agentId) => params.manager.getAgent(agentId), [params.conversationId]);
768
+ return { emittedCount: 1, updatedCount: 0, toolIds };
769
+ }
770
+ function isCancelStopReason(stopReason) {
771
+ return Boolean(stopReason?.includes('cancel'));
772
+ }
773
+ function isWorkspaceToolPublishFallbackRecoverableRunEndError(error) {
774
+ return String(error ?? '').toLowerCase().includes('assistant text repair exceeded retry cap');
775
+ }
776
+ function normalizeComparisonText(text) {
777
+ return text.replace(/\s+/g, ' ').trim();
778
+ }
779
+ function stripLegacyStatusText(text) {
780
+ return text
781
+ .split('\n')
782
+ .filter((line) => !/^\s*(?:-\s*)?(?:\[(?:plan|task)\]\s*)?(?:plan|task)\s+updated\s*$/i.test(line))
783
+ .filter((line) => !/^\s*(?:-\s*)?\[(?:plan|task)\]\b/i.test(line))
784
+ .join('\n');
785
+ }
786
+ function stripFallbackNoiseTokens(text) {
787
+ return text
788
+ .replace(/\[(?:plan|task)\]\s*(?:plan|task)\s+updated/ig, '')
789
+ .replace(/(?:plan|task)\s+updated/ig, '');
790
+ }
791
+ function stripInternalReminderTail(text) {
792
+ return text
793
+ .replace(/\(?System reminder acknowledged[\s\S]*$/i, '')
794
+ .trim();
795
+ }
796
+ function cleanFallbackText(text) {
797
+ return stripInternalReminderTail(stripFallbackNoiseTokens(stripLegacyStatusText(text))).trim();
798
+ }
799
+ function isIgnorablePlanOrTaskFallbackText(text) {
800
+ const normalized = text.trim().toLowerCase();
801
+ return normalized === 'plan updated'
802
+ || normalized === 'task updated'
803
+ || normalized === '[plan] plan updated'
804
+ || normalized === '[task] task updated';
805
+ }
806
+ function isIgnorableFallbackText(text) {
807
+ return text.includes(`Empty response: {'content':`)
808
+ || isIgnorablePlanOrTaskFallbackText(text);
809
+ }
810
+ function hasSubstantiveFallbackText(text) {
811
+ return text.trim().length > 0;
812
+ }
813
+ function listRunAgentMessages(db, runId) {
814
+ return db.prepare(`SELECT content, created_at, channel_id, seq
815
+ FROM channel_messages
816
+ WHERE run_id = ?
817
+ AND sender_type = 'agent'
818
+ ORDER BY created_at ASC, seq ASC`).all(runId);
819
+ }
820
+ function listRunDeltaEvents(db, runId) {
821
+ const deltas = [];
822
+ for (const row of iterateParsedNodeEvents(db, {
823
+ runId,
824
+ types: ['content.delta'],
825
+ })) {
826
+ if (row.event?.type === 'content.delta') {
827
+ deltas.push({
828
+ createdAt: row.createdAt,
829
+ seq: row.seq,
830
+ text: row.event.text,
831
+ });
832
+ }
833
+ }
834
+ return deltas;
835
+ }
836
+ function collectFallbackSegments(db, runId) {
837
+ const replyRows = listRunAgentMessages(db, runId);
838
+ const deltaRows = listRunDeltaEvents(db, runId);
839
+ if (deltaRows.length === 0)
840
+ return [];
841
+ const timeline = [
842
+ ...replyRows.map((row, index) => ({ type: 'message', createdAt: row.created_at, order: index })),
843
+ ...deltaRows.map((row, index) => ({ type: 'delta', createdAt: row.createdAt, order: index, text: row.text, seq: row.seq })),
844
+ ].sort((a, b) => {
845
+ if (a.createdAt !== b.createdAt)
846
+ return a.createdAt - b.createdAt;
847
+ if (a.type !== b.type)
848
+ return a.type === 'delta' ? -1 : 1;
849
+ return a.order - b.order;
850
+ });
851
+ let buffer = '';
852
+ const rawSegments = [];
853
+ const flushBuffer = () => {
854
+ if (!buffer)
855
+ return;
856
+ rawSegments.push(buffer);
857
+ buffer = '';
858
+ };
859
+ for (const item of timeline) {
860
+ if (item.type === 'delta') {
861
+ buffer += item.text;
862
+ continue;
863
+ }
864
+ flushBuffer();
865
+ }
866
+ flushBuffer();
867
+ const existingNormalized = new Set(replyRows.map((row) => normalizeComparisonText(cleanFallbackText(row.content))).filter(Boolean));
868
+ const emittedNormalized = new Set();
869
+ return rawSegments
870
+ .map((segment) => cleanFallbackText(segment))
871
+ .filter((segment) => hasSubstantiveFallbackText(segment))
872
+ .filter((segment) => !isIgnorableFallbackText(segment))
873
+ .filter((segment) => {
874
+ const normalized = normalizeComparisonText(segment);
875
+ if (!normalized)
876
+ return false;
877
+ if (existingNormalized.has(normalized) || emittedNormalized.has(normalized))
878
+ return false;
879
+ emittedNormalized.add(normalized);
880
+ return true;
881
+ });
882
+ }
883
+ function getFallbackMessageContext(db, conversationId, humanUserName) {
884
+ const row = db.prepare(`SELECT c.id as conversationId,
885
+ c.agent_id as agentId,
886
+ c.channel_id as channelId,
887
+ c.thread_kind as threadKind,
888
+ c.is_primary_thread as isPrimaryThread,
889
+ c.thread_root_id as threadRootId,
890
+ c.user_id as userId,
891
+ c.reply_target as replyTarget,
892
+ ch.name as channelName,
893
+ a.name as agentName,
894
+ a.agent_type as agentType
895
+ FROM conversations c
896
+ JOIN agents a ON a.agent_id = c.agent_id
897
+ LEFT JOIN channels ch ON ch.channel_id = c.channel_id
898
+ WHERE c.id = ?`).get(conversationId);
899
+ if (!row?.agentId)
900
+ return null;
901
+ const target = resolveConversationReplyTarget(db, conversationId, humanUserName)
902
+ ?? `${`#${row.channelName ?? row.channelId}`}${row.threadRootId ? `:${row.threadRootId}` : ''}`;
903
+ return {
904
+ agentId: row.agentId,
905
+ agentName: row.agentName,
906
+ agentType: row.agentType,
907
+ channelId: row.threadKind === 'direct' ? `dm:${row.agentId}` : row.channelId,
908
+ target,
909
+ threadRootId: row.threadRootId ?? null,
910
+ userId: row.threadKind === 'direct'
911
+ ? row.userId?.trim() || resolveDirectUserIdFromReplyTarget(db, row.replyTarget)
912
+ : row.userId ?? null,
913
+ };
914
+ }
915
+ function getPlanTextFromApprovalRequest(msg) {
916
+ const input = msg.input && typeof msg.input === 'object' && !Array.isArray(msg.input)
917
+ ? msg.input
918
+ : null;
919
+ if (typeof input?.plan === 'string' && input.plan.trim())
920
+ return input.plan.trim();
921
+ const args = msg.toolArgs && typeof msg.toolArgs === 'object' && !Array.isArray(msg.toolArgs)
922
+ ? msg.toolArgs
923
+ : null;
924
+ if (typeof args?.plan === 'string' && args.plan.trim())
925
+ return args.plan.trim();
926
+ return null;
927
+ }
928
+ function upsertPlanApprovalMessage(params) {
929
+ const context = getFallbackMessageContext(params.db, params.conversationId, params.manager.getConfig().humanUserName);
930
+ if (!context)
931
+ return;
932
+ const now = Date.now();
933
+ const existing = params.db.prepare(`SELECT message_id as messageId, seq, created_at as createdAt
934
+ FROM channel_messages
935
+ WHERE run_id = ?
936
+ AND message_source = ?
937
+ AND message_kind = 'plan'
938
+ ORDER BY created_at DESC, seq DESC
939
+ LIMIT 1`).get(params.runId, PLAN_APPROVAL_MESSAGE_SOURCE);
940
+ if (existing) {
941
+ const runEventSeqCutoff = getRunEventSeqCutoff(params.db, params.runId);
942
+ params.db.prepare(`UPDATE channel_messages
943
+ SET content = ?,
944
+ run_event_seq_cutoff = ?
945
+ WHERE message_id = ?`).run(params.planText, runEventSeqCutoff, existing.messageId);
946
+ const fileRefs = params.agentFileAccessService
947
+ ? params.agentFileAccessService.getFileRefsForMessage({
948
+ agentId: context.agentId,
949
+ runId: params.runId,
950
+ content: params.planText,
951
+ createdAt: existing.createdAt,
952
+ runEventSeqCutoff,
953
+ })
954
+ : undefined;
955
+ params.broadcast(params.conversationId, {
956
+ type: 'channel.message',
957
+ message: {
958
+ id: existing.messageId,
959
+ senderId: context.agentId,
960
+ senderName: context.agentName,
961
+ senderType: 'agent',
962
+ content: params.planText,
963
+ createdAt: new Date(existing.createdAt).toISOString(),
964
+ seq: existing.seq,
965
+ runId: params.runId,
966
+ messageSource: PLAN_APPROVAL_MESSAGE_SOURCE,
967
+ messageKind: 'plan',
968
+ ...(fileRefs ? { fileRefs } : {}),
969
+ ...(context.threadRootId ? { threadRootId: context.threadRootId } : {}),
970
+ },
971
+ });
972
+ return;
973
+ }
974
+ const seq = allocateNextChannelMessageSeq(params.db, context.channelId);
975
+ const messageId = randomUUID();
976
+ const runEventSeqCutoff = getRunEventSeqCutoff(params.db, params.runId);
977
+ params.db.prepare(`INSERT INTO channel_messages(message_id, channel_id, sender_id, sender_name, sender_type, target, content, seq, created_at, run_id, thread_root_id, message_kind, message_source, run_event_seq_cutoff)
978
+ VALUES(?, ?, ?, ?, 'agent', ?, ?, ?, ?, ?, ?, 'plan', ?, ?)`).run(messageId, context.channelId, context.agentId, context.agentName, context.target, params.planText, seq, now, params.runId, context.threadRootId, PLAN_APPROVAL_MESSAGE_SOURCE, runEventSeqCutoff);
979
+ const fileRefs = params.agentFileAccessService
980
+ ? params.agentFileAccessService.getFileRefsForMessage({
981
+ agentId: context.agentId,
982
+ runId: params.runId,
983
+ content: params.planText,
984
+ createdAt: now,
985
+ runEventSeqCutoff,
986
+ })
987
+ : undefined;
988
+ params.broadcast(params.conversationId, {
989
+ type: 'channel.message',
990
+ message: {
991
+ id: messageId,
992
+ senderId: context.agentId,
993
+ senderName: context.agentName,
994
+ senderType: 'agent',
995
+ content: params.planText,
996
+ createdAt: new Date(now).toISOString(),
997
+ seq,
998
+ runId: params.runId,
999
+ messageSource: PLAN_APPROVAL_MESSAGE_SOURCE,
1000
+ messageKind: 'plan',
1001
+ ...(fileRefs ? { fileRefs } : {}),
1002
+ ...(context.threadRootId ? { threadRootId: context.threadRootId } : {}),
1003
+ },
1004
+ });
1005
+ refreshConversationSummariesForConversationIds(params.db, (agentId) => params.manager.getAgent(agentId), [params.conversationId]);
1006
+ }
1007
+ function persistDeltaFallbackMessages(params) {
1008
+ const emptyResult = { emittedCount: 0, suppressedCount: 0 };
1009
+ if (!requiresMcpReplyContract(params.db, params.conversationId))
1010
+ return emptyResult;
1011
+ const platformDebugRun = isPlatformDebugRun(params.db, params.runId);
1012
+ const context = getFallbackMessageContext(params.db, params.conversationId, params.manager.getConfig().humanUserName);
1013
+ if (!context)
1014
+ return emptyResult;
1015
+ const boundTask = context.threadRootId
1016
+ ? getBoundTaskForThread(params.db, {
1017
+ channelId: context.channelId,
1018
+ threadRootId: context.threadRootId,
1019
+ })
1020
+ : undefined;
1021
+ if (platformDebugRun && hasRunFinalReplyMessage(params.db, params.runId)) {
1022
+ return {
1023
+ emittedCount: 0,
1024
+ suppressedCount: 0,
1025
+ suppressedReason: 'final_reply',
1026
+ };
1027
+ }
1028
+ if (!platformDebugRun && (context.agentType === 'codex_app_server' || context.agentType === 'claude_sdk')) {
1029
+ return {
1030
+ emittedCount: 0,
1031
+ suppressedCount: 0,
1032
+ suppressedReason: context.agentType,
1033
+ };
1034
+ }
1035
+ const segments = collectFallbackSegments(params.db, params.runId);
1036
+ if (segments.length === 0)
1037
+ return emptyResult;
1038
+ if (context.threadRootId
1039
+ && boundTask
1040
+ && !context.channelId.startsWith('dm:')
1041
+ && (boundTask.status === 'in_review' || boundTask.status === 'done')) {
1042
+ return {
1043
+ emittedCount: 0,
1044
+ suppressedCount: segments.length,
1045
+ suppressedReason: 'closed_task_thread',
1046
+ };
1047
+ }
1048
+ let createdAt = Date.now();
1049
+ let emittedCount = 0;
1050
+ for (const content of segments) {
1051
+ const seq = allocateNextChannelMessageSeq(params.db, context.channelId);
1052
+ const messageId = randomUUID();
1053
+ const runEventSeqCutoff = getRunEventSeqCutoff(params.db, params.runId);
1054
+ params.db.prepare(`INSERT INTO channel_messages(message_id, channel_id, sender_id, sender_name, sender_type, target, content, seq, created_at, run_id, thread_root_id, message_kind, message_source, run_event_seq_cutoff)
1055
+ VALUES(?, ?, ?, ?, 'agent', ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(messageId, context.channelId, context.agentId, context.agentName, context.target, content, seq, createdAt, params.runId, context.threadRootId, null, 'delta_fallback', runEventSeqCutoff);
1056
+ const fileRefs = params.agentFileAccessService
1057
+ ? params.agentFileAccessService.getFileRefsForMessage({
1058
+ agentId: context.agentId,
1059
+ runId: params.runId,
1060
+ content,
1061
+ createdAt,
1062
+ runEventSeqCutoff,
1063
+ })
1064
+ : [];
1065
+ params.broadcast(params.conversationId, {
1066
+ type: 'channel.message',
1067
+ message: {
1068
+ id: messageId,
1069
+ senderId: context.agentId,
1070
+ senderName: context.agentName,
1071
+ senderType: 'agent',
1072
+ content,
1073
+ createdAt: new Date(createdAt).toISOString(),
1074
+ seq,
1075
+ runId: params.runId,
1076
+ ...(fileRefs.length > 0 ? { fileRefs } : {}),
1077
+ messageSource: 'delta_fallback',
1078
+ ...(context.threadRootId ? { threadRootId: context.threadRootId } : {}),
1079
+ },
1080
+ });
1081
+ createdAt += 1;
1082
+ emittedCount += 1;
1083
+ }
1084
+ return { emittedCount, suppressedCount: 0 };
1085
+ }
1086
+ function isDreamDispatchRun(db, runId) {
1087
+ const row = db.prepare(`SELECT dispatch_mode as dispatchMode
1088
+ FROM run_debug_inputs
1089
+ WHERE run_id = ?
1090
+ LIMIT 1`).get(runId);
1091
+ return row?.dispatchMode === 'dream';
1092
+ }
1093
+ function getRunEndError(msg, db, conversationId, runId, wasHandedOff = false, expectedBootstrapStopReason) {
1094
+ if (msg.error)
1095
+ return msg.error;
1096
+ if (isDreamDispatchRun(db, runId))
1097
+ return null;
1098
+ if (isCancelStopReason(msg.stopReason)) {
1099
+ if (wasHandedOff || expectedBootstrapStopReason === DM_HANDOFF_BOOTSTRAP_STOP_REASON)
1100
+ return null;
1101
+ if (hasRunFinalReplyMessage(db, runId))
1102
+ return null;
1103
+ if (requiresMcpReplyContract(db, conversationId)) {
1104
+ return 'Agent run was cancelled before sending a final reply';
1105
+ }
1106
+ return 'Run cancelled before completion';
1107
+ }
1108
+ return null;
1109
+ }
1110
+ function wasRunHandedOff(db, runId) {
1111
+ return Boolean(db.prepare(`SELECT 1
1112
+ FROM events
1113
+ WHERE run_id = ?
1114
+ AND method = ?
1115
+ LIMIT 1`).get(runId, DM_TASK_HANDOFF_EVENT_METHOD));
1116
+ }
1117
+ function updateConversationStatus(db, broadcast, conversationId, status) {
1118
+ db.prepare('UPDATE conversations SET status = ?, updated_at = ? WHERE id = ?')
1119
+ .run(status, Date.now(), conversationId);
1120
+ broadcast(conversationId, {
1121
+ type: 'conversation.status',
1122
+ conversationId,
1123
+ status,
1124
+ });
1125
+ }
1126
+ function getWorkspaceRunContext(db, runId) {
1127
+ const row = db.prepare(`SELECT ws.workspace_session_id as workspaceSessionId,
1128
+ ws.session_key as sessionKey,
1129
+ r.ended_at as endedAt
1130
+ FROM runs r
1131
+ JOIN workspace_sessions ws ON ws.session_key = r.session_key
1132
+ WHERE r.run_id = ?
1133
+ LIMIT 1`).get(runId);
1134
+ return row ?? null;
1135
+ }
1136
+ function updateWorkspaceSessionStatus(db, workspaceSessionId, status) {
1137
+ const now = Date.now();
1138
+ db.prepare(`UPDATE workspace_sessions
1139
+ SET status = ?,
1140
+ updated_at = ?,
1141
+ last_active_at = ?
1142
+ WHERE workspace_session_id = ?`).run(status, now, now, workspaceSessionId);
1143
+ }
1144
+ function resolveWorkspaceSessionStatus(db, sessionKey, params) {
1145
+ if (hasOpenPendingWorkspaceApproval(db, sessionKey))
1146
+ return 'awaiting_approval';
1147
+ return getNextOpenWorkspaceRunId(db, sessionKey) ? params.openStatus : params.closedStatus;
1148
+ }
1149
+ function getNextOpenWorkspaceRunId(db, sessionKey) {
1150
+ const row = db.prepare(`SELECT run_id as runId
1151
+ FROM runs
1152
+ WHERE session_key = ?
1153
+ AND ended_at IS NULL
1154
+ ORDER BY started_at ASC, rowid ASC
1155
+ LIMIT 1`).get(sessionKey);
1156
+ return row?.runId ?? null;
1157
+ }
1158
+ function workspaceRunBelongsToCurrentNode(db, runId, currentNodeId) {
1159
+ const ownerNodeId = getWorkspaceRunDispatchNodeId(db, runId);
1160
+ return Boolean(ownerNodeId && currentNodeId && ownerNodeId === currentNodeId);
1161
+ }
1162
+ function listOpenWorkspaceRunsForNode(db, nodeId) {
1163
+ return db.prepare(`SELECT r.run_id as runId,
1164
+ ws.workspace_session_id as workspaceSessionId,
1165
+ ws.session_key as sessionKey
1166
+ FROM runs r
1167
+ JOIN workspace_sessions ws ON ws.session_key = r.session_key
1168
+ WHERE r.ended_at IS NULL
1169
+ AND EXISTS (
1170
+ SELECT 1
1171
+ FROM events owner
1172
+ WHERE owner.run_id = r.run_id
1173
+ AND owner.method = ?
1174
+ AND json_valid(owner.payload_json)
1175
+ AND json_extract(owner.payload_json, '$.nodeId') = ?
1176
+ )`).all(WORKSPACE_RUN_DISPATCH_METHOD, nodeId);
1177
+ }
1178
+ function buildBroadcastNodeEvent(event) {
1179
+ return event.type === 'tool.call'
1180
+ ? { ...event, startedAt: event.startedAt ?? Date.now() }
1181
+ : event.type === 'tool.result'
1182
+ ? { ...event, endedAt: event.endedAt ?? Date.now() }
1183
+ : event.type === 'run.usage'
1184
+ ? { ...event, createdAt: event.createdAt ?? Date.now() }
1185
+ : event.type === 'runtime.compact'
1186
+ ? { ...event, createdAt: event.createdAt ?? Date.now() }
1187
+ : event;
1188
+ }
1189
+ function broadcastPrimaryDmTaskRootStatusProjection(params) {
1190
+ void params.manager;
1191
+ const update = buildPrimaryDmTaskRootUpdateEventForThreadConversation(params.db, params.threadConversationId, params.taskThreadStatus);
1192
+ if (!update)
1193
+ return;
1194
+ params.broadcast(update.conversationId, update.event);
1195
+ }
1196
+ function finishConversationRun(params) {
1197
+ const endedAt = Date.now();
1198
+ finishRun(params.db, params.error
1199
+ ? { runId: params.runId, error: params.error }
1200
+ : { runId: params.runId, stopReason: params.stopReason ?? 'end_turn' });
1201
+ if (!params.error) {
1202
+ markCodexRotationPendingIfDue(params.db, params.conversationId);
1203
+ }
1204
+ updateConversationStatus(params.db, params.broadcast, params.conversationId, params.error ? 'failed' : 'idle');
1205
+ broadcastPrimaryDmTaskRootStatusProjection({
1206
+ db: params.db,
1207
+ broadcast: params.broadcast,
1208
+ manager: params.manager,
1209
+ threadConversationId: params.conversationId,
1210
+ taskThreadStatus: params.error ? 'failed' : 'idle',
1211
+ });
1212
+ params.broadcast(params.conversationId, {
1213
+ type: 'turn.end',
1214
+ turnId: params.runId,
1215
+ stopReason: params.error ? 'error' : (params.stopReason ?? 'end_turn'),
1216
+ endedAt,
1217
+ error: params.error,
1218
+ });
1219
+ if (params.error) {
1220
+ params.broadcast(params.conversationId, { type: 'error', message: params.error });
1221
+ }
1222
+ scheduleTaskThreadFollowupForRun({
1223
+ db: params.db,
1224
+ conversationId: params.conversationId,
1225
+ runId: params.runId,
1226
+ stopReason: params.stopReason,
1227
+ error: params.error,
1228
+ });
1229
+ void params.onRunFinished?.({
1230
+ db: params.db,
1231
+ runId: params.runId,
1232
+ conversationId: params.conversationId,
1233
+ stopReason: params.stopReason,
1234
+ error: params.error,
1235
+ });
1236
+ void params.manager.onConversationSettled(params.conversationId);
1237
+ }
1238
+ export function handleNodeWebSocket(socket, registry, broadcast, db, manager, workspaceBroker, skillsBroker, codexTranscriptBroker, claudeTranscriptBroker, broadcastToChannel, terminalBroker, inspectBroker, gitBroker, claudeControlBroker, codexAppServerBroker, agentRuntimeCleanupBroker, workspaceSessionRuntimeService, onRunFinished, workspaceToolService, missionService, droidMissionBroker, nativeMissionWorkerBroker, soloSessionRelay) {
1239
+ const agentFileAccessService = workspaceBroker
1240
+ ? new AgentFileAccessService({
1241
+ db,
1242
+ getAgentById: (agentId) => manager.getAgent(agentId),
1243
+ broker: workspaceBroker,
1244
+ })
1245
+ : null;
1246
+ let nodeId = null;
1247
+ let connectionProcessInstanceId = null;
1248
+ const isStaleNodeConnection = () => {
1249
+ if (!nodeId)
1250
+ return false;
1251
+ const activeEntry = typeof registry.getNode === 'function' ? registry.getNode(nodeId) : undefined;
1252
+ if (activeEntry?.ws && activeEntry.ws !== socket) {
1253
+ return true;
1254
+ }
1255
+ if (!connectionProcessInstanceId)
1256
+ return false;
1257
+ const currentNode = db.prepare(`SELECT process_instance_id as processInstanceId
1258
+ FROM nodes
1259
+ WHERE node_id = ?`).get(nodeId);
1260
+ return currentNode?.processInstanceId !== connectionProcessInstanceId;
1261
+ };
1262
+ // Sequence counter per runId for node/event persistence
1263
+ const runSeq = new Map();
1264
+ const refreshHandoffSummaries = (handoff, fallbackConversationId) => {
1265
+ const conversationIds = new Set();
1266
+ if (fallbackConversationId)
1267
+ conversationIds.add(fallbackConversationId);
1268
+ if (handoff?.sourceConversationId)
1269
+ conversationIds.add(handoff.sourceConversationId);
1270
+ if (handoff?.targetConversationId)
1271
+ conversationIds.add(handoff.targetConversationId);
1272
+ if (conversationIds.size === 0)
1273
+ return;
1274
+ refreshConversationSummariesForConversationIds(db, (agentId) => manager.getAgent(agentId), conversationIds);
1275
+ };
1276
+ // Wrap broadcast: when a branch conversation's status changes, also forward to its channel stream
1277
+ const broadcastWithChannelForward = (conversationId, event) => {
1278
+ broadcast(conversationId, event);
1279
+ if (broadcastToChannel) {
1280
+ const conv = manager.getConversation(conversationId);
1281
+ if (conv?.channelId && conv.threadKind === 'branch' && !conv.channelId.startsWith('dm:')) {
1282
+ if (event.type === 'channel.message') {
1283
+ broadcastToChannel(conv.channelId, event);
1284
+ return;
1285
+ }
1286
+ if (event.type === 'conversation.status') {
1287
+ broadcastToChannel(conv.channelId, {
1288
+ type: 'channel.conversation.status',
1289
+ channelId: conv.channelId,
1290
+ conversation: { ...conv, status: event.status },
1291
+ });
1292
+ }
1293
+ }
1294
+ }
1295
+ };
1296
+ const failNodeOwnedRuntimeState = (params) => {
1297
+ inspectBroker?.rejectPendingForNode(params.nodeId);
1298
+ gitBroker?.rejectPendingForNode(params.nodeId);
1299
+ workspaceBroker?.rejectPendingForNode(params.nodeId);
1300
+ terminalBroker?.handleNodeDisconnect(params.nodeId);
1301
+ skillsBroker?.rejectPendingForNode(params.nodeId);
1302
+ codexTranscriptBroker?.rejectPendingForNode(params.nodeId);
1303
+ claudeTranscriptBroker?.rejectPendingForNode(params.nodeId);
1304
+ claudeControlBroker?.rejectPendingForNode(params.nodeId);
1305
+ codexAppServerBroker?.rejectPendingForNode(params.nodeId);
1306
+ agentRuntimeCleanupBroker?.rejectPendingForNode(params.nodeId);
1307
+ workspaceSessionRuntimeService?.rejectPendingDispatchesForNode(params.nodeId, params.disconnectMessage);
1308
+ manager.rejectPendingDispatchesForNode(params.nodeId, params.disconnectMessage);
1309
+ manager.rejectPendingSteersForNode(params.nodeId, params.disconnectMessage);
1310
+ nativeMissionWorkerBroker?.rejectNodeAttempts(params.nodeId, new Error(params.disconnectMessage));
1311
+ if (params.unregisterNode) {
1312
+ registry.unregister(params.nodeId);
1313
+ }
1314
+ const now = Date.now();
1315
+ if (params.markNodeOffline) {
1316
+ db.prepare(`UPDATE nodes SET status='offline', last_seen=? WHERE node_id=?`)
1317
+ .run(now, params.nodeId);
1318
+ }
1319
+ markAssetMaterializationsClearedForNode(db, params.nodeId, now);
1320
+ const workspaceOpenRuns = listOpenWorkspaceRunsForNode(db, params.nodeId);
1321
+ const affectedWorkspaceSessions = new Map();
1322
+ for (const run of workspaceOpenRuns) {
1323
+ finishRun(db, { runId: run.runId, error: params.disconnectMessage });
1324
+ affectedWorkspaceSessions.set(run.workspaceSessionId, run.sessionKey);
1325
+ }
1326
+ if (affectedWorkspaceSessions.size > 0) {
1327
+ const stmt = db.prepare(`UPDATE workspace_sessions
1328
+ SET status = ?,
1329
+ updated_at = ?,
1330
+ last_active_at = ?
1331
+ WHERE workspace_session_id = ?`);
1332
+ for (const [workspaceSessionId, sessionKey] of affectedWorkspaceSessions.entries()) {
1333
+ stmt.run(resolveWorkspaceSessionStatus(db, sessionKey, {
1334
+ openStatus: 'queued',
1335
+ closedStatus: 'failed',
1336
+ }), now, now, workspaceSessionId);
1337
+ }
1338
+ }
1339
+ const affected = db.prepare(`SELECT id FROM conversations WHERE node_id = ? AND status != 'idle'`).all(params.nodeId);
1340
+ const openRuns = db.prepare(`SELECT r.run_id as runId, c.id as conversationId
1341
+ FROM runs r
1342
+ JOIN conversations c ON c.session_key = r.session_key
1343
+ WHERE c.node_id = ?
1344
+ AND r.ended_at IS NULL`).all(params.nodeId);
1345
+ const openRunConversationIds = new Set(openRuns.map((run) => run.conversationId));
1346
+ for (const run of openRuns) {
1347
+ finishRun(db, { runId: run.runId, error: params.disconnectMessage });
1348
+ db.prepare(`UPDATE conversations SET status='failed', updated_at=? WHERE id=? AND status != 'idle'`)
1349
+ .run(Date.now(), run.conversationId);
1350
+ broadcastWithChannelForward(run.conversationId, {
1351
+ type: 'turn.end',
1352
+ turnId: run.runId,
1353
+ stopReason: 'error',
1354
+ endedAt: Date.now(),
1355
+ error: params.disconnectMessage,
1356
+ });
1357
+ void onRunFinished?.({
1358
+ db,
1359
+ runId: run.runId,
1360
+ conversationId: run.conversationId,
1361
+ error: params.disconnectMessage,
1362
+ });
1363
+ }
1364
+ for (const conv of affected) {
1365
+ if (!openRunConversationIds.has(conv.id)) {
1366
+ db.prepare(`UPDATE conversations SET status='failed', updated_at=? WHERE id=? AND status != 'idle'`)
1367
+ .run(Date.now(), conv.id);
1368
+ }
1369
+ broadcastWithChannelForward(conv.id, {
1370
+ type: 'conversation.status',
1371
+ conversationId: conv.id,
1372
+ status: 'failed',
1373
+ });
1374
+ broadcastPrimaryDmTaskRootStatusProjection({
1375
+ db,
1376
+ broadcast: broadcastWithChannelForward,
1377
+ manager,
1378
+ threadConversationId: conv.id,
1379
+ taskThreadStatus: 'failed',
1380
+ });
1381
+ broadcastWithChannelForward(conv.id, {
1382
+ type: 'error',
1383
+ message: params.disconnectMessage,
1384
+ });
1385
+ }
1386
+ };
1387
+ socket.on('message', (raw) => {
1388
+ let msg;
1389
+ try {
1390
+ msg = JSON.parse(String(raw));
1391
+ }
1392
+ catch {
1393
+ log.warn('[node-ws] invalid JSON from node');
1394
+ return;
1395
+ }
1396
+ if (msg.type !== 'node.register' && isStaleNodeConnection()) {
1397
+ log.debug('[node-ws] ignoring message from stale node connection', {
1398
+ nodeId,
1399
+ messageType: msg.type,
1400
+ connectionProcessInstanceId,
1401
+ });
1402
+ return;
1403
+ }
1404
+ switch (msg.type) {
1405
+ case 'node.register': {
1406
+ nodeId = msg.nodeId;
1407
+ const now = Date.now();
1408
+ const processInstanceId = typeof msg.processInstanceId === 'string' && msg.processInstanceId.trim()
1409
+ ? msg.processInstanceId.trim()
1410
+ : null;
1411
+ connectionProcessInstanceId = processInstanceId;
1412
+ const previousRegistryEntry = typeof registry.getNode === 'function' ? registry.getNode(msg.nodeId) : undefined;
1413
+ const existing = db.prepare(`SELECT node_id,
1414
+ status,
1415
+ process_instance_id as processInstanceId
1416
+ FROM nodes
1417
+ WHERE node_id = ?`).get(msg.nodeId);
1418
+ if (existing?.status === 'deleted') {
1419
+ log.warn(`[node-ws] connection rejected: node ${msg.nodeId} was deleted`);
1420
+ socket.close(4000, 'Machine has been deleted');
1421
+ return;
1422
+ }
1423
+ const replacedProcessInstance = Boolean(existing?.processInstanceId
1424
+ && processInstanceId
1425
+ && existing.processInstanceId !== processInstanceId);
1426
+ registry.register({
1427
+ nodeId: msg.nodeId,
1428
+ hostname: msg.hostname,
1429
+ agentTypes: msg.agentTypes,
1430
+ version: msg.version,
1431
+ processInstanceId,
1432
+ terminalBackendAvailable: msg.terminalBackendAvailable,
1433
+ ws: socket,
1434
+ lastSeen: now,
1435
+ });
1436
+ // Persist to DB: update existing pre-provisioned row or insert new
1437
+ const agentTypesJson = JSON.stringify(msg.agentTypes);
1438
+ if (existing) {
1439
+ db.prepare(`UPDATE nodes SET hostname=?, agent_types_json=?, version=?, process_instance_id=?, status='online', last_seen=?,
1440
+ created_at=CASE WHEN created_at=0 THEN ? ELSE created_at END WHERE node_id=?`).run(msg.hostname, agentTypesJson, msg.version, processInstanceId, now, now, msg.nodeId);
1441
+ }
1442
+ else {
1443
+ const pending = db.prepare(`SELECT node_id
1444
+ FROM nodes
1445
+ WHERE status = 'pending'
1446
+ AND display_name = ?
1447
+ ORDER BY provisioned_at DESC
1448
+ LIMIT 1`).get(msg.hostname);
1449
+ if (pending) {
1450
+ adoptProvisionedNodeIdentity(db, pending.node_id, msg.nodeId, msg.hostname, agentTypesJson, msg.version, processInstanceId, now);
1451
+ }
1452
+ else {
1453
+ db.prepare(`INSERT INTO nodes(node_id, hostname, agent_types_json, version, process_instance_id, status, last_seen, created_at, provisioned_at, display_name, env_var_keys)
1454
+ VALUES(?,?,?,?,?,'online',?,?,0,NULL,'[]')`).run(msg.nodeId, msg.hostname, agentTypesJson, msg.version, processInstanceId, now, now);
1455
+ }
1456
+ }
1457
+ if (replacedProcessInstance) {
1458
+ const disconnectMessage = `Agent node process restarted: ${msg.nodeId}`;
1459
+ failNodeOwnedRuntimeState({
1460
+ nodeId: msg.nodeId,
1461
+ disconnectMessage,
1462
+ unregisterNode: false,
1463
+ markNodeOffline: false,
1464
+ });
1465
+ if (previousRegistryEntry?.ws && previousRegistryEntry.ws !== socket) {
1466
+ try {
1467
+ previousRegistryEntry.ws.close();
1468
+ }
1469
+ catch {
1470
+ // Ignore failures from stale sockets during process replacement cleanup.
1471
+ }
1472
+ }
1473
+ log.warn('[node-ws] replaced active node process and failed open runtime state', {
1474
+ nodeId: msg.nodeId,
1475
+ previousProcessInstanceId: existing?.processInstanceId ?? null,
1476
+ processInstanceId,
1477
+ });
1478
+ }
1479
+ void cleanupDisabledPlatformAgentRuntimeForNode(db, {
1480
+ nodeId: msg.nodeId,
1481
+ agentRuntimeCleanupBroker,
1482
+ }).finally(() => {
1483
+ try {
1484
+ socket.send(JSON.stringify({ type: 'node.ack', nodeId: msg.nodeId }));
1485
+ }
1486
+ catch {
1487
+ // Ignore ack send failures; the socket close path will reconcile node state.
1488
+ }
1489
+ log.info(`[node-ws] registered: ${msg.nodeId} (${msg.hostname})`);
1490
+ void workspaceToolService?.reconcileActiveTerminalsForNode(msg.nodeId).catch((error) => {
1491
+ log.warn('[node-ws] workspace tool active terminal reconcile failed', {
1492
+ nodeId: msg.nodeId,
1493
+ error: String(error?.message ?? error),
1494
+ });
1495
+ });
1496
+ });
1497
+ break;
1498
+ }
1499
+ case 'node.heartbeat': {
1500
+ registry.heartbeat(msg.nodeId);
1501
+ break;
1502
+ }
1503
+ case 'node.hosts.snapshot': {
1504
+ registry.heartbeat(msg.nodeId);
1505
+ db.prepare(`UPDATE nodes SET last_seen = ?, status = 'online' WHERE node_id = ?`)
1506
+ .run(msg.generatedAt, msg.nodeId);
1507
+ const { validHosts, rejectedHosts } = validateNodeHostSnapshots(db, {
1508
+ nodeId: msg.nodeId,
1509
+ hosts: msg.hosts,
1510
+ });
1511
+ for (const rejected of rejectedHosts) {
1512
+ cleanupRejectedNodeHostSnapshot(db, {
1513
+ nodeId: msg.nodeId,
1514
+ host: rejected.host,
1515
+ conversationId: rejected.conversationId,
1516
+ reason: rejected.reason,
1517
+ agentRuntimeCleanupBroker,
1518
+ });
1519
+ }
1520
+ persistNodeHostSnapshots(db, {
1521
+ nodeId: msg.nodeId,
1522
+ snapshotAt: msg.generatedAt,
1523
+ hosts: validHosts,
1524
+ });
1525
+ const activeConversationIds = new Set();
1526
+ for (const host of validHosts) {
1527
+ if (host.state === 'active' && host.currentRunId) {
1528
+ const status = getSnapshotConversationStatus(db, {
1529
+ runId: host.currentRunId,
1530
+ hasPendingApproval: host.hasPendingApproval,
1531
+ });
1532
+ const conversationIds = manager.handleNodeHostSnapshotCurrentRun({
1533
+ runId: host.currentRunId,
1534
+ nodeId: msg.nodeId,
1535
+ hostKey: host.hostKey,
1536
+ sessionKey: host.sessionKey,
1537
+ agentType: host.agentType,
1538
+ hasPendingApproval: host.hasPendingApproval,
1539
+ });
1540
+ for (const conversationId of conversationIds) {
1541
+ activeConversationIds.add(conversationId);
1542
+ broadcastWithChannelForward(conversationId, {
1543
+ type: 'conversation.status',
1544
+ conversationId,
1545
+ status,
1546
+ });
1547
+ if (status === 'awaiting_approval') {
1548
+ broadcastPrimaryDmTaskRootStatusProjection({
1549
+ db,
1550
+ broadcast: broadcastWithChannelForward,
1551
+ manager,
1552
+ threadConversationId: conversationId,
1553
+ taskThreadStatus: status,
1554
+ });
1555
+ }
1556
+ }
1557
+ }
1558
+ }
1559
+ const queuedConversationIds = db.prepare(`SELECT DISTINCT q.conversation_id as conversationId
1560
+ FROM conversation_prompt_queue q
1561
+ JOIN conversations c ON c.id = q.conversation_id
1562
+ WHERE c.node_id = ?`).all(msg.nodeId);
1563
+ for (const conversation of queuedConversationIds) {
1564
+ if (activeConversationIds.has(conversation.conversationId))
1565
+ continue;
1566
+ void manager.onConversationSettled(conversation.conversationId);
1567
+ }
1568
+ break;
1569
+ }
1570
+ case 'node.runtime.snapshot': {
1571
+ registry.heartbeat(msg.nodeId);
1572
+ db.prepare(`UPDATE nodes SET last_seen = ?, status = 'online' WHERE node_id = ?`)
1573
+ .run(msg.generatedAt, msg.nodeId);
1574
+ const snapshotProcessInstanceId = typeof msg.processInstanceId === 'string' && msg.processInstanceId.trim()
1575
+ ? msg.processInstanceId.trim()
1576
+ : connectionProcessInstanceId;
1577
+ if (connectionProcessInstanceId
1578
+ && snapshotProcessInstanceId
1579
+ && snapshotProcessInstanceId !== connectionProcessInstanceId) {
1580
+ log.warn('[node-ws] ignoring runtime snapshot from mismatched node process', {
1581
+ nodeId: msg.nodeId,
1582
+ connectionProcessInstanceId,
1583
+ snapshotProcessInstanceId,
1584
+ });
1585
+ break;
1586
+ }
1587
+ persistNodeRuntimeSnapshot(db, {
1588
+ nodeId: msg.nodeId,
1589
+ processInstanceId: snapshotProcessInstanceId,
1590
+ snapshotAt: msg.generatedAt,
1591
+ runtime: msg.runtime,
1592
+ });
1593
+ break;
1594
+ }
1595
+ case 'asset.materialized': {
1596
+ if (!nodeId) {
1597
+ log.debug('[node-ws] ignoring asset.materialized before node registration', { assetId: msg.assetId });
1598
+ break;
1599
+ }
1600
+ const assetId = msg.assetId?.trim();
1601
+ const agentId = msg.agentId?.trim();
1602
+ const conversationId = msg.conversationId?.trim();
1603
+ const nodeProcessInstanceId = msg.nodeProcessInstanceId?.trim();
1604
+ const hostKey = msg.hostKey?.trim();
1605
+ const hostInstanceId = msg.hostInstanceId?.trim();
1606
+ const localPath = msg.localPath?.trim();
1607
+ if (!assetId
1608
+ || !agentId
1609
+ || !conversationId
1610
+ || !nodeProcessInstanceId
1611
+ || !hostKey
1612
+ || !hostInstanceId
1613
+ || !localPath
1614
+ || !path.isAbsolute(localPath)) {
1615
+ log.warn('[node-ws] ignoring invalid asset.materialized payload', { assetId, agentId, conversationId, hostKey });
1616
+ break;
1617
+ }
1618
+ const currentNode = db.prepare(`SELECT process_instance_id as processInstanceId
1619
+ FROM nodes
1620
+ WHERE node_id = ?`).get(nodeId);
1621
+ if (currentNode?.processInstanceId !== nodeProcessInstanceId) {
1622
+ log.warn('[node-ws] ignoring asset.materialized for stale node process', {
1623
+ assetId,
1624
+ nodeId,
1625
+ nodeProcessInstanceId,
1626
+ currentProcessInstanceId: currentNode?.processInstanceId ?? null,
1627
+ });
1628
+ break;
1629
+ }
1630
+ const conversation = manager.getConversation(conversationId);
1631
+ if (!conversation || conversation.agentId !== agentId || conversation.nodeId !== nodeId) {
1632
+ log.warn('[node-ws] ignoring asset.materialized for mismatched conversation', {
1633
+ assetId,
1634
+ agentId,
1635
+ conversationId,
1636
+ nodeId,
1637
+ });
1638
+ break;
1639
+ }
1640
+ const expectedHost = manager.getConversationHostKey(conversationId);
1641
+ if (!expectedHost || expectedHost.nodeId !== nodeId || expectedHost.hostKey !== hostKey) {
1642
+ log.warn('[node-ws] ignoring asset.materialized for mismatched host binding', {
1643
+ assetId,
1644
+ conversationId,
1645
+ nodeId,
1646
+ hostKey,
1647
+ expectedHostKey: expectedHost?.hostKey ?? null,
1648
+ expectedNodeId: expectedHost?.nodeId ?? null,
1649
+ });
1650
+ break;
1651
+ }
1652
+ const assetExists = !!db.prepare(`SELECT 1 FROM attachments WHERE id = ?`).get(assetId);
1653
+ if (!assetExists) {
1654
+ log.warn('[node-ws] ignoring asset.materialized for unknown asset', { assetId, conversationId });
1655
+ break;
1656
+ }
1657
+ if (msg.runId) {
1658
+ const runMatchesConversation = !!db.prepare(`SELECT 1
1659
+ FROM runs r
1660
+ JOIN conversations c ON c.session_key = r.session_key
1661
+ WHERE r.run_id = ?
1662
+ AND c.id = ?`).get(msg.runId, conversationId);
1663
+ if (!runMatchesConversation) {
1664
+ log.warn('[node-ws] ignoring asset.materialized for mismatched run', {
1665
+ assetId,
1666
+ runId: msg.runId,
1667
+ conversationId,
1668
+ });
1669
+ break;
1670
+ }
1671
+ }
1672
+ const materializedAt = Number.isFinite(msg.materializedAt) ? msg.materializedAt : Date.now();
1673
+ const materializedNodeId = nodeId;
1674
+ const materializedAssetId = assetId;
1675
+ const materializedAgentId = agentId;
1676
+ const materializedConversationId = conversationId;
1677
+ const materializedNodeProcessInstanceId = nodeProcessInstanceId;
1678
+ const materializedHostKey = hostKey;
1679
+ const materializedHostInstanceId = hostInstanceId;
1680
+ const materializedLocalPath = localPath;
1681
+ db.transaction(() => {
1682
+ upsertMaterializedHostSnapshot(db, {
1683
+ nodeId: materializedNodeId,
1684
+ hostKey: materializedHostKey,
1685
+ hostInstanceId: materializedHostInstanceId,
1686
+ conversationId: materializedConversationId,
1687
+ runId: msg.runId ?? null,
1688
+ snapshotAt: materializedAt,
1689
+ });
1690
+ recordAssetMaterialized(db, {
1691
+ assetId: materializedAssetId,
1692
+ agentId: materializedAgentId,
1693
+ conversationId: materializedConversationId,
1694
+ nodeId: materializedNodeId,
1695
+ nodeProcessInstanceId: materializedNodeProcessInstanceId,
1696
+ hostKey: materializedHostKey,
1697
+ hostInstanceId: materializedHostInstanceId,
1698
+ runId: msg.runId ?? null,
1699
+ localPath: materializedLocalPath,
1700
+ materializedAt,
1701
+ });
1702
+ })();
1703
+ break;
1704
+ }
1705
+ case 'asset.materialization.cleared': {
1706
+ if (!nodeId)
1707
+ break;
1708
+ const hostKey = msg.hostKey?.trim();
1709
+ const hostInstanceId = msg.hostInstanceId?.trim();
1710
+ const nodeProcessInstanceId = msg.nodeProcessInstanceId?.trim();
1711
+ if (!hostKey || !hostInstanceId || !nodeProcessInstanceId)
1712
+ break;
1713
+ const currentNode = db.prepare(`SELECT process_instance_id as processInstanceId
1714
+ FROM nodes
1715
+ WHERE node_id = ?`).get(nodeId);
1716
+ if (currentNode?.processInstanceId !== nodeProcessInstanceId) {
1717
+ log.debug('[node-ws] ignoring asset.materialization.cleared for stale node process', {
1718
+ nodeId,
1719
+ hostKey,
1720
+ hostInstanceId,
1721
+ });
1722
+ break;
1723
+ }
1724
+ markAssetMaterializationsClearedForHost(db, {
1725
+ nodeId,
1726
+ hostKey,
1727
+ hostInstanceId,
1728
+ clearedAt: Number.isFinite(msg.clearedAt) ? msg.clearedAt : Date.now(),
1729
+ });
1730
+ break;
1731
+ }
1732
+ case 'mission.run.accepted': {
1733
+ if (!nodeId)
1734
+ break;
1735
+ if (msg.runtimeProvider === 'bigbang_native') {
1736
+ nativeMissionWorkerBroker?.handleAccepted(msg, nodeId);
1737
+ break;
1738
+ }
1739
+ const attempt = droidMissionBroker?.handleAccepted(msg, nodeId);
1740
+ if (!attempt)
1741
+ break;
1742
+ if (attempt.interaction) {
1743
+ missionService?.recordMissionRuntimeEvent({
1744
+ missionId: msg.missionId,
1745
+ eventType: 'mission_interaction_accepted',
1746
+ source: 'agent_node',
1747
+ eventTime: Date.now(),
1748
+ payload: {
1749
+ missionDir: msg.missionDir,
1750
+ pid: msg.pid ?? null,
1751
+ requestId: msg.requestId,
1752
+ },
1753
+ });
1754
+ }
1755
+ else {
1756
+ missionService?.markMissionAccepted({
1757
+ missionId: msg.missionId,
1758
+ missionDir: msg.missionDir,
1759
+ pid: msg.pid,
1760
+ requestId: msg.requestId,
1761
+ });
1762
+ }
1763
+ break;
1764
+ }
1765
+ case 'mission.run.event': {
1766
+ if (!nodeId)
1767
+ break;
1768
+ if (msg.runtimeProvider === 'bigbang_native') {
1769
+ nativeMissionWorkerBroker?.handleEvent(msg, nodeId);
1770
+ break;
1771
+ }
1772
+ if (!droidMissionBroker?.verifyEvent(msg, nodeId))
1773
+ break;
1774
+ missionService?.recordMissionRuntimeEvent({
1775
+ missionId: msg.missionId,
1776
+ eventType: msg.eventType,
1777
+ source: msg.source,
1778
+ eventTime: msg.eventTime,
1779
+ payload: {
1780
+ ...msg.payload,
1781
+ requestId: msg.requestId,
1782
+ },
1783
+ });
1784
+ break;
1785
+ }
1786
+ case 'mission.run.end': {
1787
+ if (!nodeId)
1788
+ break;
1789
+ if (msg.runtimeProvider === 'bigbang_native') {
1790
+ nativeMissionWorkerBroker?.handleEnd(msg, nodeId);
1791
+ break;
1792
+ }
1793
+ const attempt = droidMissionBroker?.handleEnd(msg, nodeId);
1794
+ if (!attempt)
1795
+ break;
1796
+ if (attempt.interaction) {
1797
+ const failed = Boolean(msg.error) || (msg.exitCode != null && msg.exitCode !== 0) || Boolean(msg.signal);
1798
+ missionService?.recordMissionRuntimeEvent({
1799
+ missionId: msg.missionId,
1800
+ eventType: failed ? 'mission_interaction_failed' : 'mission_interaction_completed',
1801
+ source: 'agent_node',
1802
+ eventTime: Date.now(),
1803
+ payload: {
1804
+ missionDir: msg.missionDir,
1805
+ factorySessionId: msg.factorySessionId ?? null,
1806
+ factoryArtifactDir: msg.factoryArtifactDir ?? null,
1807
+ exitCode: msg.exitCode ?? null,
1808
+ signal: msg.signal ?? null,
1809
+ error: msg.error ?? null,
1810
+ requestId: msg.requestId,
1811
+ },
1812
+ });
1813
+ }
1814
+ else {
1815
+ missionService?.finishDroidMission({
1816
+ missionId: msg.missionId,
1817
+ missionDir: msg.missionDir,
1818
+ factorySessionId: msg.factorySessionId,
1819
+ factoryArtifactDir: msg.factoryArtifactDir,
1820
+ exitCode: msg.exitCode,
1821
+ signal: msg.signal,
1822
+ error: msg.error,
1823
+ requestId: msg.requestId,
1824
+ });
1825
+ }
1826
+ break;
1827
+ }
1828
+ case 'mission.continuation.inspect.response': {
1829
+ if (!nodeId)
1830
+ break;
1831
+ droidMissionBroker?.handleContinuationInspectResponse(msg, nodeId);
1832
+ break;
1833
+ }
1834
+ case 'run.accepted': {
1835
+ const workspaceRun = getWorkspaceRunContext(db, msg.runId);
1836
+ if (workspaceRun) {
1837
+ if (workspaceRun.endedAt != null) {
1838
+ log.debug('[node-ws] ignoring late workspace run.accepted for ended run', {
1839
+ runId: msg.runId,
1840
+ workspaceSessionId: workspaceRun.workspaceSessionId,
1841
+ });
1842
+ break;
1843
+ }
1844
+ if (!workspaceRunBelongsToCurrentNode(db, msg.runId, nodeId)) {
1845
+ log.debug('[node-ws] ignoring workspace run.accepted from non-owner node', {
1846
+ runId: msg.runId,
1847
+ workspaceSessionId: workspaceRun.workspaceSessionId,
1848
+ nodeId,
1849
+ });
1850
+ break;
1851
+ }
1852
+ if (!workspaceSessionRuntimeService?.handleRunAccepted(msg.runId)) {
1853
+ log.debug('[node-ws] ignoring workspace run.accepted without pending waiter', {
1854
+ runId: msg.runId,
1855
+ workspaceSessionId: workspaceRun.workspaceSessionId,
1856
+ });
1857
+ }
1858
+ break;
1859
+ }
1860
+ const accepted = manager.handleRunAccepted(msg.runId, msg.conversationId);
1861
+ if (!accepted) {
1862
+ log.debug('[node-ws] ignoring late run.accepted without pending waiter', {
1863
+ runId: msg.runId,
1864
+ conversationId: msg.conversationId,
1865
+ });
1866
+ break;
1867
+ }
1868
+ broadcastWithChannelForward(msg.conversationId, {
1869
+ type: 'conversation.status',
1870
+ conversationId: msg.conversationId,
1871
+ status: 'active',
1872
+ });
1873
+ const handoff = markConversationHandoffAccepted(db, {
1874
+ targetConversationId: msg.conversationId,
1875
+ targetRunId: msg.runId,
1876
+ });
1877
+ if (handoff) {
1878
+ insertHandoffRun(db, {
1879
+ handoffId: handoff.handoffId,
1880
+ runId: msg.runId,
1881
+ agentId: handoff.agentId,
1882
+ disposition: 'accepted',
1883
+ });
1884
+ }
1885
+ refreshHandoffSummaries(handoff, msg.conversationId);
1886
+ break;
1887
+ }
1888
+ case 'run.steer.result': {
1889
+ const handled = manager.handleRunSteerResult(msg);
1890
+ if (!handled) {
1891
+ log.debug('[node-ws] ignoring late run.steer.result without pending waiter', {
1892
+ requestId: msg.requestId,
1893
+ runId: msg.runId,
1894
+ conversationId: msg.conversationId,
1895
+ ok: msg.ok,
1896
+ });
1897
+ }
1898
+ break;
1899
+ }
1900
+ case 'run.delivery.ack': {
1901
+ const handled = manager.handleRunDeliveryAck(msg);
1902
+ if (!handled) {
1903
+ log.debug('[node-ws] ignoring unknown run.delivery.ack', {
1904
+ deliveryId: msg.deliveryId,
1905
+ roundId: msg.roundId,
1906
+ runId: msg.runId,
1907
+ conversationId: msg.conversationId,
1908
+ mode: msg.mode,
1909
+ });
1910
+ }
1911
+ break;
1912
+ }
1913
+ case 'run.event': {
1914
+ log.debug('[node-ws] run.event', { conversationId: msg.conversationId, eventType: msg.event.type });
1915
+ const workspaceRun = getWorkspaceRunContext(db, msg.runId);
1916
+ if (workspaceRun) {
1917
+ if (workspaceRun.endedAt != null) {
1918
+ log.debug('[node-ws] ignoring workspace run.event for ended run', {
1919
+ runId: msg.runId,
1920
+ workspaceSessionId: workspaceRun.workspaceSessionId,
1921
+ eventType: msg.event.type,
1922
+ });
1923
+ break;
1924
+ }
1925
+ if (!workspaceRunBelongsToCurrentNode(db, msg.runId, nodeId)) {
1926
+ log.debug('[node-ws] ignoring workspace run.event from non-owner node', {
1927
+ runId: msg.runId,
1928
+ workspaceSessionId: workspaceRun.workspaceSessionId,
1929
+ eventType: msg.event.type,
1930
+ nodeId,
1931
+ });
1932
+ break;
1933
+ }
1934
+ if (msg.event.type === 'conversation.status') {
1935
+ if (msg.event.status !== 'awaiting_approval') {
1936
+ promoteWorkspaceSentApprovalResponses(db, msg.runId);
1937
+ }
1938
+ updateWorkspaceSessionStatus(db, workspaceRun.workspaceSessionId, hasOpenPendingWorkspaceApproval(db, workspaceRun.sessionKey) ? 'awaiting_approval' : msg.event.status);
1939
+ break;
1940
+ }
1941
+ const broadcastEvent = buildBroadcastNodeEvent(msg.event);
1942
+ if (REPLAY_EVENT_TYPES.has(msg.event.type)) {
1943
+ if (isWorkspaceApprovalProgressEvent(broadcastEvent)) {
1944
+ promoteWorkspaceSentApprovalResponses(db, msg.runId);
1945
+ }
1946
+ const seq = nextNodeEventSeq(db, msg.runId, runSeq);
1947
+ runSeq.set(msg.runId, seq);
1948
+ appendNodeEvent(db, msg.runId, seq, broadcastEvent);
1949
+ if (isWorkspaceApprovalProgressEvent(broadcastEvent)) {
1950
+ updateWorkspaceSessionStatus(db, workspaceRun.workspaceSessionId, hasOpenPendingWorkspaceApproval(db, workspaceRun.sessionKey) ? 'awaiting_approval' : 'active');
1951
+ }
1952
+ }
1953
+ break;
1954
+ }
1955
+ if (msg.event.type === 'conversation.status') {
1956
+ db.prepare('UPDATE conversations SET status = ?, updated_at = ? WHERE id = ?')
1957
+ .run(msg.event.status, Date.now(), msg.conversationId);
1958
+ broadcastWithChannelForward(msg.conversationId, msg.event);
1959
+ break;
1960
+ }
1961
+ // Silently discard events for runs that no longer exist (deleted by reset/clear-chat)
1962
+ const runKnown = !!(db.prepare('SELECT 1 FROM runs WHERE run_id = ?').get(msg.runId));
1963
+ if (!runKnown) {
1964
+ log.debug('[node-ws] ignoring run.event for unknown/deleted run', { runId: msg.runId });
1965
+ break;
1966
+ }
1967
+ const broadcastEvent = buildBroadcastNodeEvent(msg.event);
1968
+ if (broadcastEvent.type === 'runtime.compact') {
1969
+ recordCodexCompactEvent(db, msg.conversationId, broadcastEvent);
1970
+ }
1971
+ broadcastWithChannelForward(msg.conversationId, broadcastEvent);
1972
+ if (broadcastEvent.type === 'run.steer.result') {
1973
+ const handled = manager.handleRunSteerResult({
1974
+ requestId: broadcastEvent.requestId,
1975
+ runId: msg.runId,
1976
+ conversationId: msg.conversationId,
1977
+ ok: broadcastEvent.ok,
1978
+ ...(broadcastEvent.error ? { error: broadcastEvent.error } : {}),
1979
+ });
1980
+ if (!handled) {
1981
+ log.debug('[node-ws] ignoring run.event steer result without pending waiter', {
1982
+ requestId: broadcastEvent.requestId,
1983
+ runId: msg.runId,
1984
+ conversationId: msg.conversationId,
1985
+ ok: broadcastEvent.ok,
1986
+ });
1987
+ }
1988
+ }
1989
+ // Persist replay-worthy events to core DB immediately
1990
+ if (REPLAY_EVENT_TYPES.has(msg.event.type)) {
1991
+ const seq = nextNodeEventSeq(db, msg.runId, runSeq);
1992
+ runSeq.set(msg.runId, seq);
1993
+ appendNodeEvent(db, msg.runId, seq, broadcastEvent);
1994
+ if (nodeId) {
1995
+ agentFileAccessService?.recordEventAccesses({
1996
+ nodeId,
1997
+ conversationId: msg.conversationId,
1998
+ runId: msg.runId,
1999
+ event: broadcastEvent,
2000
+ });
2001
+ }
2002
+ }
2003
+ break;
2004
+ }
2005
+ case 'run.end': {
2006
+ log.info('[node-ws] run.end', { runId: msg.runId, conversationId: msg.conversationId, error: msg.error ?? null });
2007
+ runSeq.delete(msg.runId);
2008
+ const workspaceRun = getWorkspaceRunContext(db, msg.runId);
2009
+ if (workspaceRun) {
2010
+ if (workspaceRun.endedAt != null) {
2011
+ log.debug('[node-ws] ignoring workspace run.end for already ended run', {
2012
+ runId: msg.runId,
2013
+ workspaceSessionId: workspaceRun.workspaceSessionId,
2014
+ });
2015
+ break;
2016
+ }
2017
+ if (!workspaceRunBelongsToCurrentNode(db, msg.runId, nodeId)) {
2018
+ log.debug('[node-ws] ignoring workspace run.end from non-owner node', {
2019
+ runId: msg.runId,
2020
+ workspaceSessionId: workspaceRun.workspaceSessionId,
2021
+ nodeId,
2022
+ });
2023
+ break;
2024
+ }
2025
+ promoteWorkspaceSentApprovalResponses(db, msg.runId);
2026
+ finishRun(db, msg.error
2027
+ ? { runId: msg.runId, error: msg.error }
2028
+ : { runId: msg.runId, stopReason: msg.stopReason ?? 'end_turn' });
2029
+ updateWorkspaceSessionStatus(db, workspaceRun.workspaceSessionId, resolveWorkspaceSessionStatus(db, workspaceRun.sessionKey, {
2030
+ openStatus: 'queued',
2031
+ closedStatus: msg.error ? 'failed' : 'idle',
2032
+ }));
2033
+ break;
2034
+ }
2035
+ // Check if this run still exists in core's DB.
2036
+ // After reset/clear-chat the run rows are deleted — ignore stale run.end messages
2037
+ // so they don't overwrite the conversation status set by the reset operation.
2038
+ const runExists = !!(db
2039
+ .prepare('SELECT 1 FROM runs WHERE run_id = ?')
2040
+ .get(msg.runId));
2041
+ if (!runExists) {
2042
+ log.warn('[node-ws] ignoring run.end for unknown/deleted run', { runId: msg.runId });
2043
+ manager.rejectPendingSteersForRun(msg.runId, msg.conversationId, 'Run ended before active-turn steer was acknowledged.');
2044
+ void manager.onConversationSettled(msg.conversationId);
2045
+ break;
2046
+ }
2047
+ manager.rejectPendingSteersForRun(msg.runId, msg.conversationId, 'Run ended before active-turn steer was acknowledged.');
2048
+ manager.handleRunFinished(msg.runId, msg.conversationId, {
2049
+ error: msg.error ?? null,
2050
+ stopReason: msg.stopReason ?? null,
2051
+ });
2052
+ const handedOff = isCancelStopReason(msg.stopReason) && wasRunHandedOff(db, msg.runId);
2053
+ const expectedBootstrapStopReason = isCancelStopReason(msg.stopReason)
2054
+ ? getExpectedBootstrapStopReason(db, msg.runId)
2055
+ : null;
2056
+ const runEndError = getRunEndError(msg, db, msg.conversationId, msg.runId, handedOff, expectedBootstrapStopReason);
2057
+ let handoff = null;
2058
+ if (msg.error) {
2059
+ handoff = markConversationHandoffFailed(db, {
2060
+ targetConversationId: msg.conversationId,
2061
+ targetRunId: msg.runId,
2062
+ error: runEndError,
2063
+ });
2064
+ }
2065
+ else if (isCancelStopReason(msg.stopReason) && !handedOff && !expectedBootstrapStopReason) {
2066
+ handoff = markConversationHandoffCancelled(db, {
2067
+ targetConversationId: msg.conversationId,
2068
+ targetRunId: msg.runId,
2069
+ error: runEndError ?? null,
2070
+ });
2071
+ }
2072
+ else if (runEndError) {
2073
+ handoff = markConversationHandoffFailed(db, {
2074
+ targetConversationId: msg.conversationId,
2075
+ targetRunId: msg.runId,
2076
+ error: runEndError,
2077
+ });
2078
+ }
2079
+ else if (!handedOff) {
2080
+ handoff = markConversationHandoffCompleted(db, {
2081
+ targetConversationId: msg.conversationId,
2082
+ targetRunId: msg.runId,
2083
+ });
2084
+ }
2085
+ if (handoff) {
2086
+ insertHandoffRun(db, {
2087
+ handoffId: handoff.handoffId,
2088
+ runId: msg.runId,
2089
+ agentId: handoff.agentId,
2090
+ disposition: handoff.status === 'failed'
2091
+ ? 'failed'
2092
+ : handoff.status === 'cancelled'
2093
+ ? 'cancelled'
2094
+ : 'completed',
2095
+ });
2096
+ }
2097
+ if ((!msg.error || isWorkspaceToolPublishFallbackRecoverableRunEndError(msg.error))
2098
+ && !isCancelStopReason(msg.stopReason)) {
2099
+ const toolPublishFallbackResult = persistPendingWorkspaceToolPublishFallback({
2100
+ db,
2101
+ conversationId: msg.conversationId,
2102
+ runId: msg.runId,
2103
+ broadcast: broadcastWithChannelForward,
2104
+ manager,
2105
+ });
2106
+ if (toolPublishFallbackResult.emittedCount > 0 || toolPublishFallbackResult.updatedCount > 0) {
2107
+ log.info('[node-ws] surfaced workspace tool publish fallback attachment', {
2108
+ conversationId: msg.conversationId,
2109
+ runId: msg.runId,
2110
+ emittedCount: toolPublishFallbackResult.emittedCount,
2111
+ updatedCount: toolPublishFallbackResult.updatedCount,
2112
+ toolIds: toolPublishFallbackResult.toolIds,
2113
+ });
2114
+ }
2115
+ const fallbackResult = persistDeltaFallbackMessages({
2116
+ db,
2117
+ conversationId: msg.conversationId,
2118
+ runId: msg.runId,
2119
+ broadcast: broadcastWithChannelForward,
2120
+ manager,
2121
+ agentFileAccessService,
2122
+ });
2123
+ if (fallbackResult.emittedCount > 0) {
2124
+ log.info('[node-ws] emitted delta fallback messages', {
2125
+ conversationId: msg.conversationId,
2126
+ runId: msg.runId,
2127
+ count: fallbackResult.emittedCount,
2128
+ });
2129
+ }
2130
+ if (fallbackResult.suppressedCount > 0) {
2131
+ log.info('[node-ws] suppressed delta fallback messages', {
2132
+ conversationId: msg.conversationId,
2133
+ runId: msg.runId,
2134
+ count: fallbackResult.suppressedCount,
2135
+ reason: fallbackResult.suppressedReason ?? 'unknown',
2136
+ });
2137
+ }
2138
+ }
2139
+ finishConversationRun({
2140
+ db,
2141
+ broadcast: broadcastWithChannelForward,
2142
+ manager,
2143
+ conversationId: msg.conversationId,
2144
+ runId: msg.runId,
2145
+ stopReason: handedOff ? 'handoff' : (expectedBootstrapStopReason ?? msg.stopReason),
2146
+ error: runEndError ?? undefined,
2147
+ onRunFinished,
2148
+ });
2149
+ clearTaskThreadReopenedRunBlock(msg.conversationId, msg.runId);
2150
+ refreshHandoffSummaries(handoff, msg.conversationId);
2151
+ break;
2152
+ }
2153
+ case 'run.debug.snapshot': {
2154
+ const workspaceRun = getWorkspaceRunContext(db, msg.runId);
2155
+ if (workspaceRun) {
2156
+ if (workspaceRun.endedAt != null) {
2157
+ log.debug('[node-ws] ignoring workspace run.debug.snapshot for ended run', {
2158
+ runId: msg.runId,
2159
+ workspaceSessionId: workspaceRun.workspaceSessionId,
2160
+ });
2161
+ break;
2162
+ }
2163
+ if (workspaceRun.sessionKey !== msg.sessionKey) {
2164
+ log.debug('[node-ws] ignoring workspace run.debug.snapshot with mismatched session key', {
2165
+ runId: msg.runId,
2166
+ workspaceSessionId: workspaceRun.workspaceSessionId,
2167
+ });
2168
+ break;
2169
+ }
2170
+ if (!workspaceRunBelongsToCurrentNode(db, msg.runId, nodeId)) {
2171
+ log.debug('[node-ws] ignoring workspace run.debug.snapshot from non-owner node', {
2172
+ runId: msg.runId,
2173
+ workspaceSessionId: workspaceRun.workspaceSessionId,
2174
+ nodeId,
2175
+ });
2176
+ break;
2177
+ }
2178
+ }
2179
+ applyRunDebugSnapshot(db, {
2180
+ runId: msg.runId,
2181
+ conversationId: msg.conversationId,
2182
+ sessionKey: msg.sessionKey,
2183
+ acpSessionId: msg.acpSessionId,
2184
+ isFreshSession: msg.isFreshSession,
2185
+ isExact: msg.isExact,
2186
+ effectiveSystemPromptText: msg.effectiveSystemPromptText,
2187
+ effectiveContextText: msg.effectiveContextText,
2188
+ });
2189
+ break;
2190
+ }
2191
+ case 'permission.request': {
2192
+ const approvalEvent = {
2193
+ type: 'approval.request',
2194
+ requestId: msg.requestId,
2195
+ toolName: msg.toolName,
2196
+ toolArgs: msg.toolArgs,
2197
+ toolKind: msg.toolKind,
2198
+ ...(msg.approvalKind ? { approvalKind: msg.approvalKind } : {}),
2199
+ ...(msg.title ? { title: msg.title } : {}),
2200
+ ...(msg.description ? { description: msg.description } : {}),
2201
+ ...(msg.input !== undefined ? { input: msg.input } : {}),
2202
+ ...(msg.actions?.length ? { actions: msg.actions } : {}),
2203
+ };
2204
+ const workspaceRun = getWorkspaceRunContext(db, msg.runId);
2205
+ if (workspaceRun) {
2206
+ if (workspaceRun.endedAt != null) {
2207
+ log.debug('[node-ws] ignoring workspace permission.request for ended run', {
2208
+ runId: msg.runId,
2209
+ workspaceSessionId: workspaceRun.workspaceSessionId,
2210
+ requestId: msg.requestId,
2211
+ });
2212
+ break;
2213
+ }
2214
+ if (!workspaceRunBelongsToCurrentNode(db, msg.runId, nodeId)) {
2215
+ log.debug('[node-ws] ignoring workspace permission.request from non-owner node', {
2216
+ runId: msg.runId,
2217
+ workspaceSessionId: workspaceRun.workspaceSessionId,
2218
+ requestId: msg.requestId,
2219
+ nodeId,
2220
+ });
2221
+ break;
2222
+ }
2223
+ updateWorkspaceSessionStatus(db, workspaceRun.workspaceSessionId, 'awaiting_approval');
2224
+ const runKnown = !!db.prepare('SELECT 1 FROM runs WHERE run_id = ?').get(msg.runId);
2225
+ if (runKnown) {
2226
+ const seq = nextNodeEventSeq(db, msg.runId, runSeq);
2227
+ runSeq.set(msg.runId, seq);
2228
+ appendNodeEvent(db, msg.runId, seq, approvalEvent);
2229
+ }
2230
+ break;
2231
+ }
2232
+ if (msg.approvalKind === 'plan') {
2233
+ const planText = getPlanTextFromApprovalRequest(msg);
2234
+ if (planText) {
2235
+ upsertPlanApprovalMessage({
2236
+ db,
2237
+ manager,
2238
+ conversationId: msg.conversationId,
2239
+ runId: msg.runId,
2240
+ planText,
2241
+ broadcast: broadcastWithChannelForward,
2242
+ agentFileAccessService,
2243
+ });
2244
+ }
2245
+ }
2246
+ db.prepare('UPDATE conversations SET status = ?, updated_at = ? WHERE id = ?')
2247
+ .run('awaiting_approval', Date.now(), msg.conversationId);
2248
+ broadcastWithChannelForward(msg.conversationId, {
2249
+ type: 'conversation.status',
2250
+ conversationId: msg.conversationId,
2251
+ status: 'awaiting_approval',
2252
+ });
2253
+ broadcastPrimaryDmTaskRootStatusProjection({
2254
+ db,
2255
+ broadcast: broadcastWithChannelForward,
2256
+ manager,
2257
+ threadConversationId: msg.conversationId,
2258
+ taskThreadStatus: 'awaiting_approval',
2259
+ });
2260
+ const runKnown = !!db.prepare('SELECT 1 FROM runs WHERE run_id = ?').get(msg.runId);
2261
+ if (runKnown) {
2262
+ const seq = nextNodeEventSeq(db, msg.runId, runSeq);
2263
+ runSeq.set(msg.runId, seq);
2264
+ appendNodeEvent(db, msg.runId, seq, approvalEvent);
2265
+ }
2266
+ broadcastWithChannelForward(msg.conversationId, approvalEvent);
2267
+ break;
2268
+ }
2269
+ case 'workspace.list.response': {
2270
+ workspaceBroker?.handleWorkspaceListResponse(msg);
2271
+ break;
2272
+ }
2273
+ case 'workspace.inspect.response': {
2274
+ inspectBroker?.handleInspectResponse(msg);
2275
+ break;
2276
+ }
2277
+ case 'workspace.git_status.response': {
2278
+ gitBroker?.handleGitStatusResponse(msg);
2279
+ break;
2280
+ }
2281
+ case 'workspace.git_diff.response': {
2282
+ gitBroker?.handleGitDiffResponse(msg);
2283
+ break;
2284
+ }
2285
+ case 'workspace.git_action.response': {
2286
+ gitBroker?.handleGitActionResponse(msg);
2287
+ break;
2288
+ }
2289
+ case 'workspace.read.response': {
2290
+ workspaceBroker?.handleWorkspaceReadResponse(msg);
2291
+ break;
2292
+ }
2293
+ case 'workspace.archive.response': {
2294
+ workspaceBroker?.handleWorkspaceArchiveResponse(msg);
2295
+ break;
2296
+ }
2297
+ case 'workspace.extract.response': {
2298
+ workspaceBroker?.handleWorkspaceExtractResponse(msg);
2299
+ break;
2300
+ }
2301
+ case 'library.mirror.apply.response': {
2302
+ workspaceBroker?.handleLibraryMirrorApplyResponse(msg);
2303
+ break;
2304
+ }
2305
+ case 'library.mirror.snapshot.response': {
2306
+ workspaceBroker?.handleLibraryMirrorSnapshotResponse(msg);
2307
+ break;
2308
+ }
2309
+ case 'library.authority.delete.response': {
2310
+ workspaceBroker?.handleLibraryAuthorityDeleteResponse(msg);
2311
+ break;
2312
+ }
2313
+ case 'library.authority.rename.response': {
2314
+ workspaceBroker?.handleLibraryAuthorityRenameResponse(msg);
2315
+ break;
2316
+ }
2317
+ case 'fs.read_path.response': {
2318
+ workspaceBroker?.handleFsReadPathResponse(msg);
2319
+ break;
2320
+ }
2321
+ case 'fs.path_suggest.response': {
2322
+ workspaceBroker?.handleFsPathSuggestResponse(msg);
2323
+ break;
2324
+ }
2325
+ case 'http.fetch_text.response': {
2326
+ workspaceBroker?.handleHttpFetchTextResponse(msg);
2327
+ break;
2328
+ }
2329
+ case 'workspace.service_proxy.response': {
2330
+ workspaceBroker?.handleWorkspaceServiceProxyResponse(msg);
2331
+ break;
2332
+ }
2333
+ case 'fs.read_path.stream.start': {
2334
+ workspaceBroker?.handleFsReadPathStreamStart(msg);
2335
+ break;
2336
+ }
2337
+ case 'fs.read_path.stream.chunk': {
2338
+ workspaceBroker?.handleFsReadPathStreamChunk(msg);
2339
+ break;
2340
+ }
2341
+ case 'fs.read_path.stream.end': {
2342
+ workspaceBroker?.handleFsReadPathStreamEnd(msg);
2343
+ break;
2344
+ }
2345
+ case 'workspace.write.response': {
2346
+ workspaceBroker?.handleWorkspaceWriteResponse(msg);
2347
+ break;
2348
+ }
2349
+ case 'workspace.delete_file.response': {
2350
+ workspaceBroker?.handleWorkspaceDeleteFileResponse(msg);
2351
+ break;
2352
+ }
2353
+ case 'workspace.delete_path.response': {
2354
+ workspaceBroker?.handleWorkspaceDeletePathResponse(msg);
2355
+ break;
2356
+ }
2357
+ case 'workspace.reset.response': {
2358
+ workspaceBroker?.handleWorkspaceResetResponse(msg);
2359
+ break;
2360
+ }
2361
+ case 'agent.cleanup.response': {
2362
+ agentRuntimeCleanupBroker?.handleCleanupResponse(msg);
2363
+ break;
2364
+ }
2365
+ case 'terminal.list.response': {
2366
+ terminalBroker?.handleListResponse(msg);
2367
+ break;
2368
+ }
2369
+ case 'terminal.create.response': {
2370
+ terminalBroker?.handleCreateResponse(msg);
2371
+ break;
2372
+ }
2373
+ case 'terminal.snapshot.response': {
2374
+ terminalBroker?.handleSnapshotResponse(msg);
2375
+ break;
2376
+ }
2377
+ case 'terminal.input.response': {
2378
+ terminalBroker?.handleInputResponse(msg);
2379
+ break;
2380
+ }
2381
+ case 'terminal.resize.response': {
2382
+ terminalBroker?.handleResizeResponse(msg);
2383
+ break;
2384
+ }
2385
+ case 'terminal.close.response': {
2386
+ terminalBroker?.handleCloseResponse(msg);
2387
+ break;
2388
+ }
2389
+ case 'terminal.output.event': {
2390
+ terminalBroker?.handleOutputEvent(msg);
2391
+ break;
2392
+ }
2393
+ case 'terminal.exit.event': {
2394
+ terminalBroker?.handleExitEvent(msg);
2395
+ break;
2396
+ }
2397
+ case 'skills.list.response': {
2398
+ skillsBroker?.handleSkillsListResponse(msg);
2399
+ break;
2400
+ }
2401
+ case 'skills.read.response': {
2402
+ skillsBroker?.handleSkillsReadResponse(msg);
2403
+ break;
2404
+ }
2405
+ case 'codex.transcript.list.response': {
2406
+ codexTranscriptBroker?.handleListResponse(msg);
2407
+ break;
2408
+ }
2409
+ case 'codex.transcript.read.response': {
2410
+ codexTranscriptBroker?.handleReadResponse(msg);
2411
+ break;
2412
+ }
2413
+ case 'claude.transcript.list.response': {
2414
+ claudeTranscriptBroker?.handleListResponse(msg);
2415
+ break;
2416
+ }
2417
+ case 'claude.transcript.read.response': {
2418
+ claudeTranscriptBroker?.handleReadResponse(msg);
2419
+ break;
2420
+ }
2421
+ case 'claude.controls.response': {
2422
+ claudeControlBroker?.handleControlsResponse(msg);
2423
+ break;
2424
+ }
2425
+ case 'claude.set_mode.response': {
2426
+ claudeControlBroker?.handleSetModeResponse(msg);
2427
+ break;
2428
+ }
2429
+ case 'claude.set_model.response': {
2430
+ claudeControlBroker?.handleSetModelResponse(msg);
2431
+ break;
2432
+ }
2433
+ case 'claude.command.response': {
2434
+ claudeControlBroker?.handleCommandResponse(msg);
2435
+ break;
2436
+ }
2437
+ case 'codex.model.list.response': {
2438
+ codexAppServerBroker?.handleModelListResponse(msg);
2439
+ break;
2440
+ }
2441
+ case 'codex.skills.list.response': {
2442
+ codexAppServerBroker?.handleSkillsListResponse(msg);
2443
+ break;
2444
+ }
2445
+ case 'codex.thread.list.response': {
2446
+ codexAppServerBroker?.handleThreadListResponse(msg);
2447
+ break;
2448
+ }
2449
+ case 'codex.thread.read.response': {
2450
+ codexAppServerBroker?.handleThreadReadResponse(msg);
2451
+ break;
2452
+ }
2453
+ case 'codex.conversation.summary.response': {
2454
+ codexAppServerBroker?.handleConversationSummaryResponse(msg);
2455
+ break;
2456
+ }
2457
+ case 'solo.run.event':
2458
+ case 'solo.run.end':
2459
+ case 'solo.permission.request': {
2460
+ if (!soloSessionRelay?.relayNodeMessage(msg)) {
2461
+ log.debug('[node-ws] solo message with no active client relay', {
2462
+ type: msg.type,
2463
+ soloSessionId: msg.soloSessionId,
2464
+ });
2465
+ }
2466
+ break;
2467
+ }
2468
+ default: {
2469
+ log.warn('[node-ws] unknown message type', msg.type);
2470
+ }
2471
+ }
2472
+ });
2473
+ socket.on('close', () => {
2474
+ if (nodeId) {
2475
+ if (isStaleNodeConnection()) {
2476
+ log.debug('[node-ws] ignoring stale socket close for replaced node connection', {
2477
+ nodeId,
2478
+ connectionProcessInstanceId,
2479
+ });
2480
+ return;
2481
+ }
2482
+ const disconnectMessage = `Agent node disconnected: ${nodeId}`;
2483
+ failNodeOwnedRuntimeState({
2484
+ nodeId,
2485
+ disconnectMessage,
2486
+ unregisterNode: true,
2487
+ markNodeOffline: true,
2488
+ });
2489
+ log.info(`[node-ws] disconnected: ${nodeId}`);
2490
+ }
2491
+ });
2492
+ socket.on('error', (err) => {
2493
+ log.warn('[node-ws] socket error', err);
2494
+ });
2495
+ }