ultra-builder-pro-cli 0.16.0 → 0.17.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/.ultra-template/specs/research-distillate.md +4 -3
- package/CHANGELOG.md +86 -0
- package/README.md +54 -24
- package/adapters/_shared/codex-assets.cjs +10 -5
- package/adapters/_shared/handbook.cjs +1 -1
- package/adapters/_shared/interaction-contract.cjs +61 -0
- package/adapters/claude.js +1 -1
- package/adapters/opencode.js +1 -1
- package/bin/orchestrator.js +175 -19
- package/commands/ultra-change.md +1 -1
- package/commands/ultra-deliver.md +3 -3
- package/commands/ultra-init.md +1 -1
- package/commands/ultra-plan.md +1 -1
- package/commands/ultra-research.md +2 -2
- package/commands/ultra-review.md +10 -0
- package/commands/ultra-status.md +1 -1
- package/commands/ultra-test.md +1 -1
- package/docs/AGENT-CONTEXT.md +11 -8
- package/docs/ARCHITECTURE.md +67 -9
- package/docs/LEGACY-CLI-CROSSWALK.md +163 -0
- package/docs/PLAN.zh-CN.md +19 -13
- package/docs/RUNTIME-COMPAT-MATRIX.md +2 -2
- package/docs/WORKFLOW-LIFECYCLE.md +123 -59
- package/hooks/pre_stop_check.py +3 -2
- package/mcp-server/lib/baseline-workflow.cjs +84 -16
- package/mcp-server/lib/change-workflow.cjs +55 -35
- package/mcp-server/lib/context-spine.cjs +171 -105
- package/mcp-server/lib/git-bootstrap.cjs +240 -0
- package/mcp-server/lib/init-project.cjs +178 -41
- package/mcp-server/lib/project-breadcrumb.cjs +14 -14
- package/mcp-server/lib/state-db.cjs +257 -10
- package/mcp-server/lib/state-ops.cjs +64 -1
- package/mcp-server/lib/workflow-state.cjs +300 -205
- package/mcp-server/server.cjs +58 -34
- package/orchestrator/auto-merge.cjs +19 -0
- package/orchestrator/daemon.cjs +104 -0
- package/orchestrator/parallel-orchestrator.cjs +371 -49
- package/orchestrator/session-runner.cjs +354 -25
- package/package.json +2 -1
- package/skills/ultra-change/SKILL.md +69 -94
- package/skills/ultra-deliver/SKILL.md +37 -65
- package/skills/ultra-dev/SKILL.md +50 -52
- package/skills/ultra-doctor/SKILL.md +34 -49
- package/skills/ultra-init/SKILL.md +78 -105
- package/skills/ultra-plan/SKILL.md +78 -99
- package/skills/ultra-research/SKILL.md +99 -113
- package/skills/ultra-review/SKILL.md +38 -53
- package/skills/ultra-review/references/review-modes.md +5 -3
- package/skills/ultra-review/references/unified-schema.md +26 -6
- package/skills/ultra-review/scripts/review_wait.py +12 -0
- package/skills/ultra-status/SKILL.md +34 -44
- package/skills/ultra-test/SKILL.md +61 -54
- package/skills/ultra-think/SKILL.md +37 -51
- package/skills/ultra-think/references/decision-dialogue.md +87 -111
- package/spec/cli-protocol.md +35 -0
- package/spec/mcp-tools.yaml +145 -28
- package/spec/schemas/delivery-report.v1.schema.json +1 -46
- package/spec/schemas/state-db.sql +8 -2
- package/spec/schemas/test-report.v1.schema.json +45 -14
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/session.cjs +10 -3
- package/ultra-tools/commands/status.cjs +19 -14
- package/ultra-tools/commands/system.cjs +2 -2
- package/ultra-tools/commands/task.cjs +3 -0
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
// Phase 8B.2 — Parallel orchestrator.
|
|
4
4
|
//
|
|
5
5
|
// Consumes an execution-plan.json (built by Phase 8A.4a plan-builder) and
|
|
6
|
-
// drives per-wave session spawning. Each wave is either parallel
|
|
7
|
-
// Promise.all on proc exit) or serial (await per task) based on the
|
|
6
|
+
// drives resumable per-wave session spawning. Each wave is either parallel
|
|
7
|
+
// (batch Promise.all on proc exit) or serial (await per task) based on the
|
|
8
8
|
// file-overlap detection already done by plan-builder.
|
|
9
9
|
//
|
|
10
10
|
// Responsibilities:
|
|
11
11
|
// • load plan (object or path)
|
|
12
|
-
// • for each wave: evaluate dispatch-rules per task → spawn sessions
|
|
13
|
-
// session-runner → wait for exit →
|
|
14
|
-
// •
|
|
12
|
+
// • for each ready wave: evaluate dispatch-rules per task → spawn sessions
|
|
13
|
+
// via session-runner → wait for transport exit → preserve task-gate state
|
|
14
|
+
// • stop at the first non-converged wave and resume safely on the next call
|
|
15
|
+
// • emit wave_started / wave_paused|wave_completed and
|
|
16
|
+
// plan_paused|plan_completed events
|
|
15
17
|
//
|
|
16
18
|
// Not responsible for:
|
|
17
19
|
// • circuit breaker (Phase 5.2 recordTaskFailure owns that path)
|
|
@@ -19,12 +21,16 @@
|
|
|
19
21
|
// • merge back (Phase 8B.4 layers on top of closeSession)
|
|
20
22
|
|
|
21
23
|
const fs = require('node:fs');
|
|
24
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
22
25
|
|
|
23
26
|
const planStore = require('../mcp-server/lib/plan-store.cjs');
|
|
24
27
|
const ops = require('../mcp-server/lib/state-ops.cjs');
|
|
28
|
+
const workflows = require('../mcp-server/lib/workflow-state.cjs');
|
|
25
29
|
const runner = require('./session-runner.cjs');
|
|
26
30
|
const dispatchRules = require('./dispatch-rules.cjs');
|
|
27
31
|
|
|
32
|
+
const TERMINAL_TASK_STATUSES = new Set(['completed', 'expanded']);
|
|
33
|
+
|
|
28
34
|
function resolvePlan({ plan, planPath, repoRoot }) {
|
|
29
35
|
if (plan) return plan;
|
|
30
36
|
if (planPath) return JSON.parse(fs.readFileSync(planPath, 'utf8'));
|
|
@@ -37,44 +43,251 @@ function resolvePlan({ plan, planPath, repoRoot }) {
|
|
|
37
43
|
return loaded;
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
function planError(code, message, details = null) {
|
|
47
|
+
const error = new Error(message);
|
|
48
|
+
error.code = code;
|
|
49
|
+
if (details) error.details = details;
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validatePlanShape(plan) {
|
|
54
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
|
|
55
|
+
throw planError('PLAN_INVALID', 'execution plan must be an object');
|
|
56
|
+
}
|
|
57
|
+
if (plan.schema_version !== undefined && plan.schema_version !== '1.0') {
|
|
58
|
+
throw planError(
|
|
59
|
+
'PLAN_INVALID',
|
|
60
|
+
`unsupported execution plan schema_version: ${String(plan.schema_version)}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (plan.change_id !== undefined && plan.change_id !== null
|
|
64
|
+
&& (typeof plan.change_id !== 'string' || plan.change_id.trim() === '')) {
|
|
65
|
+
throw planError('PLAN_INVALID', 'execution plan change_id must be null or a non-empty string');
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(plan.waves) || plan.waves.length === 0) {
|
|
68
|
+
throw planError('PLAN_INVALID', 'execution plan must contain at least one dependency wave');
|
|
69
|
+
}
|
|
70
|
+
if (plan.cycles !== undefined && !Array.isArray(plan.cycles)) {
|
|
71
|
+
throw planError('PLAN_INVALID', 'execution plan cycles must be an array');
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(plan.cycles) && plan.cycles.length > 0) {
|
|
74
|
+
throw planError('PLAN_INVALID', 'execution plan contains dependency cycles', {
|
|
75
|
+
cycles: plan.cycles,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const waveIds = new Set();
|
|
80
|
+
const taskIds = new Set();
|
|
81
|
+
for (const [index, wave] of plan.waves.entries()) {
|
|
82
|
+
if (!wave || typeof wave !== 'object' || Array.isArray(wave)) {
|
|
83
|
+
throw planError('PLAN_INVALID', `execution plan wave ${index + 1} must be an object`);
|
|
84
|
+
}
|
|
85
|
+
if ((typeof wave.id !== 'string' && typeof wave.id !== 'number')
|
|
86
|
+
|| String(wave.id).trim() === '') {
|
|
87
|
+
throw planError('PLAN_INVALID', `execution plan wave ${index + 1} requires an id`);
|
|
88
|
+
}
|
|
89
|
+
const waveKey = String(wave.id);
|
|
90
|
+
if (waveIds.has(waveKey)) {
|
|
91
|
+
throw planError('PLAN_INVALID', `execution plan contains duplicate wave id ${waveKey}`);
|
|
92
|
+
}
|
|
93
|
+
waveIds.add(waveKey);
|
|
94
|
+
if (!Array.isArray(wave.tasks) || wave.tasks.length === 0) {
|
|
95
|
+
throw planError('PLAN_INVALID', `execution plan wave ${waveKey} must contain tasks`);
|
|
96
|
+
}
|
|
97
|
+
if (typeof wave.parallel !== 'boolean') {
|
|
98
|
+
throw planError('PLAN_INVALID', `execution plan wave ${waveKey} requires parallel boolean`);
|
|
99
|
+
}
|
|
100
|
+
for (const taskId of wave.tasks) {
|
|
101
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') {
|
|
102
|
+
throw planError('PLAN_INVALID', `execution plan wave ${waveKey} has an invalid task id`);
|
|
103
|
+
}
|
|
104
|
+
if (taskIds.has(taskId)) {
|
|
105
|
+
throw planError('PLAN_INVALID', `execution plan contains duplicate task ${taskId}`);
|
|
106
|
+
}
|
|
107
|
+
taskIds.add(taskId);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return plan;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function assertPlanAuthority(db, repoRoot, plan) {
|
|
114
|
+
const taskIds = plan.waves.flatMap((wave) => wave.tasks);
|
|
115
|
+
const taskRows = taskIds.map((id) => ops.readTask(db, id)).filter(Boolean);
|
|
116
|
+
const taskChangeIds = new Set(taskRows.map((task) => task.change_id).filter(Boolean));
|
|
117
|
+
|
|
118
|
+
if (!plan.change_id) {
|
|
119
|
+
if (taskChangeIds.size > 0) {
|
|
120
|
+
throw planError(
|
|
121
|
+
'PLAN_NOT_COMPLETED',
|
|
122
|
+
'change-owned tasks require a change-bound, completed current execution plan',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return { mode: 'legacy-unbound', workflow_id: null };
|
|
126
|
+
}
|
|
127
|
+
if (taskChangeIds.size > 1 || (taskChangeIds.size === 1 && !taskChangeIds.has(plan.change_id))) {
|
|
128
|
+
throw planError(
|
|
129
|
+
'PLAN_CHANGE_MISMATCH',
|
|
130
|
+
`execution plan ${plan.change_id} contains tasks owned by another change`,
|
|
131
|
+
{ task_change_ids: [...taskChangeIds].sort() },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const completedRuns = workflows.listWorkflows(
|
|
136
|
+
db,
|
|
137
|
+
{ kind: 'plan', status: 'completed', change_id: plan.change_id, limit: 100 },
|
|
138
|
+
{ rootDir: repoRoot },
|
|
139
|
+
);
|
|
140
|
+
if (completedRuns.length === 0) {
|
|
141
|
+
throw planError(
|
|
142
|
+
'PLAN_NOT_COMPLETED',
|
|
143
|
+
`change ${plan.change_id} has no completed plan workflow`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let currentPlan;
|
|
148
|
+
try {
|
|
149
|
+
currentPlan = workflows.assertCurrentPlan(db, plan.change_id, repoRoot);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw planError(
|
|
152
|
+
error.code === 'WORKFLOW_PLAN_NOT_COMPLETED' ? 'PLAN_NOT_COMPLETED' : 'PLAN_STALE',
|
|
153
|
+
error.message,
|
|
154
|
+
{ authority_code: error.code || null },
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const expected = planStore.buildPlan(
|
|
158
|
+
currentPlan.tasks,
|
|
159
|
+
{ changeId: plan.change_id },
|
|
160
|
+
);
|
|
161
|
+
if (!isDeepStrictEqual(plan, expected)) {
|
|
162
|
+
throw planError(
|
|
163
|
+
'PLAN_STALE',
|
|
164
|
+
`execution plan for ${plan.change_id} does not match the current DB task graph`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return { mode: 'current-change', workflow_id: currentPlan.plan.id };
|
|
168
|
+
}
|
|
169
|
+
|
|
40
170
|
function awaitExit(proc) {
|
|
41
171
|
return new Promise((resolve) => {
|
|
42
|
-
if (!proc) {
|
|
172
|
+
if (!proc) {
|
|
173
|
+
resolve({ code: -1, signal: null, error: new Error('worker process was not created') });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
43
176
|
if (proc.exitCode !== null && proc.exitCode !== undefined) {
|
|
44
|
-
resolve({ code: proc.exitCode, signal: proc.signalCode || null });
|
|
177
|
+
resolve({ code: proc.exitCode, signal: proc.signalCode || null, error: null });
|
|
45
178
|
return;
|
|
46
179
|
}
|
|
47
|
-
|
|
180
|
+
let settled = false;
|
|
181
|
+
const settle = (result) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
proc.removeListener('exit', onExit);
|
|
185
|
+
proc.removeListener('error', onError);
|
|
186
|
+
resolve(result);
|
|
187
|
+
};
|
|
188
|
+
const onExit = (code, signal) => settle({
|
|
189
|
+
code: code === null ? -1 : code,
|
|
190
|
+
signal,
|
|
191
|
+
error: null,
|
|
192
|
+
});
|
|
193
|
+
const onError = (error) => settle({ code: -1, signal: null, error });
|
|
194
|
+
proc.once('exit', onExit);
|
|
195
|
+
proc.once('error', onError);
|
|
48
196
|
});
|
|
49
197
|
}
|
|
50
198
|
|
|
51
|
-
function
|
|
199
|
+
function failureReason(execution) {
|
|
200
|
+
if (execution.error) return `worker spawn or runtime error: ${execution.error.message}`;
|
|
201
|
+
return `runtime process exited with code ${execution.code}`
|
|
202
|
+
+ `${execution.signal ? ` (${execution.signal})` : ''}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function recordExecutionFailure(db, taskId, {
|
|
206
|
+
sessionId = null,
|
|
207
|
+
reason,
|
|
208
|
+
} = {}) {
|
|
209
|
+
const current = ops.readTask(db, taskId);
|
|
210
|
+
if (current && !TERMINAL_TASK_STATUSES.has(current.status) && current.status !== 'blocked') {
|
|
211
|
+
try { ops.patchTask(db, taskId, { status: 'blocked' }); }
|
|
212
|
+
catch (error) {
|
|
213
|
+
process.stderr.write(
|
|
214
|
+
`parallel-orchestrator: patchTask(${taskId}→blocked) failed: ${error.message}\n`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
ops.recordTaskFailure(db, taskId, { reason, session_id: sessionId });
|
|
220
|
+
} catch (error) {
|
|
221
|
+
process.stderr.write(
|
|
222
|
+
`parallel-orchestrator: recordTaskFailure(${taskId}) failed: ${error.message}\n`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function finalizeSession(db, handle, execution, ctx = {}) {
|
|
228
|
+
const { code } = execution;
|
|
52
229
|
const sessionStatus = code === 0 ? 'completed' : 'crashed';
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
230
|
+
const liveSession = ops.readSession(db, handle.sid);
|
|
231
|
+
if (!liveSession || liveSession.status !== 'running') {
|
|
232
|
+
return {
|
|
233
|
+
sid: handle.sid,
|
|
234
|
+
task_id: handle.task_id,
|
|
235
|
+
code,
|
|
236
|
+
signal: execution.signal || null,
|
|
237
|
+
error: execution.error?.message || null,
|
|
238
|
+
status: liveSession?.status || 'missing',
|
|
239
|
+
task_status: ops.readTask(db, handle.task_id)?.status || null,
|
|
240
|
+
worktree_preserved: Boolean(
|
|
241
|
+
liveSession?.worktree_path && fs.existsSync(liveSession.worktree_path),
|
|
242
|
+
),
|
|
243
|
+
merge: undefined,
|
|
244
|
+
close_error: null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (code !== 0) {
|
|
248
|
+
recordExecutionFailure(db, handle.task_id, {
|
|
249
|
+
sessionId: handle.sid,
|
|
250
|
+
reason: failureReason(execution),
|
|
251
|
+
});
|
|
59
252
|
}
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
253
|
+
|
|
254
|
+
// Process exit is transport evidence, not Ultra task completion. The worker
|
|
255
|
+
// must converge the task through the normal dev/test/review authority before
|
|
256
|
+
// an explicit auto-merge may integrate it.
|
|
257
|
+
let task = ops.readTask(db, handle.task_id);
|
|
258
|
+
const taskConverged = Boolean(task && TERMINAL_TASK_STATUSES.has(task.status));
|
|
63
259
|
let merge;
|
|
260
|
+
let worktreePreserved = true;
|
|
261
|
+
let closeError = null;
|
|
64
262
|
try {
|
|
65
263
|
const closeResult = runner.closeSession(
|
|
66
264
|
{ db, repoRoot: ctx.repoRoot, sid: handle.sid },
|
|
67
265
|
{
|
|
68
266
|
status: sessionStatus,
|
|
69
|
-
autoMerge: !!ctx.autoMerge && sessionStatus === 'completed',
|
|
267
|
+
autoMerge: !!ctx.autoMerge && sessionStatus === 'completed' && taskConverged,
|
|
70
268
|
mergeBaseBranch: ctx.mergeBaseBranch || 'main',
|
|
71
269
|
},
|
|
72
270
|
);
|
|
73
271
|
merge = closeResult.merge;
|
|
272
|
+
worktreePreserved = closeResult.worktree_preserved;
|
|
74
273
|
} catch (err) {
|
|
274
|
+
closeError = err.message;
|
|
75
275
|
process.stderr.write(`parallel-orchestrator: closeSession(${handle.sid}) failed: ${err.message}\n`);
|
|
276
|
+
if (ctx.onError) ctx.onError(err);
|
|
76
277
|
}
|
|
77
|
-
|
|
278
|
+
task = ops.readTask(db, handle.task_id);
|
|
279
|
+
return {
|
|
280
|
+
sid: handle.sid,
|
|
281
|
+
task_id: handle.task_id,
|
|
282
|
+
code,
|
|
283
|
+
signal: execution.signal || null,
|
|
284
|
+
error: execution.error?.message || null,
|
|
285
|
+
status: sessionStatus,
|
|
286
|
+
task_status: task?.status || null,
|
|
287
|
+
worktree_preserved: worktreePreserved,
|
|
288
|
+
merge,
|
|
289
|
+
close_error: closeError,
|
|
290
|
+
};
|
|
78
291
|
}
|
|
79
292
|
|
|
80
293
|
async function runTask(db, repoRoot, task, wave, ctx) {
|
|
@@ -102,54 +315,116 @@ async function runTask(db, repoRoot, task, wave, ctx) {
|
|
|
102
315
|
});
|
|
103
316
|
handle.task_id = task.id;
|
|
104
317
|
} catch (err) {
|
|
318
|
+
if (runner.isExpectedExecutionGate(err)) {
|
|
319
|
+
return {
|
|
320
|
+
sid: null,
|
|
321
|
+
task_id: task.id,
|
|
322
|
+
code: -4,
|
|
323
|
+
status: 'authority_blocked',
|
|
324
|
+
authority_code: err.code,
|
|
325
|
+
error: err.message,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
105
328
|
if (onError) onError(err);
|
|
329
|
+
recordExecutionFailure(db, task.id, {
|
|
330
|
+
reason: `worker session could not be created: ${err.message}`,
|
|
331
|
+
});
|
|
106
332
|
return { sid: null, task_id: task.id, code: -3, status: 'spawn_failed' };
|
|
107
333
|
}
|
|
108
334
|
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
try {
|
|
335
|
+
// The worker may converge very quickly, so re-read before transitioning and
|
|
336
|
+
// never downgrade a task that already reached a terminal state.
|
|
337
|
+
try {
|
|
338
|
+
if (ops.readTask(db, task.id)?.status === 'pending') {
|
|
339
|
+
ops.patchTask(db, task.id, { status: 'in_progress' });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
112
342
|
catch (err) {
|
|
113
|
-
|
|
343
|
+
const latest = ops.readTask(db, task.id);
|
|
344
|
+
if (!latest || !TERMINAL_TASK_STATUSES.has(latest.status)) {
|
|
345
|
+
try { if (handle.process && !handle.process.killed) handle.process.kill('SIGTERM'); }
|
|
346
|
+
catch (_) { /* best effort */ }
|
|
347
|
+
recordExecutionFailure(db, task.id, {
|
|
348
|
+
sessionId: handle.sid,
|
|
349
|
+
reason: `task could not enter in_progress: ${err.message}`,
|
|
350
|
+
});
|
|
351
|
+
return finalizeSession(
|
|
352
|
+
db,
|
|
353
|
+
handle,
|
|
354
|
+
{ code: -1, signal: 'SIGTERM', error: err },
|
|
355
|
+
{ ...ctx, repoRoot },
|
|
356
|
+
);
|
|
357
|
+
}
|
|
114
358
|
}
|
|
115
359
|
|
|
116
|
-
const
|
|
117
|
-
return finalizeSession(db, handle,
|
|
360
|
+
const execution = await awaitExit(handle.process);
|
|
361
|
+
return finalizeSession(db, handle, execution, { ...ctx, repoRoot });
|
|
118
362
|
}
|
|
119
363
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
364
|
+
function taskDependenciesReady(db, task) {
|
|
365
|
+
return (task.deps || []).every((dependencyId) => {
|
|
366
|
+
const dependency = ops.readTask(db, dependencyId);
|
|
367
|
+
return Boolean(dependency && TERMINAL_TASK_STATUSES.has(dependency.status));
|
|
124
368
|
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function runWave(db, repoRoot, wave, ctx) {
|
|
372
|
+
const taskRows = wave.tasks.map((id) => ({ id, task: ops.readTask(db, id) }));
|
|
373
|
+
const missingTaskIds = taskRows
|
|
374
|
+
.filter((entry) => !entry.task)
|
|
375
|
+
.map((entry) => entry.id);
|
|
376
|
+
if (ctx.onError) {
|
|
377
|
+
for (const id of missingTaskIds) {
|
|
378
|
+
ctx.onError(new Error(`task ${id} not found in DB; wave cannot converge`));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const tasks = taskRows.map((entry) => entry.task).filter(Boolean);
|
|
382
|
+
const runnable = tasks.filter((task) => (
|
|
383
|
+
task.status === 'pending'
|
|
384
|
+
&& !task.stale
|
|
385
|
+
&& taskDependenciesReady(db, task)
|
|
386
|
+
));
|
|
125
387
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
388
|
+
if (runnable.length > 0) {
|
|
389
|
+
ops.appendEvent(db, {
|
|
390
|
+
type: 'wave_started',
|
|
391
|
+
payload: {
|
|
392
|
+
wave_id: wave.id,
|
|
393
|
+
tasks: runnable.map((task) => task.id),
|
|
394
|
+
parallel: wave.parallel,
|
|
395
|
+
},
|
|
131
396
|
});
|
|
397
|
+
}
|
|
132
398
|
|
|
133
399
|
let results;
|
|
134
|
-
if (wave.parallel &&
|
|
135
|
-
results = await Promise.all(
|
|
400
|
+
if (wave.parallel && runnable.length > 1) {
|
|
401
|
+
results = await Promise.all(runnable.map((task) => runTask(db, repoRoot, task, wave, ctx)));
|
|
136
402
|
} else {
|
|
137
403
|
results = [];
|
|
138
|
-
for (const task of
|
|
404
|
+
for (const task of runnable) {
|
|
139
405
|
results.push(await runTask(db, repoRoot, task, wave, ctx));
|
|
140
406
|
}
|
|
141
407
|
}
|
|
142
408
|
|
|
409
|
+
const finalTasks = tasks.map((task) => ops.readTask(db, task.id)).filter(Boolean);
|
|
410
|
+
const complete = missingTaskIds.length === 0
|
|
411
|
+
&& finalTasks.length === tasks.length
|
|
412
|
+
&& finalTasks.every((task) => TERMINAL_TASK_STATUSES.has(task.status));
|
|
413
|
+
const awaiting = finalTasks
|
|
414
|
+
.filter((task) => !TERMINAL_TASK_STATUSES.has(task.status))
|
|
415
|
+
.map((task) => ({ id: task.id, status: task.status, stale: Boolean(task.stale) }))
|
|
416
|
+
.concat(missingTaskIds.map((id) => ({ id, status: 'missing', stale: false })));
|
|
143
417
|
ops.appendEvent(db, {
|
|
144
|
-
type: 'wave_completed',
|
|
418
|
+
type: complete ? 'wave_completed' : 'wave_paused',
|
|
145
419
|
payload: {
|
|
146
420
|
wave_id: wave.id,
|
|
147
|
-
|
|
148
|
-
|
|
421
|
+
executions_succeeded: results.filter((r) => r.status === 'completed').length,
|
|
422
|
+
executions_crashed: results.filter((r) => r.status === 'crashed').length,
|
|
149
423
|
deferred: results.filter((r) => r.status === 'defer' || r.status === 'block').length,
|
|
424
|
+
awaiting_workflow_gates: awaiting,
|
|
150
425
|
},
|
|
151
426
|
});
|
|
152
|
-
return results;
|
|
427
|
+
return { results, complete, awaiting };
|
|
153
428
|
}
|
|
154
429
|
|
|
155
430
|
async function runPlan({
|
|
@@ -164,32 +439,79 @@ async function runPlan({
|
|
|
164
439
|
if (!Array.isArray(runtimes) || runtimes.length === 0) {
|
|
165
440
|
throw new Error('runPlan: runtimes array required');
|
|
166
441
|
}
|
|
167
|
-
|
|
442
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
443
|
+
const error = new Error(
|
|
444
|
+
'runPlan: an explicit executable command is required; refusing to reserve empty sessions',
|
|
445
|
+
);
|
|
446
|
+
error.code = 'ORCHESTRATOR_COMMAND_REQUIRED';
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
if (!Array.isArray(commandArgs)) {
|
|
450
|
+
const error = new Error('runPlan: commandArgs must be an array');
|
|
451
|
+
error.code = 'ORCHESTRATOR_COMMAND_INVALID';
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
const resolved = validatePlanShape(resolvePlan({ plan, planPath, repoRoot }));
|
|
455
|
+
const planAuthority = assertPlanAuthority(db, repoRoot, resolved);
|
|
168
456
|
|
|
169
457
|
const ctx = {
|
|
170
458
|
runtimes, command, commandArgs, commandArgsFor, onError,
|
|
171
459
|
autoMerge, mergeBaseBranch,
|
|
172
460
|
};
|
|
173
461
|
const allResults = [];
|
|
462
|
+
let completedWaves = 0;
|
|
463
|
+
let pausedWave = null;
|
|
174
464
|
for (const wave of resolved.waves) {
|
|
175
|
-
const
|
|
176
|
-
allResults.push(...results);
|
|
465
|
+
const outcome = await runWave(db, repoRoot, wave, ctx);
|
|
466
|
+
allResults.push(...outcome.results);
|
|
467
|
+
if (!outcome.complete) {
|
|
468
|
+
pausedWave = { id: wave.id, awaiting: outcome.awaiting };
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
completedWaves += 1;
|
|
177
472
|
}
|
|
178
473
|
|
|
474
|
+
const planTaskIds = [...new Set(resolved.waves.flatMap((wave) => wave.tasks))];
|
|
475
|
+
const awaitingWorkflowGates = planTaskIds
|
|
476
|
+
.map((id) => ops.readTask(db, id) || { id, status: 'missing', stale: false })
|
|
477
|
+
.filter((task) => !TERMINAL_TASK_STATUSES.has(task.status))
|
|
478
|
+
.map((task) => ({ id: task.id, status: task.status, stale: Boolean(task.stale) }));
|
|
479
|
+
const completed = awaitingWorkflowGates.length === 0;
|
|
179
480
|
ops.appendEvent(db, {
|
|
180
|
-
type: 'plan_completed',
|
|
481
|
+
type: completed ? 'plan_completed' : 'plan_paused',
|
|
181
482
|
payload: {
|
|
182
483
|
waves: resolved.waves.length,
|
|
183
|
-
|
|
484
|
+
waves_completed: completedWaves,
|
|
485
|
+
paused_wave_id: pausedWave?.id || null,
|
|
486
|
+
executions_succeeded: allResults.filter((r) => r.status === 'completed').length,
|
|
184
487
|
crashed: allResults.filter((r) => r.status === 'crashed').length,
|
|
488
|
+
awaiting_workflow_gates: awaitingWorkflowGates,
|
|
185
489
|
},
|
|
186
490
|
});
|
|
187
491
|
|
|
188
|
-
return {
|
|
492
|
+
return {
|
|
493
|
+
status: completed ? 'completed' : 'paused',
|
|
494
|
+
waves: resolved.waves.length,
|
|
495
|
+
waves_completed: completedWaves,
|
|
496
|
+
paused_wave_id: pausedWave?.id || null,
|
|
497
|
+
plan_workflow_id: planAuthority.workflow_id,
|
|
498
|
+
awaiting_workflow_gates: awaitingWorkflowGates,
|
|
499
|
+
results: allResults,
|
|
500
|
+
};
|
|
189
501
|
}
|
|
190
502
|
|
|
191
503
|
module.exports = {
|
|
192
504
|
runPlan,
|
|
193
505
|
// exposed for targeted tests
|
|
194
|
-
_internal: {
|
|
506
|
+
_internal: {
|
|
507
|
+
runWave,
|
|
508
|
+
runTask,
|
|
509
|
+
resolvePlan,
|
|
510
|
+
validatePlanShape,
|
|
511
|
+
assertPlanAuthority,
|
|
512
|
+
awaitExit,
|
|
513
|
+
finalizeSession,
|
|
514
|
+
taskDependenciesReady,
|
|
515
|
+
recordExecutionFailure,
|
|
516
|
+
},
|
|
195
517
|
};
|