ultra-builder-pro-cli 0.20.0 → 0.21.1
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/docs/research/README.md +26 -5
- package/.ultra-template/specs/discovery.md +1 -1
- package/.ultra-template/tasks/tasks.json +1 -1
- package/CHANGELOG.md +66 -0
- package/README.md +54 -24
- package/adapters/_shared/codex-assets.cjs +69 -11
- package/adapters/_shared/interaction-contract.cjs +36 -3
- package/adapters/_shared/runtime-assets.cjs +41 -1
- package/adapters/claude.js +7 -5
- package/adapters/codex.js +1 -1
- package/adapters/kimi.js +2 -8
- package/adapters/opencode.js +30 -28
- package/bin/orchestrator.js +64 -24
- package/docs/AGENT-CONTEXT.md +74 -43
- package/docs/ARCHITECTURE.md +63 -31
- package/docs/ARTIFACT-AUTHORITY.md +234 -0
- package/docs/DECISIONS.md +10 -4
- package/docs/LEGACY-CLI-CROSSWALK.md +6 -4
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
- package/docs/ROADMAP.md +19 -5
- package/docs/RUNTIME-COMPAT-MATRIX.md +24 -13
- package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
- package/docs/WORKFLOW-LIFECYCLE.md +70 -39
- package/hooks/README.md +6 -5
- package/hooks/active_task_context.py +97 -12
- package/hooks/context_spine.py +16 -4
- package/hooks/health_check.py +5 -4
- package/hooks/pre_stop_check.py +5 -2
- package/hooks/runtime_paths.py +396 -0
- package/hooks/subagent_tracker.py +5 -2
- package/hooks/workflow_checkpoint.py +16 -3
- package/hooks/workflow_context.py +11 -3
- package/hooks/workflow_resume.py +7 -4
- package/mcp-server/breadcrumb.cjs +20 -2
- package/mcp-server/lib/archive-journal.cjs +1026 -111
- package/mcp-server/lib/archive-mutation-worker.py +365 -0
- package/mcp-server/lib/artifact-registry.cjs +1126 -0
- package/mcp-server/lib/baseline-workflow.cjs +9 -4
- package/mcp-server/lib/change-packet.cjs +818 -0
- package/mcp-server/lib/change-workflow.cjs +932 -176
- package/mcp-server/lib/context-paths.cjs +111 -0
- package/mcp-server/lib/context-spine.cjs +511 -45
- package/mcp-server/lib/decision-dialogue.cjs +345 -3
- package/mcp-server/lib/delivery-transaction.cjs +471 -0
- package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
- package/mcp-server/lib/doctor.cjs +373 -11
- package/mcp-server/lib/git-bootstrap.cjs +459 -47
- package/mcp-server/lib/init-project.cjs +190 -95
- package/mcp-server/lib/plan-publication-worker.cjs +830 -0
- package/mcp-server/lib/plan-store.cjs +700 -5
- package/mcp-server/lib/project-breadcrumb.cjs +23 -4
- package/mcp-server/lib/projector.cjs +174 -40
- package/mcp-server/lib/runtime-paths.cjs +1687 -0
- package/mcp-server/lib/safe-project-file.cjs +385 -0
- package/mcp-server/lib/spec-learning.cjs +28 -12
- package/mcp-server/lib/state-db.cjs +263 -9
- package/mcp-server/lib/state-ops.cjs +248 -49
- package/mcp-server/lib/telemetry.cjs +12 -3
- package/mcp-server/lib/workflow-state.cjs +610 -58
- package/mcp-server/server.cjs +278 -33
- package/orchestrator/daemon.cjs +2 -10
- package/orchestrator/parallel-orchestrator.cjs +314 -50
- package/orchestrator/planner/plan-builder.cjs +1 -1
- package/orchestrator/recovery.cjs +125 -7
- package/orchestrator/session-close-journal-worker.cjs +5 -0
- package/orchestrator/session-close-journal.cjs +1014 -0
- package/orchestrator/session-runner.cjs +679 -69
- package/orchestrator/worktree-manager.cjs +186 -35
- package/package.json +2 -3
- package/skills/cc-collab/SKILL.md +4 -1
- package/skills/codex-collab/SKILL.md +5 -2
- package/skills/ultra-change/SKILL.md +26 -8
- package/skills/ultra-deliver/SKILL.md +23 -11
- package/skills/ultra-dev/SKILL.md +33 -6
- package/skills/ultra-doctor/SKILL.md +6 -2
- package/skills/ultra-init/SKILL.md +4 -2
- package/skills/ultra-plan/SKILL.md +49 -16
- package/skills/ultra-research/SKILL.md +30 -27
- package/skills/ultra-review/SKILL.md +9 -5
- package/skills/ultra-status/SKILL.md +9 -4
- package/skills/ultra-test/SKILL.md +10 -5
- package/skills/ultra-think/SKILL.md +6 -2
- package/skills/ultra-think/references/decision-dialogue.md +17 -6
- package/skills/ultra-verify/SKILL.md +4 -1
- package/spec/cli-protocol.md +18 -7
- package/spec/mcp-tools.yaml +502 -44
- package/spec/schemas/change-delta.v1.schema.json +153 -0
- package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +50 -13
- package/spec/schemas/tasks.v4.5.schema.json +1 -1
- package/ultra-tools/cli.cjs +3 -2
- package/ultra-tools/commands/db.cjs +76 -25
- package/ultra-tools/commands/migrate.cjs +995 -80
- package/ultra-tools/commands/session.cjs +34 -8
- package/ultra-tools/commands/status.cjs +32 -6
- package/ultra-tools/commands/system.cjs +129 -22
- package/commands/learn.md +0 -10
- package/orchestrator/skill-miner.cjs +0 -133
- package/output-styles/coding-vibes.md +0 -67
- package/output-styles/structural-thinking.md +0 -61
- package/skills/learn/SKILL.md +0 -58
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
// Phase 8B.2 — Parallel orchestrator.
|
|
4
4
|
//
|
|
5
|
-
// Consumes
|
|
5
|
+
// Consumes the current Change-scoped plan.json (built by the plan workflow) and
|
|
6
6
|
// drives resumable per-wave session spawning. Each wave is either parallel
|
|
7
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
|
+
// • resolve the canonical Change plan (or an explicit legacy object/path)
|
|
12
12
|
// • for each ready wave: evaluate dispatch-rules per task → spawn sessions
|
|
13
13
|
// via session-runner → wait for transport exit → preserve task-gate state
|
|
14
14
|
// • stop at the first non-converged wave and resume safely on the next call
|
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
// • merge back (Phase 8B.4 layers on top of closeSession)
|
|
22
22
|
|
|
23
23
|
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
24
25
|
const { isDeepStrictEqual } = require('node:util');
|
|
25
26
|
|
|
26
27
|
const planStore = require('../mcp-server/lib/plan-store.cjs');
|
|
28
|
+
const contextSpine = require('../mcp-server/lib/context-spine.cjs');
|
|
27
29
|
const ops = require('../mcp-server/lib/state-ops.cjs');
|
|
28
30
|
const workflows = require('../mcp-server/lib/workflow-state.cjs');
|
|
29
31
|
const runner = require('./session-runner.cjs');
|
|
@@ -31,12 +33,53 @@ const dispatchRules = require('./dispatch-rules.cjs');
|
|
|
31
33
|
|
|
32
34
|
const TERMINAL_TASK_STATUSES = new Set(['completed', 'expanded']);
|
|
33
35
|
|
|
34
|
-
function resolvePlan({ plan, planPath, repoRoot }) {
|
|
36
|
+
function resolvePlan({ db, plan, planPath, repoRoot, changeId = null }) {
|
|
35
37
|
if (plan) return plan;
|
|
36
|
-
if (planPath)
|
|
37
|
-
|
|
38
|
+
if (planPath) {
|
|
39
|
+
const legacy = JSON.parse(fs.readFileSync(planPath, 'utf8'));
|
|
40
|
+
if (legacy?.change_id) {
|
|
41
|
+
throw planError(
|
|
42
|
+
'PLAN_CHANGE_SCOPED_REQUIRED',
|
|
43
|
+
'change-bound plans must be resolved from their canonical Change artifact root',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return legacy;
|
|
47
|
+
}
|
|
48
|
+
let candidates;
|
|
49
|
+
if (changeId) {
|
|
50
|
+
candidates = db.prepare(
|
|
51
|
+
`SELECT id, artifact_root, status
|
|
52
|
+
FROM changes
|
|
53
|
+
WHERE id = ? AND status IN ('active', 'blocked')`,
|
|
54
|
+
).all(changeId);
|
|
55
|
+
} else {
|
|
56
|
+
candidates = db.prepare(
|
|
57
|
+
`SELECT c.id, c.artifact_root, c.status, MAX(w.completed_at) AS planned_at
|
|
58
|
+
FROM changes c
|
|
59
|
+
JOIN workflow_runs w ON w.change_id = c.id
|
|
60
|
+
WHERE c.status IN ('active', 'blocked')
|
|
61
|
+
AND w.kind = 'plan' AND w.status = 'completed'
|
|
62
|
+
GROUP BY c.id, c.artifact_root, c.status
|
|
63
|
+
ORDER BY planned_at DESC, c.id ASC`,
|
|
64
|
+
).all();
|
|
65
|
+
}
|
|
66
|
+
if (candidates.length > 1) {
|
|
67
|
+
throw planError(
|
|
68
|
+
'PLAN_CHANGE_REQUIRED',
|
|
69
|
+
'more than one current Change has an executable plan; provide changeId',
|
|
70
|
+
{ change_ids: candidates.map((candidate) => candidate.id).sort() },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const change = candidates[0] || null;
|
|
74
|
+
const loaded = change
|
|
75
|
+
? planStore.loadChangePlanArtifact(repoRoot, change, { db, strict: true })
|
|
76
|
+
: null;
|
|
38
77
|
if (!loaded) {
|
|
39
|
-
const err = new Error(
|
|
78
|
+
const err = new Error(
|
|
79
|
+
changeId
|
|
80
|
+
? `runPlan: no current scoped plan found for change ${changeId}`
|
|
81
|
+
: 'runPlan: no current Change-scoped plan found; pass an explicit legacy planPath only for migration compatibility',
|
|
82
|
+
);
|
|
40
83
|
err.code = 'PLAN_NOT_FOUND';
|
|
41
84
|
throw err;
|
|
42
85
|
}
|
|
@@ -154,16 +197,79 @@ function assertPlanAuthority(db, repoRoot, plan) {
|
|
|
154
197
|
{ authority_code: error.code || null },
|
|
155
198
|
);
|
|
156
199
|
}
|
|
200
|
+
const currentContext = contextSpine.validateContextSnapshot(db, {
|
|
201
|
+
change_id: plan.change_id,
|
|
202
|
+
task_id: null,
|
|
203
|
+
role: 'plan',
|
|
204
|
+
gate: 'planning',
|
|
205
|
+
}, {
|
|
206
|
+
rootDir: repoRoot,
|
|
207
|
+
require_current_checkout: true,
|
|
208
|
+
});
|
|
209
|
+
if (!currentContext.snapshot
|
|
210
|
+
|| currentContext.snapshot.id !== currentPlan.plan.summary.plan_context_snapshot_id) {
|
|
211
|
+
throw planError(
|
|
212
|
+
'PLAN_STALE',
|
|
213
|
+
`execution plan for ${plan.change_id} was superseded by another planning context`,
|
|
214
|
+
{ authority_code: 'WORKFLOW_PLAN_CONTEXT_MISMATCH' },
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (currentContext.blockers.length > 0) {
|
|
218
|
+
throw planError(
|
|
219
|
+
'PLAN_STALE',
|
|
220
|
+
`execution plan for ${plan.change_id} has stale planning context`,
|
|
221
|
+
{
|
|
222
|
+
authority_code: 'WORKFLOW_PLAN_CONTEXT_STALE',
|
|
223
|
+
blockers: currentContext.blockers,
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
}
|
|
157
227
|
const expected = planStore.buildPlan(
|
|
158
228
|
currentPlan.tasks,
|
|
159
229
|
{ changeId: plan.change_id },
|
|
160
230
|
);
|
|
161
|
-
|
|
231
|
+
const { context, ...planCore } = plan;
|
|
232
|
+
if (!isDeepStrictEqual(planCore, expected)) {
|
|
162
233
|
throw planError(
|
|
163
234
|
'PLAN_STALE',
|
|
164
235
|
`execution plan for ${plan.change_id} does not match the current DB task graph`,
|
|
165
236
|
);
|
|
166
237
|
}
|
|
238
|
+
if (!context
|
|
239
|
+
|| context.snapshot_id !== currentPlan.plan.summary.plan_context_snapshot_id
|
|
240
|
+
|| context.manifest_path !== currentPlan.plan.summary.plan_context_path
|
|
241
|
+
|| context.manifest_digest !== currentPlan.plan.summary.plan_context_digest) {
|
|
242
|
+
throw planError(
|
|
243
|
+
'PLAN_STALE',
|
|
244
|
+
`execution plan for ${plan.change_id} does not match its planning context`,
|
|
245
|
+
{ authority_code: 'WORKFLOW_PLAN_CONTEXT_MISMATCH' },
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const change = db.prepare(
|
|
249
|
+
'SELECT id, artifact_root FROM changes WHERE id = ?',
|
|
250
|
+
).get(plan.change_id);
|
|
251
|
+
const canonicalPaths = planStore.changePlanPaths(repoRoot, change);
|
|
252
|
+
const expectedPlanPath = path.relative(repoRoot, canonicalPaths.json);
|
|
253
|
+
const expectedPlanMdPath = path.relative(repoRoot, canonicalPaths.md);
|
|
254
|
+
if (currentPlan.plan.summary.plan_path !== expectedPlanPath
|
|
255
|
+
|| currentPlan.plan.summary.plan_md_path !== expectedPlanMdPath) {
|
|
256
|
+
throw planError(
|
|
257
|
+
'PLAN_STALE',
|
|
258
|
+
`completed plan workflow for ${plan.change_id} is not bound to its canonical artifacts`,
|
|
259
|
+
{ authority_code: 'WORKFLOW_PLAN_ARTIFACT_INVALID' },
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const canonicalPlan = planStore.loadChangePlanArtifact(
|
|
263
|
+
repoRoot,
|
|
264
|
+
change,
|
|
265
|
+
{ db, strict: true },
|
|
266
|
+
);
|
|
267
|
+
if (!canonicalPlan || !isDeepStrictEqual(canonicalPlan, plan)) {
|
|
268
|
+
throw planError(
|
|
269
|
+
'PLAN_STALE',
|
|
270
|
+
`execution plan for ${plan.change_id} differs from its canonical Change artifact`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
167
273
|
return { mode: 'current-change', workflow_id: currentPlan.plan.id };
|
|
168
274
|
}
|
|
169
275
|
|
|
@@ -202,6 +308,27 @@ function failureReason(execution) {
|
|
|
202
308
|
+ `${execution.signal ? ` (${execution.signal})` : ''}`;
|
|
203
309
|
}
|
|
204
310
|
|
|
311
|
+
function authorityBlockedResult(error, taskId, {
|
|
312
|
+
sid = null,
|
|
313
|
+
reservationFailed = false,
|
|
314
|
+
} = {}) {
|
|
315
|
+
const gate = runner.findExpectedExecutionGate(error);
|
|
316
|
+
if (!gate) return null;
|
|
317
|
+
return {
|
|
318
|
+
sid,
|
|
319
|
+
task_id: taskId,
|
|
320
|
+
code: -4,
|
|
321
|
+
status: 'authority_blocked',
|
|
322
|
+
retryable: true,
|
|
323
|
+
authority_code: gate.code,
|
|
324
|
+
error: error.message,
|
|
325
|
+
...(Array.isArray(gate.details?.blockers)
|
|
326
|
+
? { blockers: gate.details.blockers }
|
|
327
|
+
: {}),
|
|
328
|
+
...(reservationFailed ? { _reservationFailed: true } : {}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
205
332
|
function recordExecutionFailure(db, taskId, {
|
|
206
333
|
sessionId = null,
|
|
207
334
|
reason,
|
|
@@ -290,7 +417,7 @@ function finalizeSession(db, handle, execution, ctx = {}) {
|
|
|
290
417
|
};
|
|
291
418
|
}
|
|
292
419
|
|
|
293
|
-
|
|
420
|
+
function prepareTaskDispatch(task, wave, ctx) {
|
|
294
421
|
const { runtimes, command, commandArgs, commandArgsFor, onError } = ctx;
|
|
295
422
|
const decision = dispatchRules.evaluate({
|
|
296
423
|
task,
|
|
@@ -301,64 +428,119 @@ async function runTask(db, repoRoot, task, wave, ctx) {
|
|
|
301
428
|
});
|
|
302
429
|
if (decision.action !== 'spawn_agent') {
|
|
303
430
|
if (onError) onError(new Error(`task ${task.id}: deferred/blocked by rule ${decision.rule_id}`));
|
|
304
|
-
return {
|
|
431
|
+
return {
|
|
432
|
+
task,
|
|
433
|
+
result: { sid: null, task_id: task.id, code: -2, status: decision.action },
|
|
434
|
+
};
|
|
305
435
|
}
|
|
306
436
|
|
|
307
437
|
const args = commandArgsFor ? commandArgsFor(task) : commandArgs;
|
|
438
|
+
if (!Array.isArray(args)) {
|
|
439
|
+
throw planError(
|
|
440
|
+
'ORCHESTRATOR_COMMAND_INVALID',
|
|
441
|
+
`task ${task.id} command arguments must be an array`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return { task, decision, command, args };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function reservePreparedTask(db, repoRoot, prepared, ctx) {
|
|
448
|
+
if (prepared.result) return prepared.result;
|
|
449
|
+
const {
|
|
450
|
+
task, decision, command, args,
|
|
451
|
+
} = prepared;
|
|
452
|
+
const { onError } = ctx;
|
|
308
453
|
let handle;
|
|
309
454
|
try {
|
|
310
455
|
handle = runner.spawnSession({
|
|
311
456
|
db, repoRoot,
|
|
312
457
|
task_id: task.id,
|
|
313
458
|
runtime: decision.runtime,
|
|
314
|
-
command
|
|
459
|
+
command: ctx.deferStart ? null : command,
|
|
460
|
+
args: ctx.deferStart ? [] : args,
|
|
461
|
+
mark_task_started: true,
|
|
315
462
|
});
|
|
316
463
|
handle.task_id = task.id;
|
|
317
464
|
} catch (err) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
code: -4,
|
|
323
|
-
status: 'authority_blocked',
|
|
324
|
-
authority_code: err.code,
|
|
325
|
-
error: err.message,
|
|
326
|
-
};
|
|
327
|
-
}
|
|
465
|
+
const authorityBlocked = authorityBlockedResult(err, task.id, {
|
|
466
|
+
reservationFailed: true,
|
|
467
|
+
});
|
|
468
|
+
if (authorityBlocked) return authorityBlocked;
|
|
328
469
|
if (onError) onError(err);
|
|
329
470
|
recordExecutionFailure(db, task.id, {
|
|
330
471
|
reason: `worker session could not be created: ${err.message}`,
|
|
331
472
|
});
|
|
332
|
-
return {
|
|
473
|
+
return {
|
|
474
|
+
sid: null,
|
|
475
|
+
task_id: task.id,
|
|
476
|
+
code: -3,
|
|
477
|
+
status: 'spawn_failed',
|
|
478
|
+
error: err.message,
|
|
479
|
+
_reservationFailed: true,
|
|
480
|
+
};
|
|
333
481
|
}
|
|
334
482
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
483
|
+
return {
|
|
484
|
+
_pendingStart: true,
|
|
485
|
+
handle,
|
|
486
|
+
command,
|
|
487
|
+
args,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function runTask(db, repoRoot, task, wave, ctx) {
|
|
492
|
+
const prepared = ctx.prepared || prepareTaskDispatch(task, wave, ctx);
|
|
493
|
+
const reservation = reservePreparedTask(db, repoRoot, prepared, ctx);
|
|
494
|
+
if (!reservation?._pendingStart) return reservation;
|
|
495
|
+
const { handle } = reservation;
|
|
496
|
+
if (!handle.process) {
|
|
497
|
+
handle.process = runner.startSessionProcess({
|
|
498
|
+
db,
|
|
499
|
+
repoRoot,
|
|
500
|
+
sid: handle.sid,
|
|
501
|
+
command: reservation.command,
|
|
502
|
+
args: reservation.args,
|
|
503
|
+
});
|
|
504
|
+
handle.pid = handle.process.pid;
|
|
341
505
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
506
|
+
const execution = await awaitExit(handle.process);
|
|
507
|
+
if (ctx.deferFinalize) {
|
|
508
|
+
return { _pendingFinalize: true, handle, execution };
|
|
509
|
+
}
|
|
510
|
+
return finalizeSession(db, handle, execution, { ...ctx, repoRoot });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function unwindReservations(db, repoRoot, reservations, ctx) {
|
|
514
|
+
const errors = [];
|
|
515
|
+
for (const reservation of [...reservations].reverse()) {
|
|
516
|
+
if (!reservation?._pendingStart) continue;
|
|
517
|
+
const { handle } = reservation;
|
|
518
|
+
try {
|
|
519
|
+
runner.closeSession(
|
|
520
|
+
{ db, repoRoot, sid: handle.sid },
|
|
521
|
+
{ status: 'crashed', remove_worktree: true },
|
|
356
522
|
);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
errors.push(error);
|
|
525
|
+
if (ctx.onError) ctx.onError(error);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const task = ops.readTask(db, handle.task_id);
|
|
529
|
+
if (task?.status === 'in_progress') {
|
|
530
|
+
try {
|
|
531
|
+
ops.updateTaskStatus(db, handle.task_id, 'pending');
|
|
532
|
+
} catch (error) {
|
|
533
|
+
errors.push(error);
|
|
534
|
+
if (ctx.onError) ctx.onError(error);
|
|
535
|
+
}
|
|
357
536
|
}
|
|
358
537
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
538
|
+
if (errors.length > 0) {
|
|
539
|
+
throw planError(
|
|
540
|
+
'WAVE_RESERVATION_ROLLBACK_FAILED',
|
|
541
|
+
`parallel wave reservation rollback failed: ${errors.map((error) => error.message).join('; ')}`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
362
544
|
}
|
|
363
545
|
|
|
364
546
|
function taskDependenciesReady(db, task) {
|
|
@@ -384,8 +566,11 @@ async function runWave(db, repoRoot, wave, ctx) {
|
|
|
384
566
|
&& !task.stale
|
|
385
567
|
&& taskDependenciesReady(db, task)
|
|
386
568
|
));
|
|
569
|
+
// Pure preparation must finish for the whole wave before any reservation
|
|
570
|
+
// mutates tasks, sessions, or worktrees.
|
|
571
|
+
const prepared = runnable.map((task) => prepareTaskDispatch(task, wave, ctx));
|
|
387
572
|
|
|
388
|
-
if (
|
|
573
|
+
if (prepared.length > 0) {
|
|
389
574
|
ops.appendEvent(db, {
|
|
390
575
|
type: 'wave_started',
|
|
391
576
|
payload: {
|
|
@@ -398,11 +583,85 @@ async function runWave(db, repoRoot, wave, ctx) {
|
|
|
398
583
|
|
|
399
584
|
let results;
|
|
400
585
|
if (wave.parallel && runnable.length > 1) {
|
|
401
|
-
|
|
586
|
+
const reservations = [];
|
|
587
|
+
let reservationFailure = null;
|
|
588
|
+
for (const item of prepared) {
|
|
589
|
+
const reservation = reservePreparedTask(db, repoRoot, item, {
|
|
590
|
+
...ctx,
|
|
591
|
+
deferStart: true,
|
|
592
|
+
deferFinalize: true,
|
|
593
|
+
});
|
|
594
|
+
reservations.push(reservation);
|
|
595
|
+
if (reservation?._reservationFailed) {
|
|
596
|
+
reservationFailure = reservation;
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (reservationFailure) {
|
|
601
|
+
unwindReservations(db, repoRoot, reservations, ctx);
|
|
602
|
+
results = reservations.map((reservation) => (
|
|
603
|
+
reservation?._pendingStart
|
|
604
|
+
? {
|
|
605
|
+
sid: reservation.handle.sid,
|
|
606
|
+
task_id: reservation.handle.task_id,
|
|
607
|
+
code: -5,
|
|
608
|
+
status: 'reservation_cancelled',
|
|
609
|
+
}
|
|
610
|
+
: reservation
|
|
611
|
+
));
|
|
612
|
+
} else {
|
|
613
|
+
const executions = await Promise.all(reservations.map(async (reservation) => {
|
|
614
|
+
if (!reservation?._pendingStart) return reservation;
|
|
615
|
+
const { handle } = reservation;
|
|
616
|
+
try {
|
|
617
|
+
handle.process = runner.startSessionProcess({
|
|
618
|
+
db,
|
|
619
|
+
repoRoot,
|
|
620
|
+
sid: handle.sid,
|
|
621
|
+
command: reservation.command,
|
|
622
|
+
args: reservation.args,
|
|
623
|
+
});
|
|
624
|
+
handle.pid = handle.process.pid;
|
|
625
|
+
const execution = await awaitExit(handle.process);
|
|
626
|
+
return { _pendingFinalize: true, handle, execution };
|
|
627
|
+
} catch (error) {
|
|
628
|
+
const authorityBlocked = authorityBlockedResult(
|
|
629
|
+
error,
|
|
630
|
+
handle.task_id,
|
|
631
|
+
{ sid: handle.sid },
|
|
632
|
+
);
|
|
633
|
+
if (authorityBlocked) {
|
|
634
|
+
unwindReservations(db, repoRoot, [reservation], ctx);
|
|
635
|
+
return authorityBlocked;
|
|
636
|
+
}
|
|
637
|
+
if (ctx.onError) ctx.onError(error);
|
|
638
|
+
recordExecutionFailure(db, handle.task_id, {
|
|
639
|
+
sessionId: handle.sid,
|
|
640
|
+
reason: `worker process could not be started: ${error.message}`,
|
|
641
|
+
});
|
|
642
|
+
return finalizeSession(
|
|
643
|
+
db,
|
|
644
|
+
handle,
|
|
645
|
+
{ code: -1, signal: null, error },
|
|
646
|
+
{ ...ctx, repoRoot },
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}));
|
|
650
|
+
// Parallel execution completes in parallel; settlement is serialized
|
|
651
|
+
// only after every worker exit has been observed.
|
|
652
|
+
results = executions.map((result) => (
|
|
653
|
+
result?._pendingFinalize
|
|
654
|
+
? finalizeSession(db, result.handle, result.execution, { ...ctx, repoRoot })
|
|
655
|
+
: result
|
|
656
|
+
));
|
|
657
|
+
}
|
|
402
658
|
} else {
|
|
403
659
|
results = [];
|
|
404
|
-
for (const
|
|
405
|
-
results.push(await runTask(db, repoRoot, task, wave,
|
|
660
|
+
for (const item of prepared) {
|
|
661
|
+
results.push(await runTask(db, repoRoot, item.task, wave, {
|
|
662
|
+
...ctx,
|
|
663
|
+
prepared: item,
|
|
664
|
+
}));
|
|
406
665
|
}
|
|
407
666
|
}
|
|
408
667
|
|
|
@@ -428,7 +687,7 @@ async function runWave(db, repoRoot, wave, ctx) {
|
|
|
428
687
|
}
|
|
429
688
|
|
|
430
689
|
async function runPlan({
|
|
431
|
-
db, repoRoot, plan, planPath,
|
|
690
|
+
db, repoRoot, plan, planPath, changeId = null,
|
|
432
691
|
runtimes,
|
|
433
692
|
command = null, commandArgs = [], commandArgsFor = null,
|
|
434
693
|
autoMerge = false, mergeBaseBranch = 'main',
|
|
@@ -451,7 +710,9 @@ async function runPlan({
|
|
|
451
710
|
error.code = 'ORCHESTRATOR_COMMAND_INVALID';
|
|
452
711
|
throw error;
|
|
453
712
|
}
|
|
454
|
-
const resolved = validatePlanShape(resolvePlan({
|
|
713
|
+
const resolved = validatePlanShape(resolvePlan({
|
|
714
|
+
db, plan, planPath, repoRoot, changeId,
|
|
715
|
+
}));
|
|
455
716
|
const planAuthority = assertPlanAuthority(db, repoRoot, resolved);
|
|
456
717
|
|
|
457
718
|
const ctx = {
|
|
@@ -513,5 +774,8 @@ module.exports = {
|
|
|
513
774
|
finalizeSession,
|
|
514
775
|
taskDependenciesReady,
|
|
515
776
|
recordExecutionFailure,
|
|
777
|
+
prepareTaskDispatch,
|
|
778
|
+
reservePreparedTask,
|
|
779
|
+
unwindReservations,
|
|
516
780
|
},
|
|
517
781
|
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// estimated_cost_usd, estimated_duration_min, cycles }.
|
|
7
7
|
// Composes topo.computeWaves + pricing.computeCost with a file-overlap
|
|
8
8
|
// conflict detector. Downstream 8A.4b writes this object to
|
|
9
|
-
//
|
|
9
|
+
// the owning Change's deterministic plan.json and plan.md artifacts.
|
|
10
10
|
|
|
11
11
|
const { computeWaves } = require('../../mcp-server/lib/topo.cjs');
|
|
12
12
|
const { computeCost } = require('../../mcp-server/lib/pricing.cjs');
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// whether the task should be tripped.
|
|
15
15
|
|
|
16
16
|
const ops = require('../mcp-server/lib/state-ops.cjs');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const closeJournal = require('./session-close-journal.cjs');
|
|
20
|
+
const runner = require('./session-runner.cjs');
|
|
17
21
|
|
|
18
22
|
function isPidAlive(pid) {
|
|
19
23
|
if (!pid || pid <= 0) return false;
|
|
@@ -33,11 +37,115 @@ function listOrphanSessions(db) {
|
|
|
33
37
|
return db.prepare("SELECT * FROM sessions WHERE status = 'orphan'").all();
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
function
|
|
40
|
+
function reconcileCloseWorktree(repoRoot, intent) {
|
|
41
|
+
runner._internal.assertManagedWorktreePath(
|
|
42
|
+
repoRoot,
|
|
43
|
+
intent.worktree_path,
|
|
44
|
+
{ sid: intent.sid, validateRuntimeTree: false },
|
|
45
|
+
);
|
|
46
|
+
if (fs.existsSync(intent.worktree_path)) {
|
|
47
|
+
const blocker = runner._internal.worktreeRemovalBlocker(
|
|
48
|
+
repoRoot,
|
|
49
|
+
intent.worktree_path,
|
|
50
|
+
);
|
|
51
|
+
if (blocker) throw new Error(blocker);
|
|
52
|
+
}
|
|
53
|
+
runner._internal.reconcileRemovedWorktree(
|
|
54
|
+
repoRoot,
|
|
55
|
+
intent.worktree_path,
|
|
56
|
+
{ sid: intent.sid },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function closeIntentMatchesSession(intent, session) {
|
|
61
|
+
return Boolean(
|
|
62
|
+
intent
|
|
63
|
+
&& session
|
|
64
|
+
&& intent.sid === session.sid
|
|
65
|
+
&& intent.task_id === session.task_id
|
|
66
|
+
&& typeof intent.worktree_path === 'string'
|
|
67
|
+
&& typeof session.worktree_path === 'string'
|
|
68
|
+
&& path.resolve(intent.worktree_path) === path.resolve(session.worktree_path),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function recoverCloseIntents(db, repoRoot) {
|
|
73
|
+
if (!repoRoot) return { recovered: [], pending: [] };
|
|
74
|
+
const recovered = [];
|
|
75
|
+
const pending = [];
|
|
76
|
+
for (const intent of closeJournal.list(repoRoot)) {
|
|
77
|
+
const session = ops.readSession(db, intent.sid);
|
|
78
|
+
if (!session) {
|
|
79
|
+
pending.push({ sid: intent.sid, reason: 'session_missing' });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!closeIntentMatchesSession(intent, session)) {
|
|
83
|
+
pending.push({ sid: intent.sid, reason: 'journal_identity_mismatch' });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (session.status === intent.requested_status) {
|
|
87
|
+
try {
|
|
88
|
+
reconcileCloseWorktree(repoRoot, intent);
|
|
89
|
+
closeJournal.discard(repoRoot, intent.sid);
|
|
90
|
+
recovered.push({
|
|
91
|
+
sid: intent.sid,
|
|
92
|
+
status: session.status,
|
|
93
|
+
reconciled: 'already_terminal',
|
|
94
|
+
});
|
|
95
|
+
} catch (error) {
|
|
96
|
+
closeJournal.update(repoRoot, intent.sid, {
|
|
97
|
+
phase: 'recovery_failed',
|
|
98
|
+
error: error.message,
|
|
99
|
+
});
|
|
100
|
+
pending.push({ sid: intent.sid, reason: error.message });
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (['completed', 'crashed'].includes(session.status)) {
|
|
105
|
+
pending.push({
|
|
106
|
+
sid: intent.sid,
|
|
107
|
+
reason: `terminal_status_conflict:${session.status}`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (isPidAlive(session.pid)) {
|
|
112
|
+
pending.push({ sid: intent.sid, reason: 'worker_still_alive' });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
reconcileCloseWorktree(repoRoot, intent);
|
|
117
|
+
closeJournal.update(repoRoot, intent.sid, {
|
|
118
|
+
phase: 'worktree_removed',
|
|
119
|
+
error: null,
|
|
120
|
+
});
|
|
121
|
+
ops.updateSession(db, intent.sid, { status: intent.requested_status });
|
|
122
|
+
closeJournal.discard(repoRoot, intent.sid);
|
|
123
|
+
recovered.push({
|
|
124
|
+
sid: intent.sid,
|
|
125
|
+
status: intent.requested_status,
|
|
126
|
+
reconciled: 'close_intent',
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
closeJournal.update(repoRoot, intent.sid, {
|
|
130
|
+
phase: 'recovery_failed',
|
|
131
|
+
error: error.message,
|
|
132
|
+
});
|
|
133
|
+
pending.push({ sid: intent.sid, reason: error.message });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { recovered, pending };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function recoverOnBoot(db, { graceSeconds = 300, repoRoot = null } = {}) {
|
|
37
140
|
if (!db) throw new Error('recoverOnBoot: db handle required');
|
|
38
141
|
|
|
142
|
+
const closeRecovery = recoverCloseIntents(db, repoRoot);
|
|
143
|
+
|
|
39
144
|
// Step 1 — promote stale running sessions to orphan (Phase 4.5 logic).
|
|
40
|
-
ops.reapOrphanSessions(db, {
|
|
145
|
+
ops.reapOrphanSessions(db, {
|
|
146
|
+
graceSeconds,
|
|
147
|
+
exclude_session_ids: closeRecovery.pending.map((entry) => entry.sid),
|
|
148
|
+
});
|
|
41
149
|
|
|
42
150
|
// Step 2 — for every orphan, decide crashed vs. keep-watching.
|
|
43
151
|
const orphans = listOrphanSessions(db);
|
|
@@ -46,11 +154,10 @@ function recoverOnBoot(db, { graceSeconds = 300 } = {}) {
|
|
|
46
154
|
const alive = isPidAlive(s.pid);
|
|
47
155
|
if (alive) continue;
|
|
48
156
|
|
|
49
|
-
ops.
|
|
50
|
-
ops.recordTaskFailure(db, s.task_id, {
|
|
157
|
+
const resolution = ops.crashOrphanSession(db, s.sid, {
|
|
51
158
|
reason: 'session_crashed_on_boot',
|
|
52
|
-
session_id: s.sid,
|
|
53
159
|
});
|
|
160
|
+
if (!resolution.changed) continue;
|
|
54
161
|
recovered.push({
|
|
55
162
|
sid: s.sid,
|
|
56
163
|
task_id: s.task_id,
|
|
@@ -60,11 +167,22 @@ function recoverOnBoot(db, { graceSeconds = 300 } = {}) {
|
|
|
60
167
|
});
|
|
61
168
|
}
|
|
62
169
|
|
|
63
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
recovered,
|
|
172
|
+
count: recovered.length,
|
|
173
|
+
closed: closeRecovery.recovered,
|
|
174
|
+
close_pending: closeRecovery.pending,
|
|
175
|
+
};
|
|
64
176
|
}
|
|
65
177
|
|
|
66
178
|
module.exports = {
|
|
67
179
|
recoverOnBoot,
|
|
68
180
|
// exposed for tests
|
|
69
|
-
_internal: {
|
|
181
|
+
_internal: {
|
|
182
|
+
isPidAlive,
|
|
183
|
+
listOrphanSessions,
|
|
184
|
+
closeIntentMatchesSession,
|
|
185
|
+
reconcileCloseWorktree,
|
|
186
|
+
recoverCloseIntents,
|
|
187
|
+
},
|
|
70
188
|
};
|