ultra-builder-pro-cli 0.19.1 → 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.
Files changed (103) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +90 -1
  5. package/README.md +62 -29
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +60 -5
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/commands/ultra-research.md +1 -1
  15. package/docs/AGENT-CONTEXT.md +79 -45
  16. package/docs/ARCHITECTURE.md +75 -38
  17. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  18. package/docs/DECISIONS.md +20 -10
  19. package/docs/LEGACY-CLI-CROSSWALK.md +15 -11
  20. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  21. package/docs/ROADMAP.md +21 -6
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +30 -13
  23. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  24. package/docs/WORKFLOW-LIFECYCLE.md +105 -54
  25. package/hooks/README.md +6 -5
  26. package/hooks/active_task_context.py +97 -12
  27. package/hooks/context_spine.py +16 -4
  28. package/hooks/health_check.py +5 -4
  29. package/hooks/pre_stop_check.py +5 -2
  30. package/hooks/runtime_paths.py +396 -0
  31. package/hooks/subagent_tracker.py +5 -2
  32. package/hooks/workflow_checkpoint.py +16 -3
  33. package/hooks/workflow_context.py +11 -3
  34. package/hooks/workflow_resume.py +7 -4
  35. package/mcp-server/breadcrumb.cjs +20 -2
  36. package/mcp-server/lib/archive-journal.cjs +1026 -111
  37. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  38. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  39. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  40. package/mcp-server/lib/change-packet.cjs +818 -0
  41. package/mcp-server/lib/change-workflow.cjs +932 -176
  42. package/mcp-server/lib/context-paths.cjs +111 -0
  43. package/mcp-server/lib/context-spine.cjs +511 -45
  44. package/mcp-server/lib/decision-dialogue.cjs +427 -12
  45. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  46. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  47. package/mcp-server/lib/doctor.cjs +375 -12
  48. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  49. package/mcp-server/lib/init-project.cjs +190 -95
  50. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  51. package/mcp-server/lib/plan-store.cjs +700 -5
  52. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  53. package/mcp-server/lib/projector.cjs +174 -40
  54. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  55. package/mcp-server/lib/safe-project-file.cjs +385 -0
  56. package/mcp-server/lib/spec-learning.cjs +28 -12
  57. package/mcp-server/lib/state-db.cjs +397 -13
  58. package/mcp-server/lib/state-ops.cjs +248 -49
  59. package/mcp-server/lib/telemetry.cjs +12 -3
  60. package/mcp-server/lib/workflow-state.cjs +614 -62
  61. package/mcp-server/server.cjs +282 -33
  62. package/orchestrator/daemon.cjs +2 -10
  63. package/orchestrator/parallel-orchestrator.cjs +314 -50
  64. package/orchestrator/planner/plan-builder.cjs +1 -1
  65. package/orchestrator/recovery.cjs +125 -7
  66. package/orchestrator/session-close-journal-worker.cjs +5 -0
  67. package/orchestrator/session-close-journal.cjs +1014 -0
  68. package/orchestrator/session-runner.cjs +679 -69
  69. package/orchestrator/worktree-manager.cjs +186 -35
  70. package/package.json +2 -3
  71. package/skills/cc-collab/SKILL.md +4 -1
  72. package/skills/codex-collab/SKILL.md +5 -2
  73. package/skills/ultra-change/SKILL.md +38 -13
  74. package/skills/ultra-deliver/SKILL.md +23 -11
  75. package/skills/ultra-dev/SKILL.md +33 -6
  76. package/skills/ultra-doctor/SKILL.md +6 -2
  77. package/skills/ultra-init/SKILL.md +4 -2
  78. package/skills/ultra-plan/SKILL.md +68 -18
  79. package/skills/ultra-research/SKILL.md +45 -32
  80. package/skills/ultra-review/SKILL.md +9 -5
  81. package/skills/ultra-status/SKILL.md +9 -4
  82. package/skills/ultra-test/SKILL.md +15 -7
  83. package/skills/ultra-think/SKILL.md +6 -0
  84. package/skills/ultra-think/references/decision-dialogue.md +61 -14
  85. package/skills/ultra-verify/SKILL.md +4 -1
  86. package/spec/cli-protocol.md +19 -7
  87. package/spec/mcp-tools.yaml +531 -46
  88. package/spec/schemas/change-delta.v1.schema.json +153 -0
  89. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  90. package/spec/schemas/mcp-tools.schema.json +1 -1
  91. package/spec/schemas/state-db.sql +57 -17
  92. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  93. package/ultra-tools/cli.cjs +3 -2
  94. package/ultra-tools/commands/db.cjs +76 -25
  95. package/ultra-tools/commands/migrate.cjs +995 -80
  96. package/ultra-tools/commands/session.cjs +34 -8
  97. package/ultra-tools/commands/status.cjs +34 -7
  98. package/ultra-tools/commands/system.cjs +129 -22
  99. package/commands/learn.md +0 -10
  100. package/orchestrator/skill-miner.cjs +0 -133
  101. package/output-styles/coding-vibes.md +0 -67
  102. package/output-styles/structural-thinking.md +0 -61
  103. package/skills/learn/SKILL.md +0 -58
@@ -2,13 +2,13 @@
2
2
 
3
3
  // Phase 8B.2 — Parallel orchestrator.
4
4
  //
5
- // Consumes an execution-plan.json (built by Phase 8A.4a plan-builder) and
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
- // • load plan (object or path)
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) return JSON.parse(fs.readFileSync(planPath, 'utf8'));
37
- const loaded = planStore.loadPlanArtifact(repoRoot);
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('runPlan: no plan found (pass plan or planPath, or pre-write artifact)');
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
- if (!isDeepStrictEqual(plan, expected)) {
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
- async function runTask(db, repoRoot, task, wave, ctx) {
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 { sid: null, task_id: task.id, code: -2, status: decision.action };
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, args,
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
- 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
- }
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 { sid: null, task_id: task.id, code: -3, status: 'spawn_failed' };
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
- // 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
- }
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
- catch (err) {
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 },
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
- const execution = await awaitExit(handle.process);
361
- return finalizeSession(db, handle, execution, { ...ctx, repoRoot });
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 (runnable.length > 0) {
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
- results = await Promise.all(runnable.map((task) => runTask(db, repoRoot, task, wave, ctx)));
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 task of runnable) {
405
- results.push(await runTask(db, repoRoot, task, wave, ctx));
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({ plan, planPath, repoRoot }));
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
- // .ultra/execution-plan.json.
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 recoverOnBoot(db, { graceSeconds = 300 } = {}) {
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, { graceSeconds });
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.updateSession(db, s.sid, { status: 'crashed' });
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 { recovered, count: recovered.length };
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: { isPidAlive, listOrphanSessions },
181
+ _internal: {
182
+ isPidAlive,
183
+ listOrphanSessions,
184
+ closeIntentMatchesSession,
185
+ reconcileCloseWorktree,
186
+ recoverCloseIntents,
187
+ },
70
188
  };
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const journal = require('./session-close-journal.cjs');
4
+
5
+ journal._internal.helperMain();