ai-maestro 1.0.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/CHANGELOG.md +11 -0
- package/LICENSE +15 -0
- package/README.md +727 -0
- package/bin/maestro.mjs +246 -0
- package/package.json +29 -0
- package/src/checkpoint.mjs +102 -0
- package/src/codex-diagnostics.mjs +682 -0
- package/src/codex-home-inspect.mjs +252 -0
- package/src/codex-home.mjs +258 -0
- package/src/commands.mjs +809 -0
- package/src/config.mjs +11 -0
- package/src/debug.mjs +164 -0
- package/src/encoding-guard.mjs +125 -0
- package/src/engines/ai-router-engine.mjs +37 -0
- package/src/engines/codex-engine.mjs +21 -0
- package/src/engines/engine.mjs +21 -0
- package/src/engines/registry.mjs +29 -0
- package/src/files.mjs +65 -0
- package/src/format.mjs +132 -0
- package/src/lock.mjs +439 -0
- package/src/memory-log.mjs +18 -0
- package/src/memory.mjs +1 -0
- package/src/orchestration/attempt-chain-runtime.mjs +627 -0
- package/src/orchestration/budget-manager.mjs +121 -0
- package/src/orchestration/capability-registry.mjs +108 -0
- package/src/orchestration/consolidator.mjs +772 -0
- package/src/orchestration/delegation-executor.mjs +262 -0
- package/src/orchestration/delegation-manager.mjs +116 -0
- package/src/orchestration/deployment-intent.mjs +36 -0
- package/src/orchestration/engine-history.mjs +110 -0
- package/src/orchestration/engine-policy.mjs +73 -0
- package/src/orchestration/engine-selector.mjs +187 -0
- package/src/orchestration/execution-context.mjs +45 -0
- package/src/orchestration/failure-classifier.mjs +136 -0
- package/src/orchestration/failure-evidence.mjs +237 -0
- package/src/orchestration/fallback-chain-lock.mjs +217 -0
- package/src/orchestration/fallback-chain-trail.mjs +218 -0
- package/src/orchestration/fallback-executor.mjs +146 -0
- package/src/orchestration/fallback-graph.mjs +106 -0
- package/src/orchestration/fallback-recommendation.mjs +73 -0
- package/src/orchestration/file-conflict-detector.mjs +126 -0
- package/src/orchestration/host-validation.mjs +241 -0
- package/src/orchestration/orchestration-loop.mjs +1971 -0
- package/src/orchestration/orchestration-runtime.mjs +1019 -0
- package/src/orchestration/orchestration-scheduler.mjs +135 -0
- package/src/orchestration/orchestration-trail.mjs +192 -0
- package/src/orchestration/preflight.mjs +212 -0
- package/src/orchestration/provider-health.mjs +566 -0
- package/src/orchestration/provider-router.mjs +352 -0
- package/src/orchestration/rc-check-adapters.mjs +817 -0
- package/src/orchestration/rc-check.mjs +544 -0
- package/src/orchestration/rc-policy.mjs +200 -0
- package/src/orchestration/runtime-gate.mjs +146 -0
- package/src/orchestration/sector-managers.mjs +215 -0
- package/src/orchestration/self-hosting-canary.mjs +231 -0
- package/src/orchestration/self-hosting-readiness.mjs +244 -0
- package/src/orchestration/spec-planner.mjs +877 -0
- package/src/orchestration/subtask-planner.mjs +176 -0
- package/src/orchestration/task-classifier.mjs +241 -0
- package/src/orchestration/verifier.mjs +1608 -0
- package/src/orchestration-commands.mjs +279 -0
- package/src/planner.mjs +38 -0
- package/src/project.mjs +1 -0
- package/src/run-diagnostics.mjs +641 -0
- package/src/runner.mjs +243 -0
- package/src/safety.mjs +116 -0
- package/src/schema.mjs +371 -0
- package/src/session-commands.mjs +521 -0
- package/src/shell.mjs +93 -0
- package/src/smart-planner.mjs +249 -0
- package/src/task-commands.mjs +1182 -0
- package/src/tasks.mjs +134 -0
- package/src/ui/commands.mjs +76 -0
- package/src/ui/events.mjs +45 -0
- package/src/ui/public/app.js +600 -0
- package/src/ui/public/index.html +88 -0
- package/src/ui/public/style.css +460 -0
- package/src/ui/server.mjs +112 -0
- package/src/ui/state.mjs +504 -0
- package/src/usage.mjs +178 -0
- package/src/workspace-diff.mjs +228 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import { getEngine } from '../engines/registry.mjs';
|
|
2
|
+
import { redactObject } from '../format.mjs';
|
|
3
|
+
import { getTaskById, updateTaskStatus } from '../tasks.mjs';
|
|
4
|
+
import { runTask, withAccountingLock } from '../task-commands.mjs';
|
|
5
|
+
import { countRunsForTask } from './runtime-gate.mjs';
|
|
6
|
+
import { classifyFailureEvidence, HEALTH_AFFECTING_FAILURE_CLASSES } from './failure-evidence.mjs';
|
|
7
|
+
import { deriveHealthTargetId, recordHealthFailure, recordHealthSuccess } from './provider-health.mjs';
|
|
8
|
+
import { listEngineRecords } from './capability-registry.mjs';
|
|
9
|
+
import { loadBudget } from './budget-manager.mjs';
|
|
10
|
+
import { planProviderFallback } from './provider-router.mjs';
|
|
11
|
+
import {
|
|
12
|
+
appendFallbackChainEvent,
|
|
13
|
+
findLatestFallbackChainForTask,
|
|
14
|
+
replayFallbackChain
|
|
15
|
+
} from './fallback-chain-trail.mjs';
|
|
16
|
+
import {
|
|
17
|
+
acquireFallbackChainLock,
|
|
18
|
+
releaseFallbackChainLock
|
|
19
|
+
} from './fallback-chain-lock.mjs';
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_FALLBACK_CHAIN_DEADLINE_MS = 600000;
|
|
22
|
+
|
|
23
|
+
const SUCCESS_STATUSES = new Set(['completed', 'completed_with_warnings']);
|
|
24
|
+
|
|
25
|
+
function nowIso() {
|
|
26
|
+
return new Date().toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function terminal(eventType, status, fields = {}) {
|
|
30
|
+
return {
|
|
31
|
+
status,
|
|
32
|
+
terminalEventType: eventType,
|
|
33
|
+
...fields
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function asSet(values) {
|
|
38
|
+
return values instanceof Set ? values : new Set((Array.isArray(values) ? values : []).map(item => String(item)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeEngineIdCandidate(value) {
|
|
42
|
+
if (typeof value === 'string') {
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return trimmed ? trimmed : null;
|
|
45
|
+
}
|
|
46
|
+
if (value && typeof value === 'object' && value.id != null) {
|
|
47
|
+
const trimmed = String(value.id).trim();
|
|
48
|
+
return trimmed ? trimmed : null;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeRunEngineId(run, fallbackEngineId = null) {
|
|
54
|
+
const explicit = normalizeEngineIdCandidate(fallbackEngineId);
|
|
55
|
+
if (explicit) return explicit;
|
|
56
|
+
const selected = normalizeEngineIdCandidate(run && run.selectedEngine);
|
|
57
|
+
if (selected) return selected;
|
|
58
|
+
const runtimeSelected = normalizeEngineIdCandidate(run && run.runtimeGate && run.runtimeGate.selectedEngine);
|
|
59
|
+
if (runtimeSelected) return runtimeSelected;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mapGateFailureClass(run) {
|
|
64
|
+
const gate = run && run.runtimeGate ? run.runtimeGate : {};
|
|
65
|
+
if (gate.decision === 'NEEDS_HUMAN') return 'human_decision';
|
|
66
|
+
if (gate.blockCategory === 'budget') return 'unknown';
|
|
67
|
+
const preflightDecision = gate.preflightDecision || (gate.preflight && gate.preflight.decision);
|
|
68
|
+
if (preflightDecision === 'UNAVAILABLE') return 'provider_unavailable';
|
|
69
|
+
if (preflightDecision === 'NEEDS_CAPABILITY' || preflightDecision === 'NEEDS_CONTEXT') return 'model_unavailable';
|
|
70
|
+
if (preflightDecision === 'DECLINE') return 'human_decision';
|
|
71
|
+
const category = gate.fallback && gate.fallback.category;
|
|
72
|
+
if (category === 'provider_unavailable') return 'provider_unavailable';
|
|
73
|
+
if (category === 'rate_limit') return 'rate_limit';
|
|
74
|
+
if (category === 'quota') return 'quota';
|
|
75
|
+
if (category === 'auth_failure' || category === 'authentication') return 'authentication';
|
|
76
|
+
if (category === 'context_too_small' || category === 'missing_tool' || category === 'insufficient_capability') return 'model_unavailable';
|
|
77
|
+
return 'unknown';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classifyRunEvidence(run, task) {
|
|
81
|
+
if (!run) {
|
|
82
|
+
return { outcome: 'failure', failureClass: 'unknown', reason: 'runTask returned no run', evidenceSummary: '' };
|
|
83
|
+
}
|
|
84
|
+
if (run.blockedByGate === true) {
|
|
85
|
+
return {
|
|
86
|
+
outcome: 'failure',
|
|
87
|
+
failureClass: mapGateFailureClass(run),
|
|
88
|
+
retryable: false,
|
|
89
|
+
affectsHealth: false,
|
|
90
|
+
evidenceSource: 'runtimeGate',
|
|
91
|
+
evidenceSummary: 'blockedByGate=true',
|
|
92
|
+
reason: 'pre-execution runtime gate decision'
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (SUCCESS_STATUSES.has(run.status)) {
|
|
96
|
+
return {
|
|
97
|
+
outcome: 'success',
|
|
98
|
+
failureClass: null,
|
|
99
|
+
retryable: false,
|
|
100
|
+
affectsHealth: false,
|
|
101
|
+
evidenceSource: 'status',
|
|
102
|
+
evidenceSummary: 'status=' + run.status,
|
|
103
|
+
reason: 'task completed'
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (run.blockedBySafety === true) {
|
|
107
|
+
return { outcome: 'failure', failureClass: 'safety_block', retryable: false, affectsHealth: false, evidenceSource: 'blockedBySafety', evidenceSummary: 'blockedBySafety=true', reason: 'safety blocked the attempt' };
|
|
108
|
+
}
|
|
109
|
+
const deliverable = run.deliverableVerification || {};
|
|
110
|
+
if ((deliverable.failures || []).length > 0 || (deliverable.missingEvidence || []).length > 0) {
|
|
111
|
+
return { outcome: 'failure', failureClass: 'deliverable_rejection', retryable: false, affectsHealth: false, evidenceSource: 'deliverableVerification', evidenceSummary: 'deliverable verification rejected the attempt', reason: 'deliverable verification failed' };
|
|
112
|
+
}
|
|
113
|
+
if (run.status === 'needs_human') {
|
|
114
|
+
return { outcome: 'failure', failureClass: 'human_decision', retryable: false, affectsHealth: false, evidenceSource: 'status', evidenceSummary: 'status=needs_human', reason: 'attempt requires human decision' };
|
|
115
|
+
}
|
|
116
|
+
return classifyFailureEvidence({
|
|
117
|
+
source: 'run',
|
|
118
|
+
taskDescription: task ? task.description : '',
|
|
119
|
+
acceptanceCriteria: task ? task.acceptance : [],
|
|
120
|
+
exitCode: run.exitCode,
|
|
121
|
+
signal: run.signal,
|
|
122
|
+
timedOut: run.timedOut,
|
|
123
|
+
stdout: run.stdout,
|
|
124
|
+
stderr: run.stderr,
|
|
125
|
+
fullStdout: run.fullStdout,
|
|
126
|
+
fullStderr: run.fullStderr,
|
|
127
|
+
retryAfterMs: run.retryAfterMs
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function workspaceSafetyFromRun(run) {
|
|
132
|
+
return {
|
|
133
|
+
workspaceChanged: run ? run.workspaceChanged === true : true,
|
|
134
|
+
changedFiles: run && Array.isArray(run.changedFiles) ? run.changedFiles : [],
|
|
135
|
+
createdFiles: run && Array.isArray(run.createdFiles) ? run.createdFiles : [],
|
|
136
|
+
deletedFiles: run && Array.isArray(run.deletedFiles) ? run.deletedFiles : [],
|
|
137
|
+
outOfScopeChanges: run && Array.isArray(run.outOfScopeChanges) ? run.outOfScopeChanges : []
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function workspaceIsSafe(run) {
|
|
142
|
+
const safety = workspaceSafetyFromRun(run);
|
|
143
|
+
return safety.workspaceChanged === false &&
|
|
144
|
+
safety.outOfScopeChanges.length === 0 &&
|
|
145
|
+
safety.deletedFiles.length === 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function engineMetadata(engineId, deps) {
|
|
149
|
+
if (!engineId) return {};
|
|
150
|
+
try {
|
|
151
|
+
const records = await (deps.listEngineRecords || listEngineRecords)({ enabledOnly: false });
|
|
152
|
+
const record = records.find(item => String(item.id) === String(engineId));
|
|
153
|
+
return record ? { provider: record.provider || null, model: record.model || null, record } : {};
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function updateHealthAfterRun({ run, taskId, engineId, healthTargetId, failureClassification, deps }) {
|
|
160
|
+
if (!run || run.blockedByGate === true || !healthTargetId) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const metadata = await engineMetadata(engineId, deps);
|
|
164
|
+
if (failureClassification.outcome === 'success') {
|
|
165
|
+
return (deps.recordHealthSuccess || recordHealthSuccess)({
|
|
166
|
+
healthTargetId,
|
|
167
|
+
taskId,
|
|
168
|
+
runId: run.runId,
|
|
169
|
+
orchestrationId: run.orchestrationId || null,
|
|
170
|
+
metadata
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (HEALTH_AFFECTING_FAILURE_CLASSES.has(failureClassification.failureClass)) {
|
|
174
|
+
return (deps.recordHealthFailure || recordHealthFailure)({
|
|
175
|
+
healthTargetId,
|
|
176
|
+
taskId,
|
|
177
|
+
runId: run.runId,
|
|
178
|
+
orchestrationId: run.orchestrationId || null,
|
|
179
|
+
failureClass: failureClassification.failureClass,
|
|
180
|
+
evidenceSource: failureClassification.evidenceSource || null,
|
|
181
|
+
evidenceSummary: failureClassification.evidenceSummary || '',
|
|
182
|
+
retryAfterMs: failureClassification.retryAfterMs,
|
|
183
|
+
metadata
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function setTaskStatus(taskId, status, deps) {
|
|
190
|
+
const update = deps.updateTaskStatus || updateTaskStatus;
|
|
191
|
+
const lock = deps.withAccountingLock || withAccountingLock;
|
|
192
|
+
return lock(() => update(taskId, status));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function eventBase(state, fields = {}) {
|
|
196
|
+
return {
|
|
197
|
+
taskId: Number(state.taskId),
|
|
198
|
+
orchestrationId: fields.orchestrationId || null,
|
|
199
|
+
fallbackChainId: state.fallbackChainId,
|
|
200
|
+
originatingRunId: state.originatingRunId,
|
|
201
|
+
deadline: state.deadline,
|
|
202
|
+
previousAttemptId: state.previousAttemptId,
|
|
203
|
+
attemptNumber: state.attemptNumber,
|
|
204
|
+
visitedEngineIds: [...state.visitedEngineIds],
|
|
205
|
+
visitedHealthTargets: [...state.visitedHealthTargets],
|
|
206
|
+
reasonChain: [...state.reasonChain],
|
|
207
|
+
...fields
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function appendEvent(state, fields, deps) {
|
|
212
|
+
const append = deps.appendFallbackChainEvent || appendFallbackChainEvent;
|
|
213
|
+
return append(eventBase(state, fields), { projectRoot: deps.projectRoot });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function makeBudgetState(runsSoFar, budget) {
|
|
217
|
+
return {
|
|
218
|
+
runsSoFar,
|
|
219
|
+
maxRunsPerTask: budget.maxRunsPerTask,
|
|
220
|
+
maxAttempts: budget.maxRunsPerTask,
|
|
221
|
+
budget
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function planNext({ task, state, failureClassification, budgetState, deps }) {
|
|
226
|
+
const route = deps.planProviderFallback || planProviderFallback;
|
|
227
|
+
return route({
|
|
228
|
+
task,
|
|
229
|
+
failureClassification,
|
|
230
|
+
visitedEngineIds: state.visitedEngineIds,
|
|
231
|
+
visitedHealthTargets: state.visitedHealthTargets,
|
|
232
|
+
budgetState,
|
|
233
|
+
deadline: state.deadline,
|
|
234
|
+
timestamp: nowIso(),
|
|
235
|
+
allowPaid: state.budget.allowPaid === true,
|
|
236
|
+
preferFree: state.budget.preferFree !== false,
|
|
237
|
+
dependencies: deps.routerDependencies || {}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function resolveEngineForAttempt(engineId, deps) {
|
|
242
|
+
if (!engineId) return undefined;
|
|
243
|
+
const resolve = deps.resolveEngine || getEngine;
|
|
244
|
+
return resolve(engineId);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function executeAttempt({ taskId, task, engineId, state, options, deps }) {
|
|
248
|
+
if (state.attemptNumber > 0) {
|
|
249
|
+
await setTaskStatus(taskId, 'pending', deps);
|
|
250
|
+
}
|
|
251
|
+
const runsBefore = await (deps.countRunsForTask || countRunsForTask)(taskId);
|
|
252
|
+
const runOptions = {
|
|
253
|
+
force: options.force === true,
|
|
254
|
+
suppressOutput: true,
|
|
255
|
+
quiet: true,
|
|
256
|
+
verbose: options.verbose === true,
|
|
257
|
+
debug: options.debug === true
|
|
258
|
+
};
|
|
259
|
+
if (engineId) {
|
|
260
|
+
runOptions.engine = await resolveEngineForAttempt(engineId, deps);
|
|
261
|
+
}
|
|
262
|
+
const runResult = await (deps.runTask || runTask)(taskId, runOptions);
|
|
263
|
+
const run = runResult && runResult.run ? runResult.run : null;
|
|
264
|
+
if (!run || !run.runId) {
|
|
265
|
+
return terminal('fallback_needs_human', 'needs_human', {
|
|
266
|
+
reason: ['runTask returned no run; cannot correlate fallback attempt'],
|
|
267
|
+
runResult
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!state.fallbackChainId) {
|
|
272
|
+
state.fallbackChainId = run.runId;
|
|
273
|
+
state.originatingRunId = run.runId;
|
|
274
|
+
state.deadline = options.deadline || new Date(Date.now() + (options.deadlineMs || DEFAULT_FALLBACK_CHAIN_DEADLINE_MS)).toISOString();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
state.attemptNumber += 1;
|
|
278
|
+
const attemptNumber = state.attemptNumber;
|
|
279
|
+
const previousAttemptId = state.previousAttemptId;
|
|
280
|
+
const actualEngineId = normalizeRunEngineId(run, engineId);
|
|
281
|
+
const healthTargetId = actualEngineId ? deriveHealthTargetId({ id: actualEngineId }) : null;
|
|
282
|
+
if (actualEngineId) state.visitedEngineIds.add(actualEngineId);
|
|
283
|
+
if (healthTargetId) state.visitedHealthTargets.add(healthTargetId);
|
|
284
|
+
|
|
285
|
+
if (attemptNumber === 1) {
|
|
286
|
+
await appendEvent(state, {
|
|
287
|
+
eventType: 'fallback_chain_started',
|
|
288
|
+
runId: run.runId,
|
|
289
|
+
attemptId: run.runId,
|
|
290
|
+
attemptNumber,
|
|
291
|
+
previousAttemptId,
|
|
292
|
+
engineId: actualEngineId,
|
|
293
|
+
healthTargetId,
|
|
294
|
+
decision: 'started',
|
|
295
|
+
reason: ['fallback chain started from returned run'],
|
|
296
|
+
budgetBefore: { runsSoFar: runsBefore },
|
|
297
|
+
workspaceSafety: workspaceSafetyFromRun(run)
|
|
298
|
+
}, deps);
|
|
299
|
+
}
|
|
300
|
+
await appendEvent(state, {
|
|
301
|
+
eventType: 'fallback_attempt_started',
|
|
302
|
+
runId: run.runId,
|
|
303
|
+
attemptId: run.runId,
|
|
304
|
+
attemptNumber,
|
|
305
|
+
previousAttemptId,
|
|
306
|
+
engineId: actualEngineId,
|
|
307
|
+
healthTargetId,
|
|
308
|
+
decision: 'attempt_started',
|
|
309
|
+
reason: ['attempt correlated by returned run.runId'],
|
|
310
|
+
budgetBefore: { runsSoFar: runsBefore },
|
|
311
|
+
workspaceSafety: workspaceSafetyFromRun(run)
|
|
312
|
+
}, deps);
|
|
313
|
+
|
|
314
|
+
const failureClassification = classifyRunEvidence(run, task);
|
|
315
|
+
await updateHealthAfterRun({ run, taskId, engineId: actualEngineId, healthTargetId, failureClassification, deps });
|
|
316
|
+
const runsAfter = await (deps.countRunsForTask || countRunsForTask)(taskId);
|
|
317
|
+
state.previousAttemptId = run.runId;
|
|
318
|
+
state.reasonChain.push(failureClassification.reason || failureClassification.failureClass || 'attempt classified');
|
|
319
|
+
|
|
320
|
+
await appendEvent(state, {
|
|
321
|
+
eventType: 'fallback_attempt_finished',
|
|
322
|
+
runId: run.runId,
|
|
323
|
+
attemptId: run.runId,
|
|
324
|
+
attemptNumber,
|
|
325
|
+
previousAttemptId,
|
|
326
|
+
engineId: actualEngineId,
|
|
327
|
+
healthTargetId,
|
|
328
|
+
failureClass: failureClassification.failureClass,
|
|
329
|
+
decision: failureClassification.outcome === 'success' ? 'success' : 'failed',
|
|
330
|
+
reason: [failureClassification.reason || 'attempt finished'],
|
|
331
|
+
budgetBefore: { runsSoFar: runsBefore },
|
|
332
|
+
budgetAfter: { runsSoFar: runsAfter },
|
|
333
|
+
workspaceSafety: workspaceSafetyFromRun(run)
|
|
334
|
+
}, deps);
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
run,
|
|
338
|
+
runResult,
|
|
339
|
+
actualEngineId,
|
|
340
|
+
healthTargetId,
|
|
341
|
+
failureClassification,
|
|
342
|
+
runsBefore,
|
|
343
|
+
runsAfter,
|
|
344
|
+
attemptNumber
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function finishChain(state, eventType, status, fields, deps) {
|
|
349
|
+
await appendEvent(state, {
|
|
350
|
+
eventType,
|
|
351
|
+
runId: fields.runId || state.previousAttemptId,
|
|
352
|
+
attemptId: fields.runId || state.previousAttemptId,
|
|
353
|
+
engineId: fields.engineId || null,
|
|
354
|
+
healthTargetId: fields.healthTargetId || null,
|
|
355
|
+
failureClass: fields.failureClass || null,
|
|
356
|
+
decision: fields.decision || eventType,
|
|
357
|
+
reason: fields.reason || [],
|
|
358
|
+
selectedCandidate: fields.selectedCandidate || null,
|
|
359
|
+
rejectedCandidates: fields.rejectedCandidates || [],
|
|
360
|
+
budgetBefore: fields.budgetBefore || null,
|
|
361
|
+
budgetAfter: fields.budgetAfter || null,
|
|
362
|
+
workspaceSafety: fields.workspaceSafety || { workspaceChanged: null },
|
|
363
|
+
terminalOutcome: eventType
|
|
364
|
+
}, deps);
|
|
365
|
+
if (status === 'needs_human' || status === 'blocked') {
|
|
366
|
+
await setTaskStatus(state.taskId, status, deps);
|
|
367
|
+
}
|
|
368
|
+
return terminal(eventType, status, {
|
|
369
|
+
fallbackChainId: state.fallbackChainId,
|
|
370
|
+
originatingRunId: state.originatingRunId,
|
|
371
|
+
attemptNumber: state.attemptNumber,
|
|
372
|
+
previousAttemptId: state.previousAttemptId,
|
|
373
|
+
visitedEngineIds: [...state.visitedEngineIds],
|
|
374
|
+
visitedHealthTargets: [...state.visitedHealthTargets],
|
|
375
|
+
reason: fields.reason || [],
|
|
376
|
+
selectedCandidate: fields.selectedCandidate || null,
|
|
377
|
+
rejectedCandidates: fields.rejectedCandidates || []
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function continueFromAttempt({ task, state, attempt, deps }) {
|
|
382
|
+
if (attempt.failureClassification.outcome === 'success') {
|
|
383
|
+
return finishChain(state, 'fallback_chain_completed', 'completed', {
|
|
384
|
+
runId: attempt.run.runId,
|
|
385
|
+
engineId: attempt.actualEngineId,
|
|
386
|
+
healthTargetId: attempt.healthTargetId,
|
|
387
|
+
decision: 'success',
|
|
388
|
+
reason: ['attempt succeeded'],
|
|
389
|
+
budgetAfter: { runsSoFar: attempt.runsAfter },
|
|
390
|
+
workspaceSafety: workspaceSafetyFromRun(attempt.run)
|
|
391
|
+
}, deps);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (attempt.run.blockedByGate === true && attempt.run.runtimeGate && attempt.run.runtimeGate.decision !== 'FALLBACK') {
|
|
395
|
+
const budgetBlocked = attempt.run.runtimeGate.blockCategory === 'budget';
|
|
396
|
+
return finishChain(state, budgetBlocked ? 'fallback_blocked_by_budget' : 'fallback_needs_human', budgetBlocked ? 'blocked' : 'needs_human', {
|
|
397
|
+
runId: attempt.run.runId,
|
|
398
|
+
engineId: attempt.actualEngineId,
|
|
399
|
+
healthTargetId: attempt.healthTargetId,
|
|
400
|
+
failureClass: attempt.failureClassification.failureClass,
|
|
401
|
+
decision: budgetBlocked ? 'budget_blocked' : 'needs_human',
|
|
402
|
+
reason: attempt.run.runtimeGate.reason || ['runtime gate did not authorize fallback'],
|
|
403
|
+
budgetAfter: { runsSoFar: attempt.runsAfter },
|
|
404
|
+
workspaceSafety: workspaceSafetyFromRun(attempt.run)
|
|
405
|
+
}, deps);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!workspaceIsSafe(attempt.run)) {
|
|
409
|
+
return finishChain(state, 'fallback_blocked_by_workspace', 'needs_human', {
|
|
410
|
+
runId: attempt.run.runId,
|
|
411
|
+
engineId: attempt.actualEngineId,
|
|
412
|
+
healthTargetId: attempt.healthTargetId,
|
|
413
|
+
failureClass: attempt.failureClassification.failureClass,
|
|
414
|
+
decision: 'workspace_unsafe',
|
|
415
|
+
reason: ['workspace changed or could not be proven safe after failed attempt'],
|
|
416
|
+
budgetAfter: { runsSoFar: attempt.runsAfter },
|
|
417
|
+
workspaceSafety: workspaceSafetyFromRun(attempt.run)
|
|
418
|
+
}, deps);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const budgetState = makeBudgetState(attempt.runsAfter, state.budget);
|
|
422
|
+
const plan = await planNext({ task, state, failureClassification: attempt.failureClassification, budgetState, deps });
|
|
423
|
+
for (const rejected of plan.rejectedCandidates || []) {
|
|
424
|
+
await appendEvent(state, {
|
|
425
|
+
eventType: 'fallback_candidate_rejected',
|
|
426
|
+
runId: attempt.run.runId,
|
|
427
|
+
attemptId: attempt.run.runId,
|
|
428
|
+
engineId: rejected.engineId || null,
|
|
429
|
+
healthTargetId: rejected.healthTargetId || null,
|
|
430
|
+
failureClass: attempt.failureClassification.failureClass,
|
|
431
|
+
decision: 'rejected',
|
|
432
|
+
reason: [rejected.reason || 'candidate rejected'],
|
|
433
|
+
rejectedCandidates: [rejected],
|
|
434
|
+
budgetAfter: { runsSoFar: attempt.runsAfter },
|
|
435
|
+
workspaceSafety: workspaceSafetyFromRun(attempt.run)
|
|
436
|
+
}, deps);
|
|
437
|
+
}
|
|
438
|
+
if (plan.action !== 'switch_engine' || !plan.candidateEngineId) {
|
|
439
|
+
const eventType = plan.terminalReason === 'fallback_blocked_by_budget'
|
|
440
|
+
? 'fallback_blocked_by_budget'
|
|
441
|
+
: plan.terminalReason === 'fallback_exhausted'
|
|
442
|
+
? 'fallback_chain_exhausted'
|
|
443
|
+
: 'fallback_needs_human';
|
|
444
|
+
return finishChain(state, eventType, eventType === 'fallback_needs_human' ? 'needs_human' : 'blocked', {
|
|
445
|
+
runId: attempt.run.runId,
|
|
446
|
+
engineId: attempt.actualEngineId,
|
|
447
|
+
healthTargetId: attempt.healthTargetId,
|
|
448
|
+
failureClass: attempt.failureClassification.failureClass,
|
|
449
|
+
decision: plan.action,
|
|
450
|
+
reason: plan.reason,
|
|
451
|
+
rejectedCandidates: plan.rejectedCandidates || [],
|
|
452
|
+
budgetAfter: { runsSoFar: attempt.runsAfter },
|
|
453
|
+
workspaceSafety: workspaceSafetyFromRun(attempt.run)
|
|
454
|
+
}, deps);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const selectedCandidate = {
|
|
458
|
+
engineId: plan.candidateEngineId,
|
|
459
|
+
healthTargetId: plan.candidateEngineId,
|
|
460
|
+
reason: plan.reason
|
|
461
|
+
};
|
|
462
|
+
await appendEvent(state, {
|
|
463
|
+
eventType: 'fallback_candidate_selected',
|
|
464
|
+
runId: attempt.run.runId,
|
|
465
|
+
attemptId: attempt.run.runId,
|
|
466
|
+
engineId: plan.candidateEngineId,
|
|
467
|
+
healthTargetId: plan.candidateEngineId,
|
|
468
|
+
failureClass: attempt.failureClassification.failureClass,
|
|
469
|
+
decision: 'switch_engine',
|
|
470
|
+
reason: plan.reason,
|
|
471
|
+
selectedCandidate,
|
|
472
|
+
rejectedCandidates: plan.rejectedCandidates || [],
|
|
473
|
+
budgetAfter: { runsSoFar: attempt.runsAfter },
|
|
474
|
+
workspaceSafety: workspaceSafetyFromRun(attempt.run)
|
|
475
|
+
}, deps);
|
|
476
|
+
return { status: 'continue', nextEngineId: plan.candidateEngineId };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function createNewState(taskId, budget) {
|
|
480
|
+
return {
|
|
481
|
+
taskId: Number(taskId),
|
|
482
|
+
fallbackChainId: null,
|
|
483
|
+
originatingRunId: null,
|
|
484
|
+
attemptNumber: 0,
|
|
485
|
+
previousAttemptId: null,
|
|
486
|
+
deadline: null,
|
|
487
|
+
visitedEngineIds: new Set(),
|
|
488
|
+
visitedHealthTargets: new Set(),
|
|
489
|
+
reasonChain: [],
|
|
490
|
+
budget
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function stateFromReplay(taskId, replayed, budget) {
|
|
495
|
+
return {
|
|
496
|
+
taskId: Number(taskId),
|
|
497
|
+
fallbackChainId: replayed.fallbackChainId,
|
|
498
|
+
originatingRunId: replayed.originatingRunId || replayed.fallbackChainId,
|
|
499
|
+
attemptNumber: replayed.attemptNumber,
|
|
500
|
+
previousAttemptId: replayed.previousAttemptId,
|
|
501
|
+
deadline: replayed.deadline,
|
|
502
|
+
visitedEngineIds: asSet(replayed.visitedEngineIds),
|
|
503
|
+
visitedHealthTargets: asSet(replayed.visitedHealthTargets),
|
|
504
|
+
reasonChain: replayed.reasonChain || [],
|
|
505
|
+
budget
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function dryRunFailoverPlan(taskId, options = {}) {
|
|
510
|
+
const deps = options.dependencies || {};
|
|
511
|
+
const task = await (deps.getTaskById || getTaskById)(taskId);
|
|
512
|
+
if (!task) {
|
|
513
|
+
return { dryRun: true, status: 'blocked', reason: ['task not found'], taskId: Number(taskId) };
|
|
514
|
+
}
|
|
515
|
+
const budget = await (deps.loadBudget || loadBudget)();
|
|
516
|
+
const runsSoFar = await (deps.countRunsForTask || countRunsForTask)(taskId);
|
|
517
|
+
const currentEngineId = options.currentEngineId || task.engine || null;
|
|
518
|
+
const visitedEngineIds = currentEngineId ? [currentEngineId] : [];
|
|
519
|
+
const failureClassification = {
|
|
520
|
+
outcome: 'failure',
|
|
521
|
+
failureClass: options.simulateFailure || 'network',
|
|
522
|
+
reason: 'dry-run simulated failure'
|
|
523
|
+
};
|
|
524
|
+
const plan = await (deps.planProviderFallback || planProviderFallback)({
|
|
525
|
+
task,
|
|
526
|
+
failureClassification,
|
|
527
|
+
visitedEngineIds,
|
|
528
|
+
visitedHealthTargets: visitedEngineIds,
|
|
529
|
+
budgetState: makeBudgetState(runsSoFar, budget),
|
|
530
|
+
deadline: options.deadline || new Date(Date.now() + (options.deadlineMs || DEFAULT_FALLBACK_CHAIN_DEADLINE_MS)).toISOString(),
|
|
531
|
+
dependencies: deps.routerDependencies || {}
|
|
532
|
+
});
|
|
533
|
+
return redactObject({
|
|
534
|
+
dryRun: true,
|
|
535
|
+
taskId: Number(taskId),
|
|
536
|
+
runTaskCalled: false,
|
|
537
|
+
chainLockAcquired: false,
|
|
538
|
+
failureClassification,
|
|
539
|
+
plan
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function runTaskWithFailover(taskId, options = {}) {
|
|
544
|
+
if (options.dryRun === true) {
|
|
545
|
+
return dryRunFailoverPlan(taskId, options);
|
|
546
|
+
}
|
|
547
|
+
const deps = options.dependencies || {};
|
|
548
|
+
const acquire = deps.acquireFallbackChainLock || acquireFallbackChainLock;
|
|
549
|
+
const release = deps.releaseFallbackChainLock || releaseFallbackChainLock;
|
|
550
|
+
const lock = await acquire({ command: 'run-task ' + taskId + ' --failover', taskId, projectRoot: deps.projectRoot });
|
|
551
|
+
if (!lock.acquired) {
|
|
552
|
+
return {
|
|
553
|
+
status: 'blocked',
|
|
554
|
+
terminalEventType: 'fallback_chain_lock_unavailable',
|
|
555
|
+
lockOutcome: lock.outcome,
|
|
556
|
+
activeLock: lock.activeLock || null,
|
|
557
|
+
reason: ['another fallback chain is already in progress']
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const task = await (deps.getTaskById || getTaskById)(taskId);
|
|
563
|
+
if (!task) {
|
|
564
|
+
return { status: 'blocked', terminalEventType: 'fallback_needs_human', reason: ['task not found'], lockOutcome: lock.outcome };
|
|
565
|
+
}
|
|
566
|
+
const latestChain = await (deps.findLatestFallbackChainForTask || findLatestFallbackChainForTask)(taskId, { projectRoot: deps.projectRoot });
|
|
567
|
+
if (latestChain && !latestChain.terminal && options.resume !== true) {
|
|
568
|
+
return {
|
|
569
|
+
status: 'blocked',
|
|
570
|
+
terminalEventType: 'fallback_active_chain_exists',
|
|
571
|
+
fallbackChainId: latestChain.fallbackChainId,
|
|
572
|
+
reason: ['incomplete fallback chain exists; rerun with --resume']
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
if (options.resume === true) {
|
|
576
|
+
if (!latestChain) {
|
|
577
|
+
return { status: 'blocked', terminalEventType: 'fallback_resume_missing', reason: ['no fallback chain exists to resume'] };
|
|
578
|
+
}
|
|
579
|
+
if (latestChain.terminal) {
|
|
580
|
+
return { status: 'blocked', terminalEventType: 'fallback_resume_terminal', fallbackChainId: latestChain.fallbackChainId, reason: ['terminal fallback chain cannot be resumed'] };
|
|
581
|
+
}
|
|
582
|
+
const budget = await (deps.loadBudget || loadBudget)();
|
|
583
|
+
const replayedState = stateFromReplay(taskId, latestChain, budget);
|
|
584
|
+
if (latestChain.inProgressAttempt) {
|
|
585
|
+
return finishChain(replayedState, 'fallback_needs_human', 'needs_human', {
|
|
586
|
+
runId: latestChain.inProgressAttempt.runId,
|
|
587
|
+
engineId: latestChain.inProgressAttempt.engineId,
|
|
588
|
+
healthTargetId: latestChain.inProgressAttempt.healthTargetId,
|
|
589
|
+
decision: 'resume_blocked',
|
|
590
|
+
reason: ['resume found an attempt that was in progress; human reconciliation required']
|
|
591
|
+
}, deps);
|
|
592
|
+
}
|
|
593
|
+
const selected = latestChain.selectedCandidate && latestChain.selectedCandidate.engineId;
|
|
594
|
+
if (selected) {
|
|
595
|
+
let nextEngine = selected;
|
|
596
|
+
while (nextEngine) {
|
|
597
|
+
const attempt = await executeAttempt({ taskId, task, engineId: nextEngine, state: replayedState, options, deps });
|
|
598
|
+
if (attempt.terminalEventType) return attempt;
|
|
599
|
+
const next = await continueFromAttempt({ task, state: replayedState, attempt, deps });
|
|
600
|
+
if (next.status !== 'continue') return next;
|
|
601
|
+
nextEngine = next.nextEngineId;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return finishChain(replayedState, 'fallback_needs_human', 'needs_human', {
|
|
605
|
+
decision: 'resume_blocked',
|
|
606
|
+
reason: ['resume could not identify a safe next candidate from the trail']
|
|
607
|
+
}, deps);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const budget = await (deps.loadBudget || loadBudget)();
|
|
611
|
+
const state = createNewState(taskId, budget);
|
|
612
|
+
let nextEngineId = null;
|
|
613
|
+
while (true) {
|
|
614
|
+
const attempt = await executeAttempt({ taskId, task, engineId: nextEngineId, state, options, deps });
|
|
615
|
+
if (attempt.terminalEventType) return attempt;
|
|
616
|
+
const next = await continueFromAttempt({ task, state, attempt, deps });
|
|
617
|
+
if (next.status !== 'continue') return next;
|
|
618
|
+
nextEngineId = next.nextEngineId;
|
|
619
|
+
}
|
|
620
|
+
} finally {
|
|
621
|
+
await release(lock.lockHandle);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function replayFallbackState(events = [], budget = {}) {
|
|
626
|
+
return stateFromReplay((events[0] && events[0].taskId) || 0, replayFallbackChain(events), budget);
|
|
627
|
+
}
|