@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.
- package/dist/config.js +380 -0
- package/dist/execution/executionDispatcher.js +3810 -0
- package/dist/main.js +90 -0
- package/dist/nodeEventHistory.js +206 -0
- package/dist/scheduler/dreamLogic.js +50 -0
- package/dist/scheduler/dreamScheduler.js +65 -0
- package/dist/services/agentFileAccessService.js +1913 -0
- package/dist/services/agentRuntimeCleanupBroker.js +62 -0
- package/dist/services/agentSkillsBroker.js +118 -0
- package/dist/services/agentSkillsService.js +83 -0
- package/dist/services/agentWorkspaceBroker.js +937 -0
- package/dist/services/agentWorkspaceService.js +70 -0
- package/dist/services/appVersion.js +14 -0
- package/dist/services/auth.js +586 -0
- package/dist/services/claudeControlBroker.js +154 -0
- package/dist/services/claudeTranscriptBroker.js +100 -0
- package/dist/services/claudeTranscriptService.js +359 -0
- package/dist/services/codexAppServerBroker.js +155 -0
- package/dist/services/codexTranscriptBroker.js +98 -0
- package/dist/services/codexTranscriptService.js +961 -0
- package/dist/services/droidMissionBroker.js +124 -0
- package/dist/services/droidMissionImporter.js +630 -0
- package/dist/services/droidModelOptions.js +165 -0
- package/dist/services/hubServerRegistrationService.js +268 -0
- package/dist/services/libraryManifest.js +43 -0
- package/dist/services/libraryScaffold.js +26 -0
- package/dist/services/libraryService.js +2263 -0
- package/dist/services/memoryService.js +386 -0
- package/dist/services/missionEvidence.js +377 -0
- package/dist/services/missionService.js +2361 -0
- package/dist/services/missionTrace.js +158 -0
- package/dist/services/nativeMissionBriefParser.js +120 -0
- package/dist/services/nativeMissionOrchestrator.js +2045 -0
- package/dist/services/nativeMissionReportGenerator.js +227 -0
- package/dist/services/nativeMissionValidationRunner.js +452 -0
- package/dist/services/nativeMissionWorkerBroker.js +190 -0
- package/dist/services/nodeRegistry.js +34 -0
- package/dist/services/nodeStateReconciler.js +97 -0
- package/dist/services/panelMediaScanner.js +119 -0
- package/dist/services/persistentRuntimeJsonlClient.js +153 -0
- package/dist/services/platformAgentPolicy.js +180 -0
- package/dist/services/platformAgentService.js +2041 -0
- package/dist/services/projectAccessResolver.js +93 -0
- package/dist/services/projectService.js +392 -0
- package/dist/services/resourceSpaceService.js +140 -0
- package/dist/services/scenarioRuntimeService.js +1130 -0
- package/dist/services/suggestedPlannerService.js +868 -0
- package/dist/services/workbenchGitBroker.js +161 -0
- package/dist/services/workbenchGitService.js +69 -0
- package/dist/services/workbenchInspectBroker.js +65 -0
- package/dist/services/workbenchNodePathService.js +79 -0
- package/dist/services/workbenchRegistryService.js +240 -0
- package/dist/services/workbenchRootService.js +181 -0
- package/dist/services/workbenchTerminalBroker.js +378 -0
- package/dist/services/workspaceRunOwnership.js +60 -0
- package/dist/services/workspaceScaffold.js +105 -0
- package/dist/services/workspaceSessionRuntimeService.js +576 -0
- package/dist/services/workspaceSessionService.js +245 -0
- package/dist/services/workspaceToolActionRunner.js +1582 -0
- package/dist/services/workspaceToolErrors.js +10 -0
- package/dist/services/workspaceToolExecutionUtils.js +895 -0
- package/dist/services/workspaceToolLatestStateProjector.js +91 -0
- package/dist/services/workspaceToolManifest.js +572 -0
- package/dist/services/workspaceToolMutationQueue.js +43 -0
- package/dist/services/workspaceToolPanelProjection.js +460 -0
- package/dist/services/workspaceToolPromotion.js +255 -0
- package/dist/services/workspaceToolPromotionState.js +224 -0
- package/dist/services/workspaceToolPublishDiagnostics.js +189 -0
- package/dist/services/workspaceToolPublishIdentityResolver.js +146 -0
- package/dist/services/workspaceToolReadModel.js +378 -0
- package/dist/services/workspaceToolRunLedger.js +239 -0
- package/dist/services/workspaceToolService.js +3067 -0
- package/dist/services/workspaceToolSnapshotPanelSync.js +293 -0
- package/dist/services/workspaceToolTerminalLifecycle.js +283 -0
- package/dist/services/workspaceToolTypes.js +1 -0
- package/dist/services/workspaceToolUploadMaterializer.js +228 -0
- package/dist/web/actionCardRoutes.js +129 -0
- package/dist/web/actionCards.js +469 -0
- package/dist/web/activationContext.js +684 -0
- package/dist/web/agentChannelGuards.js +48 -0
- package/dist/web/agentMentionCooldowns.js +32 -0
- package/dist/web/agentReminders.js +1668 -0
- package/dist/web/agentRuntimePresence.js +197 -0
- package/dist/web/agentSelfState.js +494 -0
- package/dist/web/agentTaskLinks.js +26 -0
- package/dist/web/agentVisibility.js +79 -0
- package/dist/web/assets.js +95 -0
- package/dist/web/channelActivationPrompt.js +395 -0
- package/dist/web/channelMemoryNotes.js +127 -0
- package/dist/web/channelMentions.js +10 -0
- package/dist/web/channelMessageSequences.js +19 -0
- package/dist/web/channelSubscriptions.js +26 -0
- package/dist/web/clearedTaskRoots.js +10 -0
- package/dist/web/collaborationPromptGuidance.js +36 -0
- package/dist/web/collaborationSurfaceState.js +140 -0
- package/dist/web/contextBundleRanking.js +154 -0
- package/dist/web/contextBundleResolver.js +488 -0
- package/dist/web/conversationBuiltinSkillRoots.js +50 -0
- package/dist/web/conversationControls.js +232 -0
- package/dist/web/conversationHandoffs.js +612 -0
- package/dist/web/conversationManager.js +2511 -0
- package/dist/web/conversationSummaries.js +876 -0
- package/dist/web/conversationSurfaceKinds.js +17 -0
- package/dist/web/conversationTargets.js +173 -0
- package/dist/web/directActivationPrompt.js +122 -0
- package/dist/web/directReplyTargets.js +69 -0
- package/dist/web/directThreadResolver.js +129 -0
- package/dist/web/dmTaskHandoffPrompt.js +120 -0
- package/dist/web/dmTaskThreadStatusProjection.js +229 -0
- package/dist/web/ftsQuery.js +33 -0
- package/dist/web/internalAgentRouter.js +11341 -0
- package/dist/web/libraryCuratorScheduler.js +58 -0
- package/dist/web/libraryDocumentPromptGuidance.js +8 -0
- package/dist/web/messageCheckpoints.js +19 -0
- package/dist/web/nodeWsHandler.js +2495 -0
- package/dist/web/notificationRounds.js +1061 -0
- package/dist/web/panelActionMessages.js +108 -0
- package/dist/web/panelActivationPrompt.js +18 -0
- package/dist/web/panelAudit.js +273 -0
- package/dist/web/panelLifecycle.js +222 -0
- package/dist/web/panelMediaPolicy.js +43 -0
- package/dist/web/panelPathPolicy.js +63 -0
- package/dist/web/panelPreviews.js +175 -0
- package/dist/web/panelQueryHandles.js +2749 -0
- package/dist/web/panelRoutes.js +2147 -0
- package/dist/web/panels.js +904 -0
- package/dist/web/peerInboxAggregates.js +1247 -0
- package/dist/web/planApprovalState.js +92 -0
- package/dist/web/platformAgentScheduler.js +66 -0
- package/dist/web/proactiveOpportunities.js +452 -0
- package/dist/web/promptContextSections.js +242 -0
- package/dist/web/promptHistorySanitizer.js +26 -0
- package/dist/web/promptSlashCommands.js +158 -0
- package/dist/web/rollingConversationSummary.js +453 -0
- package/dist/web/routeHelpers.js +11 -0
- package/dist/web/routes/handoff.js +288 -0
- package/dist/web/routes/history.js +345 -0
- package/dist/web/routes/memory.js +258 -0
- package/dist/web/routes/selfState.js +171 -0
- package/dist/web/routes/workspace.js +154 -0
- package/dist/web/runSurfaceWatermarks.js +431 -0
- package/dist/web/runtimeCapabilities.js +48 -0
- package/dist/web/sameAgentHandoffs.js +494 -0
- package/dist/web/server.js +15567 -0
- package/dist/web/sharedCollaborationCapsules.js +163 -0
- package/dist/web/soloSessionRelay.js +42 -0
- package/dist/web/soloWsHandler.js +138 -0
- package/dist/web/suggestedPlannerScheduler.js +56 -0
- package/dist/web/surfaceActivationPolicy.js +108 -0
- package/dist/web/surfaceCollaborators.js +61 -0
- package/dist/web/surfaceSystemStatus.js +263 -0
- package/dist/web/targetParticipants.js +77 -0
- package/dist/web/taskEvents.js +49 -0
- package/dist/web/taskLifecycleMessages.js +165 -0
- package/dist/web/taskLoops.js +732 -0
- package/dist/web/taskMemoryNotes.js +224 -0
- package/dist/web/taskNumbers.js +16 -0
- package/dist/web/taskOwnerGuards.js +49 -0
- package/dist/web/taskParticipantResolver.js +42 -0
- package/dist/web/taskParticipants.js +97 -0
- package/dist/web/taskSourceDetails.js +20 -0
- package/dist/web/taskStateViews.js +210 -0
- package/dist/web/taskStatusTransitions.js +9 -0
- package/dist/web/taskThreadFollowups.js +599 -0
- package/dist/web/taskThreadRuntimeClosure.js +685 -0
- package/dist/web/taskUpdateDelivery.js +104 -0
- package/dist/web/threadReplyContentHeuristics.js +30 -0
- package/dist/web/threadRoots.js +61 -0
- package/dist/web/threadTaskBindings.js +365 -0
- package/dist/web/uiPanelPromptGuidance.js +27 -0
- package/dist/web/workspaceMemoryHints.js +143 -0
- package/dist/web/workspaceToolPromptGuidance.js +30 -0
- package/dist/web/wsHandler.js +397 -0
- package/dist/web/wsSink.js +116 -0
- 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
|
+
}
|