@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,2045 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readdirSync } from 'node:fs';
3
+ import { MissionServiceError } from './missionService.js';
4
+ import { parseMissionBrief } from './nativeMissionBriefParser.js';
5
+ import { NativeMissionValidationRunner, } from './nativeMissionValidationRunner.js';
6
+ export class NativeMissionOrchestrator {
7
+ missionService;
8
+ workerBroker;
9
+ validationRunner;
10
+ activeMissions = new Set();
11
+ pausedMissions = new Set();
12
+ runningFeatures = new Map();
13
+ currentMilestones = new Map();
14
+ constructor(params) {
15
+ this.missionService = params.missionService;
16
+ this.workerBroker = params.workerBroker;
17
+ this.validationRunner =
18
+ params.validationRunner ??
19
+ new NativeMissionValidationRunner({ missionService: params.missionService });
20
+ }
21
+ isMissionActive(missionId) {
22
+ return this.activeMissions.has(missionId) || this.workerBroker.hasActiveMission(missionId);
23
+ }
24
+ recordRuntimeEvent(missionId, eventType, payload, source = 'platform') {
25
+ this.missionService.recordMissionRuntimeEvent({
26
+ missionId,
27
+ eventType,
28
+ source,
29
+ eventTime: Date.now(),
30
+ payload,
31
+ });
32
+ }
33
+ emitOrchestratorDecision(missionId, decisionType, details) {
34
+ this.recordRuntimeEvent(missionId, 'orchestrator_decision', {
35
+ decisionType,
36
+ ...details,
37
+ });
38
+ }
39
+ emitPlanningEvents(missionId, requestId, features, assertionCount) {
40
+ const milestoneSet = new Set(features.map((feature) => feature.milestone));
41
+ this.recordRuntimeEvent(missionId, 'mission_planning_started', {
42
+ requestId,
43
+ featureCount: features.length,
44
+ milestoneCount: milestoneSet.size,
45
+ });
46
+ this.recordRuntimeEvent(missionId, 'mission_planning_completed', {
47
+ requestId,
48
+ featureCount: features.length,
49
+ milestoneCount: milestoneSet.size,
50
+ assertionCount,
51
+ plan: features.map((feature) => ({
52
+ featureId: feature.featureId,
53
+ milestone: feature.milestone,
54
+ description: feature.description,
55
+ ordering: feature.ordering,
56
+ preconditions: feature.preconditions,
57
+ expectedBehavior: feature.expectedBehavior,
58
+ })),
59
+ });
60
+ }
61
+ persistPlanAndMilestones(missionId, requestId, features, assertionCount) {
62
+ const milestoneNames = [...new Set(features.map((feature) => feature.milestone))];
63
+ const milestoneOrdering = new Map();
64
+ for (const feature of features) {
65
+ if (!milestoneOrdering.has(feature.milestone) || feature.ordering < milestoneOrdering.get(feature.milestone)) {
66
+ milestoneOrdering.set(feature.milestone, feature.ordering);
67
+ }
68
+ }
69
+ const plan = this.missionService.replaceMissionPlan({
70
+ missionId,
71
+ userId: '',
72
+ isAdmin: true,
73
+ plan: {
74
+ requestId,
75
+ version: (this.missionService.getMissionPlan({ missionId, userId: '', isAdmin: true })?.version ?? 0) + 1,
76
+ featureCount: features.length,
77
+ milestoneCount: milestoneNames.length,
78
+ assertionCount,
79
+ features: features.map((feature) => ({
80
+ featureId: feature.featureId,
81
+ milestone: feature.milestone,
82
+ description: feature.description,
83
+ skillName: feature.skillName,
84
+ ordering: feature.ordering,
85
+ preconditions: feature.preconditions,
86
+ expectedBehavior: feature.expectedBehavior,
87
+ })),
88
+ milestones: milestoneNames.map((name) => ({
89
+ name,
90
+ ordering: milestoneOrdering.get(name) ?? 0,
91
+ })),
92
+ },
93
+ });
94
+ const existingMilestones = this.missionService.listMissionMilestones({ missionId, userId: '', isAdmin: true });
95
+ const milestoneByName = new Map(existingMilestones.map((m) => [m.name, m]));
96
+ const milestones = this.missionService.replaceMissionMilestones({
97
+ missionId,
98
+ userId: '',
99
+ isAdmin: true,
100
+ milestones: milestoneNames.map((name, index) => {
101
+ const existing = milestoneByName.get(name);
102
+ return {
103
+ milestoneId: existing?.milestoneId,
104
+ name,
105
+ status: existing?.status ?? 'pending',
106
+ ordering: milestoneOrdering.get(name) ?? index,
107
+ startedAt: existing?.startedAt,
108
+ completedAt: existing?.completedAt,
109
+ };
110
+ }),
111
+ });
112
+ this.emitOrchestratorDecision(missionId, 'plan_accepted', {
113
+ requestId,
114
+ planVersion: plan.version,
115
+ featureCount: features.length,
116
+ milestoneCount: milestoneNames.length,
117
+ assertionCount,
118
+ });
119
+ return { plan, milestones };
120
+ }
121
+ emitMilestoneStartedIfNeeded(missionId, milestone, featureId) {
122
+ const current = this.currentMilestones.get(missionId);
123
+ if (current === milestone)
124
+ return;
125
+ this.currentMilestones.set(missionId, milestone);
126
+ const milestones = this.missionService.listMissionMilestones({ missionId, userId: '', isAdmin: true });
127
+ const milestoneRow = milestones.find((m) => m.name === milestone);
128
+ if (milestoneRow && milestoneRow.status !== 'running' && milestoneRow.status !== 'completed' && milestoneRow.status !== 'failed') {
129
+ this.missionService.updateMissionMilestoneStatus({
130
+ missionId,
131
+ milestoneId: milestoneRow.milestoneId,
132
+ status: 'running',
133
+ startedAt: Date.now(),
134
+ });
135
+ }
136
+ this.recordRuntimeEvent(missionId, 'milestone_started', {
137
+ milestone,
138
+ featureId,
139
+ });
140
+ this.emitOrchestratorDecision(missionId, 'milestone_started', {
141
+ milestone,
142
+ featureId,
143
+ milestoneId: milestoneRow?.milestoneId,
144
+ });
145
+ }
146
+ emitMilestoneCompletedIfNeeded(missionId, milestone) {
147
+ const features = this.missionService.listMissionFeatures({
148
+ missionId,
149
+ userId: '',
150
+ isAdmin: true,
151
+ });
152
+ const milestoneFeatures = features.filter((feature) => feature.milestone === milestone);
153
+ const terminalStatuses = ['completed', 'failed', 'skipped', 'cancelled'];
154
+ const allTerminal = milestoneFeatures.length > 0 && milestoneFeatures.every((feature) => terminalStatuses.includes(feature.status));
155
+ if (!allTerminal)
156
+ return;
157
+ const statusCounts = {};
158
+ for (const feature of milestoneFeatures) {
159
+ statusCounts[feature.status] = (statusCounts[feature.status] ?? 0) + 1;
160
+ }
161
+ const milestones = this.missionService.listMissionMilestones({ missionId, userId: '', isAdmin: true });
162
+ const milestoneRow = milestones.find((m) => m.name === milestone);
163
+ if (milestoneRow) {
164
+ let finalStatus = 'completed';
165
+ if (statusCounts.failed > 0 || statusCounts.cancelled > 0) {
166
+ finalStatus = 'failed';
167
+ }
168
+ else if (statusCounts.skipped > 0 && statusCounts.completed === 0) {
169
+ finalStatus = 'skipped';
170
+ }
171
+ this.missionService.updateMissionMilestoneStatus({
172
+ missionId,
173
+ milestoneId: milestoneRow.milestoneId,
174
+ status: finalStatus,
175
+ completedAt: Date.now(),
176
+ });
177
+ }
178
+ this.recordRuntimeEvent(missionId, 'milestone_completed', {
179
+ milestone,
180
+ featureCount: milestoneFeatures.length,
181
+ statusCounts,
182
+ });
183
+ this.emitOrchestratorDecision(missionId, 'milestone_completed', {
184
+ milestone,
185
+ featureCount: milestoneFeatures.length,
186
+ statusCounts,
187
+ milestoneId: milestoneRow?.milestoneId,
188
+ });
189
+ }
190
+ async startMission(input) {
191
+ const mission = this.requireMission(input);
192
+ if (mission.runtimeProvider !== 'bigbang_native') {
193
+ throw this.error(400, 'Mission does not use the native runtime provider');
194
+ }
195
+ if (mission.state !== 'draft') {
196
+ throw this.error(409, 'Mission is already active or terminal');
197
+ }
198
+ if (this.activeMissions.has(mission.missionId) || this.workerBroker.hasActiveMission(mission.missionId)) {
199
+ throw this.error(409, 'Mission is already queued or running');
200
+ }
201
+ const nodeId = input.nodeId ?? mission.nodeId;
202
+ if (!nodeId)
203
+ throw this.error(400, 'nodeId is required');
204
+ if (!mission.missionDir)
205
+ throw this.error(400, 'missionDir is required');
206
+ this.activeMissions.add(mission.missionId);
207
+ try {
208
+ const requestId = randomUUID();
209
+ const queued = this.missionService.startMission({
210
+ missionId: mission.missionId,
211
+ userId: input.userId,
212
+ isAdmin: input.isAdmin,
213
+ nodeId,
214
+ eventPayload: { requestId, runtimeProvider: 'bigbang_native' },
215
+ });
216
+ const attempt = this.missionService.createMissionAttempt({
217
+ missionId: mission.missionId,
218
+ requestId,
219
+ attemptType: 'run',
220
+ nodeId,
221
+ missionDir: mission.missionDir,
222
+ prompt: mission.description || mission.title,
223
+ });
224
+ const parsedFeatures = parseMissionBrief(mission.description);
225
+ this.missionService.replaceMissionFeatures({
226
+ missionId: mission.missionId,
227
+ userId: input.userId,
228
+ isAdmin: input.isAdmin,
229
+ features: parsedFeatures.map((feature, index) => ({
230
+ featureId: feature.featureId,
231
+ milestone: feature.milestone,
232
+ description: feature.description,
233
+ skillName: feature.skillName,
234
+ status: feature.status,
235
+ ordering: feature.ordering ?? index,
236
+ preconditions: feature.preconditions,
237
+ expectedBehavior: feature.expectedBehavior,
238
+ })),
239
+ });
240
+ const generatedAssertions = this.missionService.generateValidationAssertionsFromFeatures({
241
+ missionId: mission.missionId,
242
+ userId: input.userId,
243
+ isAdmin: input.isAdmin,
244
+ });
245
+ const persistedFeatures = this.missionService.listMissionFeatures({
246
+ missionId: mission.missionId,
247
+ userId: input.userId,
248
+ isAdmin: input.isAdmin,
249
+ });
250
+ this.emitPlanningEvents(mission.missionId, requestId, persistedFeatures, generatedAssertions.length);
251
+ this.persistPlanAndMilestones(mission.missionId, requestId, persistedFeatures, generatedAssertions.length);
252
+ this.missionService.recordMissionRuntimeEvent({
253
+ missionId: mission.missionId,
254
+ eventType: 'mission_features_parsed',
255
+ source: 'platform',
256
+ eventTime: Date.now(),
257
+ payload: { requestId, featureCount: parsedFeatures.length, assertionCount: generatedAssertions.length },
258
+ });
259
+ this.missionService.recordMissionRuntimeEvent({
260
+ missionId: mission.missionId,
261
+ eventType: 'mission_assertions_generated',
262
+ source: 'platform',
263
+ eventTime: Date.now(),
264
+ payload: { requestId, assertionCount: generatedAssertions.length },
265
+ });
266
+ let accepted;
267
+ try {
268
+ accepted = await this.workerBroker.runMission(nodeId, queued, { requestId, attemptId: attempt.attemptId });
269
+ }
270
+ catch (error) {
271
+ const message = String(error?.message ?? error);
272
+ this.missionService.recordMissionRuntimeEvent({
273
+ missionId: mission.missionId,
274
+ eventType: 'mission_attempt_start_failed',
275
+ source: 'platform',
276
+ eventTime: Date.now(),
277
+ payload: { requestId, error: message },
278
+ });
279
+ this.missionService.markMissionStartFailed({
280
+ missionId: mission.missionId,
281
+ error: message,
282
+ requestId,
283
+ });
284
+ // Persist a partial snapshot even when the mission fails before any
285
+ // feature workers run so the UI has a normalized record of the failed
286
+ // start.
287
+ this.persistMissionSnapshot(mission.missionId);
288
+ throw error;
289
+ }
290
+ this.missionService.markMissionAccepted({
291
+ missionId: mission.missionId,
292
+ missionDir: mission.missionDir,
293
+ pid: accepted.pid,
294
+ requestId,
295
+ });
296
+ // Begin the ordered feature queue. For missions with no features this
297
+ // completes the mission immediately.
298
+ await this.dispatchNextFeature(mission.missionId, nodeId, mission.missionDir, input.userId, input.isAdmin);
299
+ const updated = this.missionService.getMission({
300
+ missionId: mission.missionId,
301
+ userId: input.userId,
302
+ isAdmin: input.isAdmin,
303
+ });
304
+ if (!updated)
305
+ throw this.error(404, 'Mission not found');
306
+ return {
307
+ mission: updated,
308
+ accepted: {
309
+ requestId: accepted.requestId,
310
+ missionId: accepted.missionId,
311
+ missionDir: accepted.missionDir,
312
+ pid: accepted.pid,
313
+ },
314
+ };
315
+ }
316
+ catch (error) {
317
+ this.activeMissions.delete(mission.missionId);
318
+ throw error;
319
+ }
320
+ }
321
+ pauseMission(input) {
322
+ const running = this.runningFeatures.get(input.missionId);
323
+ const features = this.missionService.listMissionFeatures({
324
+ missionId: input.missionId,
325
+ userId: '',
326
+ isAdmin: true,
327
+ });
328
+ const activeFeature = running ??
329
+ features
330
+ .filter((feature) => feature.status === 'running')
331
+ .map((feature) => ({
332
+ requestId: '',
333
+ featureId: feature.featureId,
334
+ nodeId: '',
335
+ attemptId: null,
336
+ milestone: feature.milestone,
337
+ }))[0] ??
338
+ null;
339
+ const nextFeature = activeFeature
340
+ ? null
341
+ : this.missionService.getNextMissionFeature({ missionId: input.missionId });
342
+ const milestones = this.missionService.listMissionMilestones({
343
+ missionId: input.missionId,
344
+ userId: '',
345
+ isAdmin: true,
346
+ });
347
+ const currentMilestoneRow = milestones.find((milestone) => milestone.status === 'running');
348
+ const effectivePausePoint = activeFeature
349
+ ? {
350
+ featureId: activeFeature.featureId,
351
+ milestone: activeFeature.milestone,
352
+ milestoneId: milestones.find((milestone) => milestone.name === activeFeature.milestone)?.milestoneId ?? null,
353
+ point: 'running_feature',
354
+ }
355
+ : nextFeature
356
+ ? {
357
+ featureId: nextFeature.featureId,
358
+ milestone: nextFeature.milestone,
359
+ milestoneId: milestones.find((milestone) => milestone.name === nextFeature.milestone)?.milestoneId ?? null,
360
+ point: 'next_pending_feature',
361
+ }
362
+ : {
363
+ featureId: null,
364
+ milestone: currentMilestoneRow?.name ?? null,
365
+ milestoneId: currentMilestoneRow?.milestoneId ?? null,
366
+ point: 'end_of_queue',
367
+ };
368
+ this.recordRuntimeEvent(input.missionId, 'mission_pause_requested', {
369
+ reason: input.reason ?? null,
370
+ effectivePausePoint,
371
+ });
372
+ try {
373
+ const mission = this.missionService.pauseMission({
374
+ missionId: input.missionId,
375
+ userId: input.userId,
376
+ isAdmin: input.isAdmin,
377
+ reason: input.reason,
378
+ effectivePausePoint,
379
+ });
380
+ this.recordRuntimeEvent(input.missionId, 'mission_pause_effective', {
381
+ effectivePausePoint,
382
+ });
383
+ this.pausedMissions.add(input.missionId);
384
+ return mission;
385
+ }
386
+ catch (error) {
387
+ // If the pause transition fails (e.g. mission already paused), only the
388
+ // requested event is emitted so the trace shows the intent without a
389
+ // spurious effective event.
390
+ throw error;
391
+ }
392
+ }
393
+ resumeMission(input) {
394
+ this.recordRuntimeEvent(input.missionId, 'mission_resume_requested', {
395
+ missionId: input.missionId,
396
+ });
397
+ try {
398
+ const mission = this.missionService.resumeMission(input);
399
+ this.recordRuntimeEvent(input.missionId, 'mission_resume_effective', {
400
+ missionId: input.missionId,
401
+ state: mission.state,
402
+ });
403
+ this.pausedMissions.delete(input.missionId);
404
+ // If no feature is currently running, continue the queue.
405
+ const running = this.runningFeatures.get(input.missionId);
406
+ if (!running) {
407
+ const missionDir = mission.missionDir;
408
+ const nodeId = mission.nodeId;
409
+ if (missionDir && nodeId && mission.state === 'running') {
410
+ void this.advanceQueue(input.missionId, nodeId, missionDir);
411
+ }
412
+ }
413
+ return mission;
414
+ }
415
+ catch (error) {
416
+ // If the resume transition fails (e.g. mission not paused), only the
417
+ // requested event is emitted.
418
+ throw error;
419
+ }
420
+ }
421
+ async queueInteraction(input) {
422
+ const mission = this.requireMission(input);
423
+ if (mission.runtimeProvider !== 'bigbang_native') {
424
+ throw this.error(400, 'Mission does not use the native runtime provider');
425
+ }
426
+ const prompt = input.prompt.trim();
427
+ if (!prompt) {
428
+ throw this.error(400, 'prompt is required');
429
+ }
430
+ if (!['queued', 'running', 'paused'].includes(mission.state)) {
431
+ throw this.error(409, 'Mission is not active');
432
+ }
433
+ const interaction = this.missionService.queueMissionInteraction({
434
+ missionId: input.missionId,
435
+ userId: input.userId,
436
+ isAdmin: input.isAdmin,
437
+ prompt,
438
+ });
439
+ // Deliver queued interactions to the orchestrator immediately. This does
440
+ // not abort the currently running feature worker; it only updates the
441
+ // orchestrator context so subsequent features see the follow-up.
442
+ const deliveredIds = this.deliverPendingInteractions(input.missionId);
443
+ if (deliveredIds.length > 0) {
444
+ this.recordRuntimeEvent(input.missionId, 'retry_scheduled', {
445
+ interactionIds: deliveredIds,
446
+ reason: 'follow_up_received',
447
+ retryTarget: 'validation_and_planning',
448
+ });
449
+ }
450
+ // Handle explicit follow-up intents: re-validation or plan amendment.
451
+ const amendmentAnalysis = this.analyzeFollowUpAmendment(input.prompt);
452
+ if (amendmentAnalysis.intent === 'revalidate') {
453
+ const revalidation = await this.revalidateMission(input.missionId, deliveredIds);
454
+ this.emitOrchestratorDecision(input.missionId, 'follow_up_handled', {
455
+ interactionIds: deliveredIds,
456
+ action: 'validation_retriggered',
457
+ resetFeatureCount: revalidation.resetFeatureCount,
458
+ evaluatedMilestones: revalidation.evaluatedMilestones,
459
+ });
460
+ }
461
+ else if (amendmentAnalysis.intent === 'plan_amendment'
462
+ || amendmentAnalysis.intent === 'append_natural') {
463
+ const amendment = this.applyFollowUpPlanAmendment(input.missionId, amendmentAnalysis.features, deliveredIds);
464
+ this.emitOrchestratorDecision(input.missionId, 'follow_up_handled', {
465
+ interactionIds: deliveredIds,
466
+ action: 'plan_modified',
467
+ modified: amendment.modified,
468
+ added: amendment.added,
469
+ modifiedFeatureIds: amendment.modifiedFeatureIds,
470
+ addedFeatureIds: amendment.addedFeatureIds,
471
+ });
472
+ }
473
+ else {
474
+ for (const interactionId of deliveredIds) {
475
+ this.missionService.updateMissionInteractionStatus({
476
+ missionId: input.missionId,
477
+ interactionId,
478
+ status: 'completed',
479
+ });
480
+ }
481
+ this.emitOrchestratorDecision(input.missionId, 'follow_up_handled', {
482
+ interactionIds: deliveredIds,
483
+ action: 'contextual_guidance',
484
+ });
485
+ }
486
+ const updatedInteraction = this.missionService.listMissionInteractions({
487
+ missionId: input.missionId,
488
+ userId: input.userId,
489
+ isAdmin: input.isAdmin,
490
+ }).find((i) => i.interactionId === interaction.interactionId);
491
+ const updated = this.missionService.getMission({
492
+ missionId: input.missionId,
493
+ userId: input.userId,
494
+ isAdmin: input.isAdmin,
495
+ });
496
+ if (!updated)
497
+ throw this.error(404, 'Mission not found');
498
+ return { mission: updated, interaction: updatedInteraction ?? interaction };
499
+ }
500
+ deliverPendingInteractions(missionId) {
501
+ const pending = this.missionService.listMissionInteractions({
502
+ missionId,
503
+ userId: '',
504
+ isAdmin: true,
505
+ }).filter((interaction) => interaction.status === 'queued');
506
+ const deliveredIds = [];
507
+ for (const interaction of pending) {
508
+ this.missionService.markMissionInteractionDelivered({
509
+ missionId,
510
+ interactionId: interaction.interactionId,
511
+ });
512
+ deliveredIds.push(interaction.interactionId);
513
+ }
514
+ // Transition delivered interactions to observed and emit a lifecycle
515
+ // event for each one. This is only done when the delivery loop succeeds
516
+ // so that no observed events are recorded for a failed delivery.
517
+ for (const interactionId of deliveredIds) {
518
+ this.missionService.updateMissionInteractionStatus({
519
+ missionId,
520
+ interactionId,
521
+ status: 'observed',
522
+ });
523
+ this.recordRuntimeEvent(missionId, 'mission_interaction_observed', {
524
+ interactionId,
525
+ missionId,
526
+ });
527
+ }
528
+ return deliveredIds;
529
+ }
530
+ analyzeFollowUpAmendment(prompt) {
531
+ const lower = prompt.toLowerCase();
532
+ const revalidateKeywords = [
533
+ 're-validate',
534
+ 'revalidate',
535
+ 'validate again',
536
+ 'reevaluate',
537
+ 're-evaluate',
538
+ 'check assertions again',
539
+ 'run validation again',
540
+ 're-run validation',
541
+ 'validate the completed features',
542
+ 'revalidate the results',
543
+ ];
544
+ if (revalidateKeywords.some((keyword) => lower.includes(keyword))) {
545
+ return { intent: 'revalidate' };
546
+ }
547
+ const hasStructure = /^#{2,3}\s+/m.test(prompt.trim());
548
+ if (hasStructure) {
549
+ const parsedFeatures = parseMissionBrief(prompt);
550
+ if (parsedFeatures.length > 0) {
551
+ return { intent: 'plan_amendment', features: parsedFeatures };
552
+ }
553
+ }
554
+ // Natural-language append intent: "Add a feature to ...", "Add feature: ...",
555
+ // "Please add a new feature that ...", etc.
556
+ const appendPattern = /^(?:please\s+)?(?:add|append|include|create)\s+(?:a\s+)?(?:new\s+)?feature\b(?:\s*[:,-]\s*|\s+to\s+|\s+for\s+|\s+that\s+|\s+which\s+|\s+)([\s\S]+)$/i;
557
+ const appendMatch = prompt.match(appendPattern);
558
+ if (appendMatch && appendMatch[1]?.trim()) {
559
+ return {
560
+ intent: 'append_natural',
561
+ features: [
562
+ {
563
+ featureId: randomUUID(),
564
+ milestone: 'Follow-up',
565
+ description: appendMatch[1].trim(),
566
+ skillName: null,
567
+ status: 'pending',
568
+ ordering: 0,
569
+ retryCount: 0,
570
+ preconditions: [],
571
+ expectedBehavior: [],
572
+ },
573
+ ],
574
+ };
575
+ }
576
+ return { intent: 'contextual_guidance' };
577
+ }
578
+ async revalidateMission(missionId, interactionIds = []) {
579
+ const features = this.missionService.listMissionFeatures({ missionId, userId: '', isAdmin: true });
580
+ const completedFeatures = features.filter((feature) => feature.status === 'completed' && !feature.isValidator);
581
+ if (completedFeatures.length > 0) {
582
+ this.missionService.resetMissionValidationAssertionsByFeatureStatus({
583
+ missionId,
584
+ userId: '',
585
+ isAdmin: true,
586
+ featureStatus: 'completed',
587
+ });
588
+ }
589
+ const evaluatedMilestones = [...new Set(completedFeatures.map((feature) => feature.milestone))];
590
+ this.emitOrchestratorDecision(missionId, 'validation_retriggered', {
591
+ reason: 'follow_up_request',
592
+ resetScope: 'completed_features',
593
+ resetFeatureCount: completedFeatures.length,
594
+ evaluatedMilestones,
595
+ });
596
+ const progress = await this.runPendingMilestoneValidations(missionId);
597
+ if (progress.halted) {
598
+ this.activeMissions.delete(missionId);
599
+ this.pausedMissions.delete(missionId);
600
+ }
601
+ for (const interactionId of interactionIds) {
602
+ this.missionService.updateMissionInteractionStatus({
603
+ missionId,
604
+ interactionId,
605
+ status: 'completed',
606
+ });
607
+ }
608
+ if (interactionIds.length > 0) {
609
+ this.recordRuntimeEvent(missionId, 'mission_interaction_applied', {
610
+ action: 'revalidated',
611
+ interactionIds,
612
+ resetFeatureCount: completedFeatures.length,
613
+ });
614
+ }
615
+ return { resetFeatureCount: completedFeatures.length, evaluatedMilestones };
616
+ }
617
+ applyFollowUpPlanAmendment(missionId, parsedFeatures, interactionIds = []) {
618
+ const allFeatures = this.missionService.listMissionFeatures({ missionId, userId: '', isAdmin: true });
619
+ const pendingFeatures = allFeatures.filter((feature) => feature.status === 'pending');
620
+ // Group parsed features and pending features by milestone so we can align
621
+ // them by sequential position. This allows a follow-up to update a pending
622
+ // feature even when its description changes.
623
+ const parsedByMilestone = groupFeaturesByMilestone(parsedFeatures);
624
+ const pendingByMilestone = groupFeaturesByMilestone(pendingFeatures);
625
+ let modified = 0;
626
+ let added = 0;
627
+ const now = Date.now();
628
+ const pendingReplacements = new Map();
629
+ for (const [milestone, parsedList] of parsedByMilestone) {
630
+ const pendingList = pendingByMilestone.get(milestone) ?? [];
631
+ for (let index = 0; index < parsedList.length; index += 1) {
632
+ const parsed = parsedList[index];
633
+ const pending = pendingList[index];
634
+ if (pending) {
635
+ pendingReplacements.set(pending.featureId, parsed);
636
+ modified += 1;
637
+ }
638
+ else {
639
+ added += 1;
640
+ }
641
+ }
642
+ }
643
+ if (modified === 0 && added === 0) {
644
+ return { modified: 0, added: 0, modifiedFeatureIds: [], addedFeatureIds: [] };
645
+ }
646
+ // Preserve the original milestone dispatch order inferred from existing
647
+ // features. New milestones introduced by the amendment are appended at
648
+ // the end.
649
+ const milestoneMinOrdering = new Map();
650
+ for (const feature of allFeatures) {
651
+ const current = milestoneMinOrdering.get(feature.milestone);
652
+ if (current === undefined || feature.ordering < current) {
653
+ milestoneMinOrdering.set(feature.milestone, feature.ordering);
654
+ }
655
+ }
656
+ const orderedMilestoneNames = [...milestoneMinOrdering.entries()]
657
+ .sort((left, right) => left[1] - right[1])
658
+ .map(([name]) => name);
659
+ // Identify parsed features that are genuinely new (not matched to an
660
+ // existing pending feature and not already present by description).
661
+ const newParsedFeatures = [];
662
+ for (const parsed of parsedFeatures) {
663
+ const alreadyMatched = [...pendingReplacements.values()].some((amended) => amended.description.trim().toLowerCase() === parsed.description.trim().toLowerCase());
664
+ if (alreadyMatched)
665
+ continue;
666
+ const existing = allFeatures.find((f) => f.description.trim().toLowerCase() === parsed.description.trim().toLowerCase());
667
+ if (existing)
668
+ continue;
669
+ newParsedFeatures.push(parsed);
670
+ }
671
+ // Build the feature list preserving original milestone order and feature
672
+ // order within each milestone. Replace matched pending features in-place,
673
+ // append new features for an existing milestone at the end of that
674
+ // milestone, and append new milestones at the very end.
675
+ const featuresByMilestone = groupFeaturesByMilestone(allFeatures);
676
+ const nextFeatures = [];
677
+ for (const milestone of orderedMilestoneNames) {
678
+ const milestoneFeatures = featuresByMilestone.get(milestone) ?? [];
679
+ for (const feature of milestoneFeatures) {
680
+ const amended = feature.status === 'pending' ? pendingReplacements.get(feature.featureId) : undefined;
681
+ if (amended) {
682
+ nextFeatures.push({
683
+ featureId: feature.featureId,
684
+ milestone: amended.milestone,
685
+ description: amended.description,
686
+ skillName: amended.skillName,
687
+ status: 'pending',
688
+ ordering: feature.ordering,
689
+ preconditions: amended.preconditions,
690
+ expectedBehavior: amended.expectedBehavior,
691
+ handoff: null,
692
+ });
693
+ }
694
+ else {
695
+ nextFeatures.push({
696
+ featureId: feature.featureId,
697
+ milestone: feature.milestone,
698
+ description: feature.description,
699
+ skillName: feature.skillName,
700
+ status: feature.status,
701
+ ordering: feature.ordering,
702
+ preconditions: feature.preconditions,
703
+ expectedBehavior: feature.expectedBehavior,
704
+ handoff: feature.handoff ?? null,
705
+ });
706
+ }
707
+ }
708
+ // Append new features for this existing milestone at its end.
709
+ for (const parsed of newParsedFeatures) {
710
+ if (parsed.milestone !== milestone)
711
+ continue;
712
+ nextFeatures.push({
713
+ featureId: parsed.featureId,
714
+ milestone: parsed.milestone,
715
+ description: parsed.description,
716
+ skillName: parsed.skillName,
717
+ status: 'pending',
718
+ ordering: 0,
719
+ preconditions: parsed.preconditions,
720
+ expectedBehavior: parsed.expectedBehavior,
721
+ handoff: null,
722
+ });
723
+ }
724
+ }
725
+ // New milestones are appended at the end in the order they first appear
726
+ // in the parsed amendment.
727
+ const existingMilestoneNames = new Set(orderedMilestoneNames);
728
+ const newMilestones = [...new Map(parsedFeatures.map((f) => [f.milestone, true])).keys()].filter((milestone) => !existingMilestoneNames.has(milestone));
729
+ for (const milestone of newMilestones) {
730
+ for (const parsed of newParsedFeatures) {
731
+ if (parsed.milestone !== milestone)
732
+ continue;
733
+ nextFeatures.push({
734
+ featureId: parsed.featureId,
735
+ milestone: parsed.milestone,
736
+ description: parsed.description,
737
+ skillName: parsed.skillName,
738
+ status: 'pending',
739
+ ordering: 0,
740
+ preconditions: parsed.preconditions,
741
+ expectedBehavior: parsed.expectedBehavior,
742
+ handoff: null,
743
+ });
744
+ }
745
+ }
746
+ // Reassign ordering sequentially to reflect the final preserved order.
747
+ for (let index = 0; index < nextFeatures.length; index += 1) {
748
+ nextFeatures[index].ordering = index;
749
+ }
750
+ this.missionService.replaceMissionFeatures({
751
+ missionId,
752
+ userId: '',
753
+ isAdmin: true,
754
+ features: nextFeatures,
755
+ });
756
+ this.missionService.generateValidationAssertionsFromFeatures({ missionId, userId: '', isAdmin: true });
757
+ // Refresh the persisted plan to reflect the amendment.
758
+ const features = this.missionService.listMissionFeatures({ missionId, userId: '', isAdmin: true });
759
+ const assertions = this.missionService.listMissionValidationAssertions({ missionId, userId: '', isAdmin: true });
760
+ this.persistPlanAndMilestones(missionId, `follow-up-${now}`, features, assertions.length);
761
+ const modifiedFeatureIds = [...pendingReplacements.keys()];
762
+ const addedFeatureIds = newParsedFeatures.map((feature) => feature.featureId);
763
+ this.emitOrchestratorDecision(missionId, 'plan_modified', {
764
+ modified,
765
+ added,
766
+ modifiedFeatureIds,
767
+ addedFeatureIds,
768
+ source: 'follow_up',
769
+ timestampMs: now,
770
+ });
771
+ for (const interactionId of interactionIds) {
772
+ this.missionService.updateMissionInteractionStatus({
773
+ missionId,
774
+ interactionId,
775
+ status: 'applied',
776
+ });
777
+ }
778
+ if (interactionIds.length > 0) {
779
+ this.recordRuntimeEvent(missionId, 'mission_interaction_applied', {
780
+ action: 'plan_amended',
781
+ interactionIds,
782
+ });
783
+ }
784
+ return { modified, added, modifiedFeatureIds, addedFeatureIds };
785
+ }
786
+ buildFeaturePrompt(missionId, mission, feature, enrichment = {}) {
787
+ const basePrompt = mission.description || mission.title;
788
+ const interactions = this.missionService.listMissionInteractions({
789
+ missionId,
790
+ userId: '',
791
+ isAdmin: true,
792
+ }).filter((interaction) => interaction.status === 'delivered'
793
+ || interaction.status === 'observed'
794
+ || interaction.status === 'applied'
795
+ || interaction.status === 'completed');
796
+ const sections = [basePrompt];
797
+ if (enrichment.priorHandoffs && enrichment.priorHandoffs.length > 0) {
798
+ sections.push('', '## Prior Work');
799
+ for (const handoff of enrichment.priorHandoffs) {
800
+ sections.push(`- ${handoff.featureId}: ${handoff.salientSummary}`);
801
+ if (handoff.whatWasImplemented && handoff.whatWasImplemented.length > 0) {
802
+ sections.push(' Implemented:');
803
+ for (const item of handoff.whatWasImplemented) {
804
+ sections.push(` - ${item}`);
805
+ }
806
+ }
807
+ }
808
+ }
809
+ if (interactions.length > 0) {
810
+ const followUps = interactions
811
+ .sort((left, right) => left.position - right.position)
812
+ .map((interaction, index) => `${index + 1}. ${interaction.prompt}`)
813
+ .join('\n');
814
+ sections.push('', 'User follow-ups during this mission:', followUps);
815
+ }
816
+ if (feature.preconditions.length > 0) {
817
+ sections.push('', '## Preconditions');
818
+ for (const precondition of feature.preconditions) {
819
+ sections.push(`- ${precondition}`);
820
+ }
821
+ }
822
+ if (enrichment.validationContract && enrichment.validationContract.trim().length > 0) {
823
+ sections.push('', '## Validation Criteria');
824
+ sections.push(enrichment.validationContract);
825
+ }
826
+ if (enrichment.workspaceFiles && enrichment.workspaceFiles.length > 0) {
827
+ sections.push('', '## Workspace Context');
828
+ sections.push('Top-level workspace entries:');
829
+ for (const entry of enrichment.workspaceFiles) {
830
+ sections.push(`- ${entry}`);
831
+ }
832
+ }
833
+ sections.push('', `Feature:\n${feature.description}`);
834
+ return sections.join('\n');
835
+ }
836
+ buildPriorHandoffs(missionId, currentFeatureId) {
837
+ const features = this.missionService.listMissionFeatures({
838
+ missionId,
839
+ userId: '',
840
+ isAdmin: true,
841
+ });
842
+ const completed = features.filter((feature) => feature.status === 'completed'
843
+ && feature.featureId !== currentFeatureId
844
+ && feature.handoff != null);
845
+ return completed
846
+ .slice(-5)
847
+ .map((feature) => {
848
+ const handoff = feature.handoff ?? {};
849
+ const salientSummary = typeof handoff.salientSummary === 'string' ? handoff.salientSummary : '';
850
+ const whatWasImplemented = Array.isArray(handoff.whatWasImplemented)
851
+ ? handoff.whatWasImplemented.filter((item) => typeof item === 'string')
852
+ : undefined;
853
+ return {
854
+ featureId: feature.featureId,
855
+ salientSummary,
856
+ whatWasImplemented: whatWasImplemented && whatWasImplemented.length > 0 ? whatWasImplemented : undefined,
857
+ };
858
+ })
859
+ .filter((handoff) => handoff.salientSummary.length > 0);
860
+ }
861
+ buildValidationContract(missionId, feature) {
862
+ const assertions = this.missionService.listMissionValidationAssertions({
863
+ missionId,
864
+ userId: '',
865
+ isAdmin: true,
866
+ });
867
+ const featureAssertions = assertions.filter((assertion) => assertion.featureId === feature.featureId);
868
+ const lines = [];
869
+ if (feature.expectedBehavior.length > 0) {
870
+ lines.push('Expected behavior:');
871
+ for (const item of feature.expectedBehavior) {
872
+ lines.push(`- ${item}`);
873
+ }
874
+ }
875
+ if (featureAssertions.length > 0) {
876
+ if (lines.length > 0)
877
+ lines.push('');
878
+ lines.push('Validation assertions:');
879
+ for (const assertion of featureAssertions) {
880
+ lines.push(`- ${assertion.assertionText}`);
881
+ }
882
+ }
883
+ return lines.join('\n');
884
+ }
885
+ listWorkspaceFiles(workspaceRoot) {
886
+ try {
887
+ return readdirSync(workspaceRoot);
888
+ }
889
+ catch {
890
+ return [];
891
+ }
892
+ }
893
+ async cancelMission(input) {
894
+ const mission = this.missionService.getMission({
895
+ missionId: input.missionId,
896
+ userId: input.userId,
897
+ isAdmin: input.isAdmin,
898
+ });
899
+ if (!mission)
900
+ throw this.error(404, 'Mission not found');
901
+ if (mission.runtimeProvider !== 'bigbang_native') {
902
+ throw this.error(400, 'Mission does not use the native runtime provider');
903
+ }
904
+ // Compute the set of features that will be cancelled before the state
905
+ // transition so the effective event can report an accurate count.
906
+ const features = this.missionService.listMissionFeatures({
907
+ missionId: input.missionId,
908
+ userId: input.userId,
909
+ isAdmin: input.isAdmin,
910
+ });
911
+ const running = this.runningFeatures.get(input.missionId);
912
+ const runningFeatureId = running?.featureId ?? null;
913
+ const cancellableStatuses = new Set(['running', 'pending', 'queued']);
914
+ const cancelledFeatureCount = features.filter((feature) => cancellableStatuses.has(feature.status)
915
+ || (feature.featureId === runningFeatureId && feature.status === 'running')).length;
916
+ this.recordRuntimeEvent(input.missionId, 'mission_cancellation_requested', {
917
+ reason: 'user_requested',
918
+ expectedCancelledFeatureCount: cancelledFeatureCount,
919
+ });
920
+ let cancelled;
921
+ try {
922
+ cancelled = this.missionService.cancelMission(input);
923
+ }
924
+ catch (error) {
925
+ // If the cancellation transition fails (e.g. mission already terminal),
926
+ // only the requested event is emitted.
927
+ throw error;
928
+ }
929
+ this.pausedMissions.delete(input.missionId);
930
+ // Mark the currently running feature as cancelled before removing it from
931
+ // the in-memory tracking. This prevents a late worker-end message from
932
+ // leaving the feature row in a stale `running` state.
933
+ if (running) {
934
+ this.missionService.updateMissionFeatureStatus({
935
+ missionId: input.missionId,
936
+ featureId: running.featureId,
937
+ status: 'cancelled',
938
+ eventType: 'feature_cancelled',
939
+ source: 'platform',
940
+ eventPayload: { reason: 'mission_cancelled' },
941
+ });
942
+ this.workerBroker.cancelFeature(running.requestId);
943
+ this.runningFeatures.delete(input.missionId);
944
+ }
945
+ // Mark any pending or queued features as cancelled.
946
+ for (const feature of features) {
947
+ if (feature.status === 'pending' || feature.status === 'queued') {
948
+ this.missionService.updateMissionFeatureStatus({
949
+ missionId: input.missionId,
950
+ featureId: feature.featureId,
951
+ status: 'cancelled',
952
+ eventType: 'feature_cancelled',
953
+ source: 'platform',
954
+ eventPayload: { reason: 'mission_cancelled' },
955
+ });
956
+ }
957
+ }
958
+ // Run milestone validation after cancelling features so that assertions
959
+ // for skipped/cancelled features are marked blocked rather than pending.
960
+ try {
961
+ await this.runPendingMilestoneValidations(input.missionId);
962
+ }
963
+ catch (error) {
964
+ const message = String(error?.message ?? error);
965
+ this.missionService.recordMissionRuntimeEvent({
966
+ missionId: input.missionId,
967
+ eventType: 'mission_validation_failed',
968
+ source: 'platform',
969
+ eventTime: Date.now(),
970
+ payload: { error: message },
971
+ });
972
+ }
973
+ this.recordRuntimeEvent(input.missionId, 'mission_cancellation_effective', {
974
+ cancelledFeatureCount,
975
+ });
976
+ this.activeMissions.delete(input.missionId);
977
+ return cancelled;
978
+ }
979
+ recordWorkerEvent(attempt, msg) {
980
+ this.missionService.recordMissionRuntimeEvent({
981
+ missionId: attempt.missionId,
982
+ eventType: msg.eventType,
983
+ source: msg.source,
984
+ eventTime: msg.eventTime,
985
+ payload: {
986
+ ...msg.payload,
987
+ requestId: attempt.requestId,
988
+ featureId: attempt.featureId ?? msg.featureId ?? null,
989
+ },
990
+ });
991
+ }
992
+ handleAttemptSuperseded(attempt, reason, supersededAt) {
993
+ if (!attempt.attemptId)
994
+ return;
995
+ const successor = this.missionService.listAttempts({
996
+ missionId: attempt.missionId,
997
+ userId: '',
998
+ isAdmin: true,
999
+ })
1000
+ .filter((a) => a.state === 'running' || a.state === 'queued')
1001
+ .sort((left, right) => right.createdAt - left.createdAt)[0];
1002
+ const supersededBy = successor && successor.attemptId !== attempt.attemptId ? successor.attemptId : null;
1003
+ // For orphan_cleanup, node-disconnect failures are not retryable so no
1004
+ // successor is created. Record the supersession with supersededBy=null.
1005
+ if (!supersededBy && reason !== 'orphan_cleanup')
1006
+ return;
1007
+ try {
1008
+ this.missionService.markAttemptSuperseded({
1009
+ attemptId: attempt.attemptId,
1010
+ supersededBy,
1011
+ reason,
1012
+ supersededAt,
1013
+ });
1014
+ }
1015
+ catch (error) {
1016
+ // If the attempt is already superseded, the cleanup intent is already
1017
+ // satisfied; do not throw.
1018
+ const message = String(error?.message ?? error);
1019
+ if (!message.includes('already superseded')) {
1020
+ this.recordRuntimeEvent(attempt.missionId, 'supersession_failed', {
1021
+ attemptId: attempt.attemptId,
1022
+ reason,
1023
+ error: message,
1024
+ });
1025
+ }
1026
+ }
1027
+ }
1028
+ async onWorkerEnd(attempt, msg) {
1029
+ const running = this.runningFeatures.get(attempt.missionId);
1030
+ if (!running || running.requestId !== attempt.requestId)
1031
+ return;
1032
+ this.runningFeatures.delete(attempt.missionId);
1033
+ const featureId = attempt.featureId ?? msg.featureId;
1034
+ if (!featureId)
1035
+ return;
1036
+ const mission = this.missionService.getMission({ missionId: attempt.missionId, userId: '', isAdmin: true });
1037
+ if (!mission || (mission.state !== 'running' && mission.state !== 'paused'))
1038
+ return;
1039
+ const finalStatus = this.resolveFeatureStatus(msg);
1040
+ const failed = finalStatus === 'failed';
1041
+ const skipped = finalStatus === 'skipped';
1042
+ if (failed) {
1043
+ const retryPolicy = this.resolveRetryPolicy(mission);
1044
+ const feature = this.missionService
1045
+ .listMissionFeatures({ missionId: attempt.missionId, userId: '', isAdmin: true })
1046
+ .find((f) => f.featureId === featureId);
1047
+ const retryCount = feature?.retryCount ?? 0;
1048
+ if (this.isRetryableFailure(msg) && retryCount < retryPolicy.maxRetries) {
1049
+ const nextRetryCount = retryCount + 1;
1050
+ this.missionService.incrementMissionFeatureRetryCount({
1051
+ missionId: attempt.missionId,
1052
+ featureId,
1053
+ });
1054
+ this.missionService.updateMissionFeatureStatus({
1055
+ missionId: attempt.missionId,
1056
+ featureId,
1057
+ status: 'pending',
1058
+ eventType: 'retry_scheduled',
1059
+ source: 'platform',
1060
+ eventPayload: {
1061
+ requestId: attempt.requestId,
1062
+ attemptId: running.attemptId,
1063
+ featureId,
1064
+ milestone: running.milestone,
1065
+ retryCount: nextRetryCount,
1066
+ maxRetries: retryPolicy.maxRetries,
1067
+ reason: msg.reason ?? null,
1068
+ error: msg.error ?? null,
1069
+ },
1070
+ });
1071
+ this.missionService.resetMissionValidationAssertions({
1072
+ missionId: attempt.missionId,
1073
+ userId: '',
1074
+ isAdmin: true,
1075
+ featureId,
1076
+ });
1077
+ const delayMs = this.computeRetryDelay(retryPolicy, retryCount);
1078
+ if (!this.pausedMissions.has(attempt.missionId)) {
1079
+ if (delayMs <= 0) {
1080
+ setImmediate(() => {
1081
+ void this.advanceQueue(attempt.missionId, attempt.nodeId, msg.missionDir, {
1082
+ attemptId: running.attemptId,
1083
+ reason: 'retry',
1084
+ });
1085
+ });
1086
+ }
1087
+ else {
1088
+ setTimeout(() => {
1089
+ void this.advanceQueue(attempt.missionId, attempt.nodeId, msg.missionDir, {
1090
+ attemptId: running.attemptId,
1091
+ reason: 'retry',
1092
+ });
1093
+ }, delayMs);
1094
+ }
1095
+ }
1096
+ return;
1097
+ }
1098
+ if (retryCount > 0) {
1099
+ this.recordRuntimeEvent(attempt.missionId, 'retry_exhausted', {
1100
+ requestId: attempt.requestId,
1101
+ attemptId: running.attemptId,
1102
+ featureId,
1103
+ milestone: running.milestone,
1104
+ retryCount,
1105
+ maxRetries: retryPolicy.maxRetries,
1106
+ reason: msg.reason ?? null,
1107
+ error: msg.error ?? null,
1108
+ });
1109
+ }
1110
+ }
1111
+ this.missionService.updateMissionFeatureStatus({
1112
+ missionId: attempt.missionId,
1113
+ featureId,
1114
+ status: finalStatus,
1115
+ eventType: failed ? 'feature_failed' : skipped ? 'feature_skipped' : 'feature_completed',
1116
+ source: 'agent_node',
1117
+ eventPayload: {
1118
+ requestId: attempt.requestId,
1119
+ attemptId: running.attemptId,
1120
+ featureId,
1121
+ milestone: running.milestone,
1122
+ exitCode: msg.exitCode ?? null,
1123
+ signal: msg.signal ?? null,
1124
+ error: msg.error ?? null,
1125
+ reason: msg.reason ?? null,
1126
+ },
1127
+ });
1128
+ // Persist the worker handoff (if any) against the feature so it can be
1129
+ // aggregated into the mission snapshot and surfaced in the UI.
1130
+ const handoff = this.extractHandoff(msg);
1131
+ this.missionService.updateMissionFeatureHandoff({
1132
+ missionId: attempt.missionId,
1133
+ featureId,
1134
+ handoff,
1135
+ });
1136
+ // Persist the full worker output so the validation runner can evaluate
1137
+ // actual evidence (files changed, stdout, handoff content) rather than
1138
+ // relying only on feature status.
1139
+ this.missionService.updateMissionFeatureWorkerOutput({
1140
+ missionId: attempt.missionId,
1141
+ featureId,
1142
+ workerOutput: msg.output ?? null,
1143
+ });
1144
+ this.recordRuntimeEvent(attempt.missionId, 'worker_output_received', {
1145
+ requestId: attempt.requestId,
1146
+ attemptId: running.attemptId,
1147
+ featureId,
1148
+ milestone: running.milestone,
1149
+ exitCode: msg.exitCode ?? null,
1150
+ signal: msg.signal ?? null,
1151
+ reason: msg.reason ?? null,
1152
+ outputStatus: msg.output?.status ?? null,
1153
+ outputPreview: handoff && typeof handoff.salientSummary === 'string'
1154
+ ? handoff.salientSummary.slice(0, 500)
1155
+ : null,
1156
+ }, 'agent_node');
1157
+ if (handoff) {
1158
+ this.recordRuntimeEvent(attempt.missionId, 'handoff_created', {
1159
+ requestId: attempt.requestId,
1160
+ attemptId: running.attemptId,
1161
+ featureId,
1162
+ milestone: running.milestone,
1163
+ salientSummary: typeof handoff.salientSummary === 'string' ? handoff.salientSummary.slice(0, 500) : null,
1164
+ }, 'agent_node');
1165
+ }
1166
+ // Inspect discovered issues from the worker handoff. Blocking issues spawn
1167
+ // FIX features at the top of the queue; non-blocking issues are dismissed
1168
+ // via a traceable mission event.
1169
+ const discoveredIssues = this.extractDiscoveredIssues(handoff);
1170
+ if (discoveredIssues.length > 0) {
1171
+ this.handleDiscoveredIssues(attempt.missionId, featureId, running.milestone, discoveredIssues);
1172
+ }
1173
+ // Validator features update milestone assertion statuses from their handoff
1174
+ // evidence. Do this before honouring returnToOrchestrator so that a usable
1175
+ // validator result is always recorded.
1176
+ const completedFeature = this.missionService.listMissionFeatures({
1177
+ missionId: attempt.missionId,
1178
+ userId: '',
1179
+ isAdmin: true,
1180
+ }).find((feature) => feature.featureId === featureId);
1181
+ if (completedFeature?.isValidator) {
1182
+ const validatorHalted = await this.applyValidatorHandoff(attempt.missionId, running.milestone, handoff);
1183
+ if (validatorHalted) {
1184
+ this.activeMissions.delete(attempt.missionId);
1185
+ this.pausedMissions.delete(attempt.missionId);
1186
+ this.persistMissionSnapshot(attempt.missionId);
1187
+ return;
1188
+ }
1189
+ }
1190
+ // If the worker handoff asks for orchestrator attention, pause the mission
1191
+ // instead of continuing the queue or failing through the normal path.
1192
+ if (handoff && handoff.returnToOrchestrator === true) {
1193
+ this.emitOrchestratorDecision(attempt.missionId, 'worker_requested_attention', {
1194
+ featureId,
1195
+ successState: handoff.successState,
1196
+ returnToOrchestrator: true,
1197
+ });
1198
+ this.pauseMission({
1199
+ missionId: attempt.missionId,
1200
+ userId: '',
1201
+ isAdmin: true,
1202
+ reason: 'returnToOrchestrator',
1203
+ });
1204
+ return;
1205
+ }
1206
+ this.emitMilestoneCompletedIfNeeded(attempt.missionId, running.milestone);
1207
+ if (failed) {
1208
+ // Node disconnect is an infrastructure failure; the validator cannot be
1209
+ // dispatched because the node is gone, so fail the mission immediately.
1210
+ if (msg.reason === 'node_disconnected') {
1211
+ this.missionService.failMission({
1212
+ missionId: attempt.missionId,
1213
+ userId: '',
1214
+ isAdmin: true,
1215
+ error: msg.error ?? 'Agent node disconnected',
1216
+ payload: {
1217
+ requestId: attempt.requestId,
1218
+ featureId,
1219
+ exitCode: msg.exitCode ?? null,
1220
+ signal: msg.signal ?? null,
1221
+ reason: msg.reason ?? null,
1222
+ },
1223
+ });
1224
+ this.activeMissions.delete(attempt.missionId);
1225
+ this.pausedMissions.delete(attempt.missionId);
1226
+ this.persistMissionSnapshot(attempt.missionId);
1227
+ return;
1228
+ }
1229
+ // Evaluate milestone assertions before failing the mission so that
1230
+ // validation results are recorded alongside the failure. If a validator
1231
+ // feature is injected, defer failing the mission until the validator
1232
+ // completes; it will record assertion evidence and then fail the mission
1233
+ // because the original implementation feature is still failed.
1234
+ let progress = {
1235
+ halted: false,
1236
+ validatorInjected: false,
1237
+ };
1238
+ try {
1239
+ progress = await this.runPendingMilestoneValidations(attempt.missionId);
1240
+ }
1241
+ catch (error) {
1242
+ const message = String(error?.message ?? error);
1243
+ this.missionService.recordMissionRuntimeEvent({
1244
+ missionId: attempt.missionId,
1245
+ eventType: 'mission_validation_failed',
1246
+ source: 'platform',
1247
+ eventTime: Date.now(),
1248
+ payload: { error: message },
1249
+ });
1250
+ }
1251
+ if (!progress.halted && !progress.validatorInjected) {
1252
+ this.missionService.failMission({
1253
+ missionId: attempt.missionId,
1254
+ userId: '',
1255
+ isAdmin: true,
1256
+ error: msg.error ?? (msg.signal ? `Process terminated by ${msg.signal}` : `Exit code ${msg.exitCode ?? 'unknown'}`),
1257
+ payload: {
1258
+ requestId: attempt.requestId,
1259
+ featureId,
1260
+ exitCode: msg.exitCode ?? null,
1261
+ signal: msg.signal ?? null,
1262
+ reason: msg.reason ?? null,
1263
+ },
1264
+ });
1265
+ }
1266
+ if (progress.validatorInjected) {
1267
+ // Let the injected validator run and record evidence before the
1268
+ // mission is failed.
1269
+ void this.advanceQueue(attempt.missionId, attempt.nodeId, msg.missionDir);
1270
+ return;
1271
+ }
1272
+ this.activeMissions.delete(attempt.missionId);
1273
+ this.pausedMissions.delete(attempt.missionId);
1274
+ this.persistMissionSnapshot(attempt.missionId);
1275
+ return;
1276
+ }
1277
+ // If the mission is paused, hold off on dispatching the next feature until
1278
+ // resume is called.
1279
+ if (this.pausedMissions.has(attempt.missionId)) {
1280
+ return;
1281
+ }
1282
+ // completed or skipped: advance to the next feature sequentially.
1283
+ void this.advanceQueue(attempt.missionId, attempt.nodeId, msg.missionDir);
1284
+ }
1285
+ extractHandoff(msg) {
1286
+ const outputHandoff = msg.output?.handoff;
1287
+ if (outputHandoff && typeof outputHandoff === 'object' && !Array.isArray(outputHandoff)) {
1288
+ return outputHandoff;
1289
+ }
1290
+ const msgHandoff = msg.handoff;
1291
+ if (msgHandoff && typeof msgHandoff === 'object' && !Array.isArray(msgHandoff)) {
1292
+ return msgHandoff;
1293
+ }
1294
+ return null;
1295
+ }
1296
+ extractDiscoveredIssues(handoff) {
1297
+ if (!handoff)
1298
+ return [];
1299
+ const issues = handoff.discoveredIssues;
1300
+ if (!Array.isArray(issues))
1301
+ return [];
1302
+ return issues.filter((issue) => {
1303
+ if (!issue || typeof issue !== 'object' || Array.isArray(issue))
1304
+ return false;
1305
+ const record = issue;
1306
+ const id = record.id;
1307
+ const severity = record.severity;
1308
+ const description = record.description;
1309
+ return (typeof id === 'string' &&
1310
+ typeof description === 'string' &&
1311
+ (severity === 'blocking' || severity === 'non-blocking'));
1312
+ });
1313
+ }
1314
+ handleDiscoveredIssues(missionId, sourceFeatureId, milestone, issues) {
1315
+ const blocking = issues.filter((issue) => issue.severity === 'blocking');
1316
+ const nonBlocking = issues.filter((issue) => issue.severity === 'non-blocking');
1317
+ if (blocking.length > 0) {
1318
+ this.injectFixFeatures(missionId, sourceFeatureId, milestone, blocking);
1319
+ }
1320
+ if (nonBlocking.length > 0) {
1321
+ this.recordRuntimeEvent(missionId, 'handoff_items_dismissed', {
1322
+ featureId: sourceFeatureId,
1323
+ milestone,
1324
+ issues: nonBlocking.map((issue) => ({
1325
+ id: issue.id,
1326
+ severity: issue.severity,
1327
+ description: issue.description,
1328
+ justification: issue.suggestion
1329
+ ? `non-blocking, deferred: ${issue.suggestion}`
1330
+ : 'non-blocking, deferred',
1331
+ })),
1332
+ });
1333
+ }
1334
+ }
1335
+ injectFixFeatures(missionId, sourceFeatureId, milestone, blockingIssues) {
1336
+ const features = this.missionService.listMissionFeatures({
1337
+ missionId,
1338
+ userId: '',
1339
+ isAdmin: true,
1340
+ });
1341
+ const minOrdering = features.length > 0 ? Math.min(...features.map((feature) => feature.ordering)) : 0;
1342
+ const fixFeatures = [];
1343
+ for (let index = 0; index < blockingIssues.length; index += 1) {
1344
+ const issue = blockingIssues[index];
1345
+ fixFeatures.push({
1346
+ featureId: `fix-${sourceFeatureId}-${issue.id}-${Date.now()}-${index}`,
1347
+ missionId,
1348
+ milestone,
1349
+ description: `FIX: ${issue.description}${issue.suggestion ? ` (${issue.suggestion})` : ''}`,
1350
+ skillName: 'native-fix',
1351
+ status: 'pending',
1352
+ ordering: minOrdering - 1 - index,
1353
+ preconditions: [],
1354
+ expectedBehavior: [],
1355
+ retryCount: 0,
1356
+ isValidator: false,
1357
+ handoff: null,
1358
+ workerOutput: null,
1359
+ createdAt: Date.now(),
1360
+ updatedAt: Date.now(),
1361
+ });
1362
+ }
1363
+ const updatedFeatures = [...features, ...fixFeatures].sort((left, right) => left.ordering === right.ordering ? left.createdAt - right.createdAt : left.ordering - right.ordering);
1364
+ this.missionService.replaceMissionFeatures({
1365
+ missionId,
1366
+ userId: '',
1367
+ isAdmin: true,
1368
+ features: updatedFeatures,
1369
+ });
1370
+ return fixFeatures;
1371
+ }
1372
+ persistMissionSnapshot(missionId) {
1373
+ let reportPath = null;
1374
+ try {
1375
+ const mission = this.missionService.getMission({ missionId, userId: '', isAdmin: true });
1376
+ if (mission?.missionDir) {
1377
+ const report = this.missionService.generateNativeMissionReport({
1378
+ missionId,
1379
+ userId: '',
1380
+ isAdmin: true,
1381
+ });
1382
+ reportPath = report.reportPath;
1383
+ this.recordRuntimeEvent(missionId, 'report_generated', {
1384
+ reportPath: report.reportPath,
1385
+ sizeBytes: report.sizeBytes,
1386
+ });
1387
+ }
1388
+ }
1389
+ catch (error) {
1390
+ const message = String(error?.message ?? error);
1391
+ this.missionService.recordMissionRuntimeEvent({
1392
+ missionId,
1393
+ eventType: 'mission_report_failed',
1394
+ source: 'platform',
1395
+ eventTime: Date.now(),
1396
+ payload: { error: message },
1397
+ });
1398
+ }
1399
+ try {
1400
+ const snapshot = this.missionService.generateNativeMissionSnapshot({
1401
+ missionId,
1402
+ userId: '',
1403
+ isAdmin: true,
1404
+ });
1405
+ const imported = this.missionService.importSnapshot({
1406
+ missionId,
1407
+ userId: '',
1408
+ isAdmin: true,
1409
+ request: { source: 'bigbang_native', snapshot },
1410
+ });
1411
+ this.recordRuntimeEvent(missionId, 'artifact_written', {
1412
+ snapshotId: imported.snapshotId,
1413
+ source: imported.source,
1414
+ reportPath,
1415
+ });
1416
+ }
1417
+ catch (error) {
1418
+ const message = String(error?.message ?? error);
1419
+ this.missionService.recordMissionRuntimeEvent({
1420
+ missionId,
1421
+ eventType: 'mission_snapshot_failed',
1422
+ source: 'platform',
1423
+ eventTime: Date.now(),
1424
+ payload: { error: message },
1425
+ });
1426
+ }
1427
+ }
1428
+ async advanceQueue(missionId, nodeId, missionDir, supersession) {
1429
+ try {
1430
+ await this.dispatchNextFeature(missionId, nodeId, missionDir, '', true, supersession);
1431
+ const mission = this.missionService.getMission({ missionId, userId: '', isAdmin: true });
1432
+ if (mission && (mission.state === 'completed' || mission.state === 'failed')) {
1433
+ this.persistMissionSnapshot(missionId);
1434
+ }
1435
+ }
1436
+ catch (error) {
1437
+ const mission = this.missionService.getMission({ missionId, userId: '', isAdmin: true });
1438
+ if (mission && mission.state !== 'failed' && mission.state !== 'completed' && mission.state !== 'cancelled') {
1439
+ const message = String(error?.message ?? error);
1440
+ this.missionService.recordMissionRuntimeEvent({
1441
+ missionId,
1442
+ eventType: 'mission_dispatch_failed',
1443
+ source: 'platform',
1444
+ eventTime: Date.now(),
1445
+ payload: { error: message },
1446
+ });
1447
+ this.missionService.failMission({
1448
+ missionId,
1449
+ userId: '',
1450
+ isAdmin: true,
1451
+ error: message,
1452
+ payload: {},
1453
+ });
1454
+ }
1455
+ const terminal = this.missionService.getMission({ missionId, userId: '', isAdmin: true });
1456
+ if (terminal && (terminal.state === 'completed' || terminal.state === 'failed')) {
1457
+ this.persistMissionSnapshot(missionId);
1458
+ }
1459
+ this.activeMissions.delete(missionId);
1460
+ }
1461
+ }
1462
+ async dispatchNextFeature(missionId, nodeId, missionDir, userId, isAdmin, supersession) {
1463
+ // Do not dispatch new features while the mission is paused.
1464
+ if (this.pausedMissions.has(missionId))
1465
+ return;
1466
+ // Run any milestone validations whose features are all terminal but still
1467
+ // have pending assertions. If a validation failure triggers the halt
1468
+ // policy, fail the mission immediately. If a validator feature was injected,
1469
+ // continue so it is dispatched through the normal queue.
1470
+ const progress = await this.runPendingMilestoneValidations(missionId);
1471
+ if (progress.halted && !progress.validatorInjected) {
1472
+ this.activeMissions.delete(missionId);
1473
+ this.pausedMissions.delete(missionId);
1474
+ return;
1475
+ }
1476
+ const nextFeature = this.missionService.getNextMissionFeature({ missionId });
1477
+ if (!nextFeature) {
1478
+ // All features are done; complete the mission.
1479
+ this.missionService.completeMission({ missionId, userId, isAdmin, payload: {} });
1480
+ this.activeMissions.delete(missionId);
1481
+ this.pausedMissions.delete(missionId);
1482
+ return;
1483
+ }
1484
+ const requestId = randomUUID();
1485
+ this.runningFeatures.set(missionId, {
1486
+ requestId,
1487
+ featureId: nextFeature.featureId,
1488
+ nodeId,
1489
+ attemptId: null,
1490
+ milestone: nextFeature.milestone,
1491
+ });
1492
+ this.emitMilestoneStartedIfNeeded(missionId, nextFeature.milestone, nextFeature.featureId);
1493
+ this.missionService.updateMissionFeatureStatus({
1494
+ missionId,
1495
+ featureId: nextFeature.featureId,
1496
+ status: 'queued',
1497
+ eventType: 'feature_queued',
1498
+ eventPayload: { requestId, milestone: nextFeature.milestone },
1499
+ });
1500
+ this.emitOrchestratorDecision(missionId, 'feature_selected', {
1501
+ requestId,
1502
+ featureId: nextFeature.featureId,
1503
+ milestone: nextFeature.milestone,
1504
+ ordering: nextFeature.ordering,
1505
+ });
1506
+ const mission = this.missionService.getMission({ missionId, userId, isAdmin });
1507
+ if (!mission)
1508
+ throw this.error(404, 'Mission not found');
1509
+ if (nextFeature.retryCount > 0) {
1510
+ const retryPolicy = this.resolveRetryPolicy(mission);
1511
+ this.recordRuntimeEvent(missionId, 'retry_started', {
1512
+ requestId,
1513
+ featureId: nextFeature.featureId,
1514
+ milestone: nextFeature.milestone,
1515
+ retryCount: nextFeature.retryCount,
1516
+ maxRetries: retryPolicy.maxRetries,
1517
+ });
1518
+ }
1519
+ try {
1520
+ const priorHandoffs = this.buildPriorHandoffs(missionId, nextFeature.featureId);
1521
+ const validationContract = this.buildValidationContract(missionId, nextFeature);
1522
+ const workspaceFiles = this.listWorkspaceFiles(mission.workspacePath);
1523
+ const promptOverride = this.buildFeaturePrompt(missionId, mission, nextFeature, {
1524
+ priorHandoffs,
1525
+ validationContract,
1526
+ workspaceFiles,
1527
+ });
1528
+ const attempt = this.missionService.createMissionAttempt({
1529
+ missionId,
1530
+ requestId,
1531
+ attemptType: 'run',
1532
+ nodeId,
1533
+ missionDir,
1534
+ prompt: nextFeature.description,
1535
+ supersedesAttemptId: supersession?.attemptId,
1536
+ supersessionReason: supersession?.reason ?? null,
1537
+ });
1538
+ const running = this.runningFeatures.get(missionId);
1539
+ if (running) {
1540
+ running.attemptId = attempt.attemptId;
1541
+ }
1542
+ this.recordRuntimeEvent(missionId, 'worker_prompt_built', {
1543
+ requestId,
1544
+ attemptId: attempt.attemptId,
1545
+ featureId: nextFeature.featureId,
1546
+ milestone: nextFeature.milestone,
1547
+ promptPreview: promptOverride.slice(0, 500),
1548
+ });
1549
+ const accepted = await this.workerBroker.runMission(nodeId, mission, {
1550
+ requestId,
1551
+ attemptId: attempt.attemptId,
1552
+ promptOverride,
1553
+ feature: {
1554
+ featureId: nextFeature.featureId,
1555
+ featureDescription: nextFeature.description,
1556
+ milestone: nextFeature.milestone,
1557
+ expectedBehavior: nextFeature.expectedBehavior,
1558
+ preconditions: nextFeature.preconditions,
1559
+ priorHandoffs,
1560
+ validationContract,
1561
+ workspaceFiles,
1562
+ useAgentDispatch: true,
1563
+ isValidator: nextFeature.isValidator,
1564
+ },
1565
+ });
1566
+ this.missionService.updateMissionFeatureStatus({
1567
+ missionId,
1568
+ featureId: nextFeature.featureId,
1569
+ status: 'running',
1570
+ eventType: 'feature_accepted',
1571
+ source: 'agent_node',
1572
+ eventPayload: {
1573
+ requestId,
1574
+ attemptId: attempt.attemptId,
1575
+ featureId: nextFeature.featureId,
1576
+ pid: accepted.pid ?? null,
1577
+ milestone: nextFeature.milestone,
1578
+ },
1579
+ });
1580
+ }
1581
+ catch (error) {
1582
+ const message = String(error?.message ?? error);
1583
+ const running = this.runningFeatures.get(missionId);
1584
+ this.runningFeatures.delete(missionId);
1585
+ this.pausedMissions.delete(missionId);
1586
+ this.missionService.updateMissionFeatureStatus({
1587
+ missionId,
1588
+ featureId: nextFeature.featureId,
1589
+ status: 'failed',
1590
+ eventType: 'feature_failed',
1591
+ eventPayload: {
1592
+ requestId,
1593
+ attemptId: running?.attemptId ?? null,
1594
+ featureId: nextFeature.featureId,
1595
+ milestone: nextFeature.milestone,
1596
+ reason: 'node_unavailable',
1597
+ error: message,
1598
+ },
1599
+ });
1600
+ this.missionService.failMission({
1601
+ missionId,
1602
+ userId,
1603
+ isAdmin,
1604
+ error: message,
1605
+ payload: { requestId, featureId: nextFeature.featureId, reason: 'node_unavailable' },
1606
+ });
1607
+ this.activeMissions.delete(missionId);
1608
+ throw error;
1609
+ }
1610
+ }
1611
+ async runPendingMilestoneValidations(missionId, force = false) {
1612
+ const features = this.missionService.listMissionFeatures({
1613
+ missionId,
1614
+ userId: '',
1615
+ isAdmin: true,
1616
+ });
1617
+ const assertions = this.missionService.listMissionValidationAssertions({
1618
+ missionId,
1619
+ userId: '',
1620
+ isAdmin: true,
1621
+ });
1622
+ const pendingAssertionFeatureIds = new Set(assertions.filter((a) => a.status === 'pending' && a.featureId).map((a) => a.featureId));
1623
+ if (pendingAssertionFeatureIds.size === 0)
1624
+ return { halted: false, validatorInjected: false };
1625
+ const featuresByMilestone = new Map();
1626
+ for (const feature of features) {
1627
+ const list = featuresByMilestone.get(feature.milestone) ?? [];
1628
+ list.push(feature);
1629
+ featuresByMilestone.set(feature.milestone, list);
1630
+ }
1631
+ const terminalStatuses = ['completed', 'failed', 'skipped', 'cancelled'];
1632
+ const pendingMilestones = [];
1633
+ const validatorNeeded = [];
1634
+ for (const [milestone, milestoneFeatures] of featuresByMilestone) {
1635
+ const implementationFeatures = milestoneFeatures.filter((feature) => !feature.isValidator);
1636
+ const validatorFeatures = milestoneFeatures.filter((feature) => feature.isValidator);
1637
+ const allImplTerminal = implementationFeatures.length > 0 &&
1638
+ implementationFeatures.every((feature) => terminalStatuses.includes(feature.status));
1639
+ const hasFailedOrCancelled = implementationFeatures.some((feature) => feature.status === 'failed' || feature.status === 'cancelled');
1640
+ if (!force && !allImplTerminal && !hasFailedOrCancelled)
1641
+ continue;
1642
+ const hasPendingAssertion = implementationFeatures.some((feature) => pendingAssertionFeatureIds.has(feature.featureId));
1643
+ if (!hasPendingAssertion)
1644
+ continue;
1645
+ // Inject a validator feature if this milestone does not already have one.
1646
+ // Completed or failed validators must not trigger a second injection.
1647
+ if (validatorFeatures.length === 0) {
1648
+ validatorNeeded.push(milestone);
1649
+ }
1650
+ else {
1651
+ const allValidatorsTerminal = validatorFeatures.every((feature) => terminalStatuses.includes(feature.status));
1652
+ if (allValidatorsTerminal) {
1653
+ pendingMilestones.push(milestone);
1654
+ }
1655
+ }
1656
+ }
1657
+ if (validatorNeeded.length > 0) {
1658
+ for (const milestone of validatorNeeded) {
1659
+ const milestoneAssertions = assertions.filter((assertion) => {
1660
+ if (!assertion.featureId)
1661
+ return false;
1662
+ const feature = features.find((f) => f.featureId === assertion.featureId);
1663
+ return feature?.milestone === milestone;
1664
+ });
1665
+ this.injectValidatorFeature(missionId, milestone, milestoneAssertions);
1666
+ }
1667
+ // The validator will be dispatched through the normal feature queue. Tell
1668
+ // callers to stop further action until it completes.
1669
+ return { halted: true, validatorInjected: true };
1670
+ }
1671
+ if (pendingMilestones.length === 0)
1672
+ return { halted: false, validatorInjected: false };
1673
+ // Order milestones by the minimum ordering of their implementation features so
1674
+ // validation runs in the same order features were dispatched.
1675
+ pendingMilestones.sort((left, right) => {
1676
+ const leftMin = Math.min(...(featuresByMilestone.get(left) ?? [])
1677
+ .filter((f) => !f.isValidator)
1678
+ .map((f) => f.ordering));
1679
+ const rightMin = Math.min(...(featuresByMilestone.get(right) ?? [])
1680
+ .filter((f) => !f.isValidator)
1681
+ .map((f) => f.ordering));
1682
+ return leftMin - rightMin;
1683
+ });
1684
+ const mission = this.missionService.getMission({ missionId, userId: '', isAdmin: true });
1685
+ const policy = this.validationRunner.resolvePolicy(mission?.effectiveModelConfig);
1686
+ const timeoutMs = this.validationRunner.resolveTimeoutMs(mission?.effectiveModelConfig);
1687
+ const terminalMissionStates = new Set(['completed', 'failed', 'cancelled']);
1688
+ for (const milestone of pendingMilestones) {
1689
+ // When a validator exists but did not produce a usable handoff, fall back to
1690
+ // the synchronous keyword evaluator. The fallback must never claim observed
1691
+ // confidence because it did not inspect actual test output.
1692
+ const result = await this.validationRunner.runValidation({
1693
+ missionId,
1694
+ milestone,
1695
+ policy,
1696
+ timeoutMs,
1697
+ forceInferredConfidence: true,
1698
+ });
1699
+ if (result.shouldHalt) {
1700
+ if (mission && !terminalMissionStates.has(mission.state)) {
1701
+ const error = `Validation failed for milestone "${milestone}" with ${result.failed} failed assertion(s)`;
1702
+ this.missionService.failMission({
1703
+ missionId,
1704
+ userId: '',
1705
+ isAdmin: true,
1706
+ error,
1707
+ payload: {
1708
+ milestone,
1709
+ failedAssertions: result.failedAssertionIds,
1710
+ policy,
1711
+ },
1712
+ });
1713
+ }
1714
+ return { halted: true, validatorInjected: false };
1715
+ }
1716
+ }
1717
+ return { halted: false, validatorInjected: false };
1718
+ }
1719
+ resolveFeatureStatus(msg) {
1720
+ if (msg.error)
1721
+ return 'failed';
1722
+ if (msg.signal)
1723
+ return 'failed';
1724
+ if (msg.exitCode != null && msg.exitCode !== 0)
1725
+ return 'failed';
1726
+ const outputStatus = msg.output?.status;
1727
+ if (outputStatus === 'failed')
1728
+ return 'failed';
1729
+ if (outputStatus === 'skipped')
1730
+ return 'skipped';
1731
+ return 'completed';
1732
+ }
1733
+ resolveRetryPolicy(mission) {
1734
+ const policy = mission.effectiveModelConfig?.retryPolicy;
1735
+ if (policy && typeof policy === 'object' && !Array.isArray(policy)) {
1736
+ const maxRetriesRaw = policy.maxRetries;
1737
+ const maxRetries = typeof maxRetriesRaw === 'number' && Number.isFinite(maxRetriesRaw)
1738
+ ? Math.max(0, Math.floor(maxRetriesRaw))
1739
+ : 0;
1740
+ const backoff = policy.backoff === 'exponential' ? 'exponential' : 'fixed';
1741
+ const baseDelayMsRaw = policy.baseDelayMs;
1742
+ const baseDelayMs = typeof baseDelayMsRaw === 'number' && Number.isFinite(baseDelayMsRaw) && baseDelayMsRaw >= 0
1743
+ ? Math.floor(baseDelayMsRaw)
1744
+ : 0;
1745
+ const maxDelayMsRaw = policy.maxDelayMs;
1746
+ const maxDelayMs = typeof maxDelayMsRaw === 'number' && Number.isFinite(maxDelayMsRaw) && maxDelayMsRaw >= 0
1747
+ ? Math.floor(maxDelayMsRaw)
1748
+ : 60_000;
1749
+ return { maxRetries, backoff, baseDelayMs, maxDelayMs };
1750
+ }
1751
+ return { maxRetries: 0, backoff: 'fixed', baseDelayMs: 0, maxDelayMs: 60_000 };
1752
+ }
1753
+ isRetryableFailure(msg) {
1754
+ if (msg.output?.retryable === true)
1755
+ return true;
1756
+ const output = msg.output ?? {};
1757
+ const outputStatus = output.status;
1758
+ if (outputStatus === 'retryable')
1759
+ return true;
1760
+ const reason = msg.reason ?? output.reason;
1761
+ if (typeof reason === 'string') {
1762
+ const retryableReasons = new Set([
1763
+ 'retryable',
1764
+ 'network_timeout',
1765
+ 'rate_limit',
1766
+ 'transient',
1767
+ 'node_unavailable',
1768
+ ]);
1769
+ if (retryableReasons.has(reason))
1770
+ return true;
1771
+ }
1772
+ return false;
1773
+ }
1774
+ computeRetryDelay(policy, retryCount) {
1775
+ if (policy.baseDelayMs <= 0)
1776
+ return 0;
1777
+ let delay = policy.baseDelayMs;
1778
+ if (policy.backoff === 'exponential') {
1779
+ delay = policy.baseDelayMs * 2 ** retryCount;
1780
+ }
1781
+ return Math.min(delay, policy.maxDelayMs);
1782
+ }
1783
+ recoverAfterRestart() {
1784
+ const missions = this.missionService.listNativeMissionsForRecovery();
1785
+ for (const mission of missions) {
1786
+ this.activeMissions.add(mission.missionId);
1787
+ if (mission.state === 'paused') {
1788
+ this.pausedMissions.add(mission.missionId);
1789
+ continue;
1790
+ }
1791
+ const features = this.missionService.listMissionFeatures({
1792
+ missionId: mission.missionId,
1793
+ userId: '',
1794
+ isAdmin: true,
1795
+ });
1796
+ const runningFeature = features.find((feature) => feature.status === 'running');
1797
+ let supersession;
1798
+ if (runningFeature) {
1799
+ const attempts = this.missionService.listAttempts({
1800
+ missionId: mission.missionId,
1801
+ userId: '',
1802
+ isAdmin: true,
1803
+ });
1804
+ const stuckAttempt = attempts
1805
+ .filter((attempt) => (attempt.state === 'running' || attempt.state === 'queued') &&
1806
+ attempt.supersededBy === null)
1807
+ .sort((left, right) => right.createdAt - left.createdAt)[0];
1808
+ if (stuckAttempt) {
1809
+ supersession = { attemptId: stuckAttempt.attemptId, reason: 'recovery' };
1810
+ }
1811
+ this.missionService.updateMissionFeatureStatus({
1812
+ missionId: mission.missionId,
1813
+ featureId: runningFeature.featureId,
1814
+ status: 'pending',
1815
+ eventType: 'orchestrator_decision',
1816
+ source: 'platform',
1817
+ eventPayload: {
1818
+ decisionType: 'recovery_requeued_running_feature',
1819
+ featureId: runningFeature.featureId,
1820
+ milestone: runningFeature.milestone,
1821
+ retryCount: runningFeature.retryCount,
1822
+ },
1823
+ });
1824
+ }
1825
+ const milestones = this.missionService.listMissionMilestones({
1826
+ missionId: mission.missionId,
1827
+ userId: '',
1828
+ isAdmin: true,
1829
+ });
1830
+ const currentMilestone = milestones.find((milestone) => milestone.status === 'running');
1831
+ if (currentMilestone) {
1832
+ this.currentMilestones.set(mission.missionId, currentMilestone.name);
1833
+ }
1834
+ else {
1835
+ const nextFeature = this.missionService.getNextMissionFeature({ missionId: mission.missionId });
1836
+ if (nextFeature) {
1837
+ this.currentMilestones.set(mission.missionId, nextFeature.milestone);
1838
+ }
1839
+ }
1840
+ if (mission.state === 'running' && mission.nodeId && mission.missionDir) {
1841
+ void this.advanceQueue(mission.missionId, mission.nodeId, mission.missionDir, supersession);
1842
+ }
1843
+ }
1844
+ }
1845
+ injectValidatorFeature(missionId, milestone, assertions) {
1846
+ const features = this.missionService.listMissionFeatures({ missionId, userId: '', isAdmin: true });
1847
+ const milestoneFeatures = features.filter((feature) => feature.milestone === milestone);
1848
+ const minOrdering = milestoneFeatures.length > 0
1849
+ ? Math.min(...milestoneFeatures.map((feature) => feature.ordering))
1850
+ : 0;
1851
+ const validatorFeature = {
1852
+ featureId: `validator-${milestone.replace(/\s+/g, '-')}-${Date.now()}`,
1853
+ missionId,
1854
+ milestone,
1855
+ description: this.buildValidatorPrompt(milestone, assertions),
1856
+ skillName: 'native-validator',
1857
+ status: 'pending',
1858
+ ordering: minOrdering - 1,
1859
+ preconditions: [],
1860
+ expectedBehavior: [],
1861
+ retryCount: 0,
1862
+ isValidator: true,
1863
+ handoff: null,
1864
+ workerOutput: null,
1865
+ createdAt: Date.now(),
1866
+ updatedAt: Date.now(),
1867
+ };
1868
+ const updatedFeatures = [...features, validatorFeature].sort((left, right) => left.ordering === right.ordering
1869
+ ? left.createdAt - right.createdAt
1870
+ : left.ordering - right.ordering);
1871
+ this.missionService.replaceMissionFeatures({
1872
+ missionId,
1873
+ userId: '',
1874
+ isAdmin: true,
1875
+ features: updatedFeatures,
1876
+ });
1877
+ this.recordRuntimeEvent(missionId, 'validator_feature_injected', {
1878
+ featureId: validatorFeature.featureId,
1879
+ milestone,
1880
+ assertionCount: assertions.length,
1881
+ });
1882
+ return validatorFeature;
1883
+ }
1884
+ buildValidatorPrompt(milestone, assertions) {
1885
+ const lines = [
1886
+ `Validator for milestone: ${milestone}`,
1887
+ '',
1888
+ 'You are a validation agent. Run the project test suite for this milestone and map the results to each pending assertion below.',
1889
+ '',
1890
+ 'Run the following test command:',
1891
+ 'pnpm --filter @bbigbang/core test && pnpm --filter @bbigbang/agent-node test',
1892
+ '',
1893
+ 'Inspect the test output and map results to assertions, marking each as passed, failed, or blocked.',
1894
+ 'Record the test command and its full output as evidence in your handoff.verification.commandsRun.',
1895
+ '',
1896
+ 'Pending assertions:',
1897
+ ];
1898
+ for (const assertion of assertions) {
1899
+ lines.push(`- ${assertion.assertionText}`);
1900
+ }
1901
+ lines.push('', 'If any test command exits with a non-zero code, mark the related assertions as failed and include the failing output as evidence.');
1902
+ return lines.join('\n');
1903
+ }
1904
+ async applyValidatorHandoff(missionId, milestone, handoff) {
1905
+ const features = this.missionService.listMissionFeatures({ missionId, userId: '', isAdmin: true });
1906
+ const assertions = this.missionService.listMissionValidationAssertions({
1907
+ missionId,
1908
+ userId: '',
1909
+ isAdmin: true,
1910
+ });
1911
+ const pendingAssertions = assertions.filter((assertion) => {
1912
+ if (assertion.status !== 'pending' || !assertion.featureId)
1913
+ return false;
1914
+ const feature = features.find((f) => f.featureId === assertion.featureId);
1915
+ return feature?.milestone === milestone;
1916
+ });
1917
+ if (pendingAssertions.length === 0)
1918
+ return false;
1919
+ const mission = this.missionService.getMission({ missionId, userId: '', isAdmin: true });
1920
+ const policy = this.validationRunner.resolvePolicy(mission?.effectiveModelConfig);
1921
+ const commandsRun = this.extractValidatorCommandsRun(handoff);
1922
+ if (commandsRun.length > 0) {
1923
+ const allCommandsPassed = commandsRun.every((entry) => entry.exitCode === 0);
1924
+ for (const assertion of pendingAssertions) {
1925
+ const linkedFeature = assertion.featureId
1926
+ ? features.find((f) => f.featureId === assertion.featureId)
1927
+ : undefined;
1928
+ const featureFailed = linkedFeature?.status === 'failed';
1929
+ const status = featureFailed || !allCommandsPassed ? 'failed' : 'passed';
1930
+ this.missionService.updateMissionValidationAssertion({
1931
+ missionId,
1932
+ assertionId: assertion.assertionId,
1933
+ status,
1934
+ note: featureFailed
1935
+ ? 'Linked implementation feature failed.'
1936
+ : allCommandsPassed
1937
+ ? 'Validator test command passed.'
1938
+ : 'Validator test command reported failures.',
1939
+ evidence: commandsRun
1940
+ .map((entry) => `${entry.command} (exitCode=${entry.exitCode})`)
1941
+ .join('; '),
1942
+ confidence: 'observed',
1943
+ });
1944
+ }
1945
+ }
1946
+ else {
1947
+ // No usable validator handoff: fall back to the synchronous evaluator.
1948
+ const result = await this.validationRunner.runValidation({
1949
+ missionId,
1950
+ milestone,
1951
+ policy,
1952
+ timeoutMs: this.validationRunner.resolveTimeoutMs(mission?.effectiveModelConfig),
1953
+ forceInferredConfidence: true,
1954
+ });
1955
+ if (result.shouldHalt) {
1956
+ if (mission && !['completed', 'failed', 'cancelled'].includes(mission.state)) {
1957
+ const error = `Validation failed for milestone "${milestone}" with ${result.failed} failed assertion(s)`;
1958
+ this.missionService.failMission({
1959
+ missionId,
1960
+ userId: '',
1961
+ isAdmin: true,
1962
+ error,
1963
+ payload: { milestone, policy },
1964
+ });
1965
+ }
1966
+ return true;
1967
+ }
1968
+ }
1969
+ const failedCount = this.missionService
1970
+ .listMissionValidationAssertions({ missionId, userId: '', isAdmin: true })
1971
+ .filter((assertion) => {
1972
+ if (assertion.status !== 'failed' || !assertion.featureId)
1973
+ return false;
1974
+ const feature = features.find((f) => f.featureId === assertion.featureId);
1975
+ return feature?.milestone === milestone;
1976
+ }).length;
1977
+ if (policy === 'failOnFirst' && failedCount > 0) {
1978
+ if (mission && !['completed', 'failed', 'cancelled'].includes(mission.state)) {
1979
+ const error = `Validation failed for milestone "${milestone}" with failed assertion(s)`;
1980
+ this.missionService.failMission({
1981
+ missionId,
1982
+ userId: '',
1983
+ isAdmin: true,
1984
+ error,
1985
+ payload: { milestone, policy },
1986
+ });
1987
+ }
1988
+ return true;
1989
+ }
1990
+ // If the milestone contains a failed implementation feature, the mission
1991
+ // must still fail even when the validator itself passes, because the
1992
+ // original feature failure is what triggered validation.
1993
+ const failedImplementationFeature = features.find((f) => f.milestone === milestone && !f.isValidator && f.status === 'failed');
1994
+ if (failedImplementationFeature) {
1995
+ if (mission && !['completed', 'failed', 'cancelled'].includes(mission.state)) {
1996
+ this.missionService.failMission({
1997
+ missionId,
1998
+ userId: '',
1999
+ isAdmin: true,
2000
+ error: `Feature ${failedImplementationFeature.featureId} failed`,
2001
+ payload: { featureId: failedImplementationFeature.featureId, milestone },
2002
+ });
2003
+ }
2004
+ return true;
2005
+ }
2006
+ return false;
2007
+ }
2008
+ extractValidatorCommandsRun(handoff) {
2009
+ if (!handoff)
2010
+ return [];
2011
+ const verification = handoff.verification;
2012
+ if (!verification || typeof verification !== 'object' || Array.isArray(verification))
2013
+ return [];
2014
+ const commandsRun = verification.commandsRun;
2015
+ if (!Array.isArray(commandsRun))
2016
+ return [];
2017
+ return commandsRun.filter((entry) => entry != null &&
2018
+ typeof entry === 'object' &&
2019
+ !Array.isArray(entry) &&
2020
+ typeof entry.command === 'string' &&
2021
+ typeof entry.exitCode === 'number' &&
2022
+ typeof entry.observation === 'string');
2023
+ }
2024
+ requireMission(input) {
2025
+ const mission = this.missionService.getMission(input);
2026
+ if (!mission)
2027
+ throw this.error(404, 'Mission not found');
2028
+ return mission;
2029
+ }
2030
+ error(statusCode, message) {
2031
+ return new MissionServiceError(statusCode, message);
2032
+ }
2033
+ }
2034
+ function groupFeaturesByMilestone(features) {
2035
+ const grouped = new Map();
2036
+ for (const feature of features) {
2037
+ const list = grouped.get(feature.milestone) ?? [];
2038
+ list.push(feature);
2039
+ grouped.set(feature.milestone, list);
2040
+ }
2041
+ for (const list of grouped.values()) {
2042
+ list.sort((left, right) => left.ordering - right.ordering);
2043
+ }
2044
+ return grouped;
2045
+ }