@deksden-com/dd-flow-cli 0.9.0-beta.74 → 0.9.0-beta.79

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 (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/command-inputs.js +300 -0
  4. package/dist/cli/help.js +1 -1
  5. package/dist/cli/hook-ingress.js +82 -0
  6. package/dist/cli/input-preparation.js +115 -0
  7. package/dist/cli/run-cli.js +1015 -423
  8. package/dist/cli.js +6 -2
  9. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  10. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  11. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  12. package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
  13. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  15. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  16. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  17. package/dist/services/canon.js +11 -8
  18. package/dist/services/cleanup.js +49 -8
  19. package/dist/services/cli-operation-classifier.js +12 -24
  20. package/dist/services/codex-hook-delivery.js +28 -0
  21. package/dist/services/config.js +11 -8
  22. package/dist/services/controller-fanout.js +7 -11
  23. package/dist/services/dashboard.js +94 -31
  24. package/dist/services/engines.js +75 -61
  25. package/dist/services/eval-snapshots.js +138 -38
  26. package/dist/services/execution-policy.js +5 -12
  27. package/dist/services/hooks.js +226 -203
  28. package/dist/services/lanes.js +60 -52
  29. package/dist/services/lifecycle-command.js +13 -1
  30. package/dist/services/lifecycle-invocations.js +285 -94
  31. package/dist/services/managed-processes.js +158 -18
  32. package/dist/services/merge-queue.js +173 -98
  33. package/dist/services/migrations.js +13 -8
  34. package/dist/services/plan-runtime.js +19 -8
  35. package/dist/services/plans.js +28 -25
  36. package/dist/services/projects.js +11 -2
  37. package/dist/services/prompts.js +23 -16
  38. package/dist/services/protocols.js +54 -19
  39. package/dist/services/recovery-observation-budget.js +1 -1
  40. package/dist/services/run-controller-adapter.js +31 -20
  41. package/dist/services/run-controller-state.js +5 -0
  42. package/dist/services/run-controller.js +102 -56
  43. package/dist/services/run-fork.js +36 -22
  44. package/dist/services/run-recovery.js +32 -17
  45. package/dist/services/runs.js +110 -38
  46. package/dist/services/runtime-budget.js +120 -48
  47. package/dist/services/runtime-scope-capture.js +2 -2
  48. package/dist/services/runtime-scope-control.js +2 -2
  49. package/dist/services/runtime-scope-resume.js +34 -12
  50. package/dist/services/runtime-scope-worker.js +49 -16
  51. package/dist/services/runtime-service.js +8 -7
  52. package/dist/services/schema-validation.js +9 -7
  53. package/dist/services/sessions.js +35 -69
  54. package/dist/services/stage-blocker.js +17 -7
  55. package/dist/services/stage-context.js +42 -16
  56. package/dist/services/stage-lifecycle.js +82 -100
  57. package/dist/services/stage-pause.js +54 -24
  58. package/dist/services/vnext-code-review.js +82 -32
  59. package/dist/services/vnext-code.js +78 -45
  60. package/dist/services/vnext-fanout.js +13 -12
  61. package/dist/services/vnext-merge.js +68 -35
  62. package/dist/services/vnext-plan-review.js +85 -42
  63. package/dist/services/vnext-plan.js +91 -70
  64. package/dist/services/vnext-protocolize.js +116 -50
  65. package/dist/services/vnext-specify.js +70 -57
  66. package/dist/services/work-registry.js +207 -94
  67. package/dist/services/worktrees.js +85 -35
  68. package/dist/storage/database.js +104 -10
  69. package/dist/storage/paths.js +17 -4
  70. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { appendAudit } from "./audit.js";
4
4
  import { requireProjectByRoot } from "./projects.js";
5
5
  import { persistProtocolState, protocolRunDiagnostics, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
- import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner, waitAcquireLaneLock } from "./lanes.js";
7
+ import { ensureLaneWorkspace, heartbeatLaneLock, laneRecord, prepareLaneLock, prepareLaneWaitTiming, prepareLaneWorkspace, releaseLaneLock, requireLaneLockOwner, waitAcquireLaneLock } from "./lanes.js";
8
8
  import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
9
9
  import { getProtocolBranchContext, requireClaimableBranchBundle } from "./branch-context.js";
10
10
  const mergeLockTtlSeconds = 300;
@@ -18,7 +18,10 @@ export function getMergeQueueStatus(context, input) {
18
18
  export function getMergeBundleStatus(context, input) {
19
19
  return getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath });
20
20
  }
21
- export function claimNextMergeJob(context, input) {
21
+ export function prepareMergeClaim(context, input) {
22
+ requireMergeText(input.workerId, "worker-id");
23
+ if (input.protocolId !== undefined)
24
+ requireMergeText(input.protocolId, "protocol-id");
22
25
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
23
26
  const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
24
27
  if (stopState.stopped) {
@@ -34,14 +37,22 @@ export function claimNextMergeJob(context, input) {
34
37
  workerId: input.workerId,
35
38
  workspacePath: input.workspacePath
36
39
  });
40
+ const job = input.protocolId !== undefined
41
+ ? queueJobByProtocol(context, project.id, input.protocolId)
42
+ : context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND status IN ('ready', 'requeued') ORDER BY created_at ASC, id ASC LIMIT 1", [project.id]);
43
+ if (input.protocolId !== undefined && (!job || !["ready", "requeued"].includes(job.status))) {
44
+ throw new AppError("merge_job_not_claimable", "Requested merge job is not ready to claim", 1, { protocol_id: input.protocolId, status: job?.status ?? "missing", allowed: ["ready", "requeued"] });
45
+ }
46
+ const transition = job ? prepareClaimedProtocolTransition(context, project.id, job.protocol_id) : null;
47
+ return { project, job, transition };
48
+ }
49
+ export function claimNextMergeJob(context, input) {
50
+ const { project } = prepareMergeClaim(context, input);
37
51
  const now = context.now();
38
52
  let claimedProtocolId = null;
39
53
  context.db.beginWriteTransaction();
40
54
  try {
41
- const job = context.db.get(`SELECT * FROM merge_queue
42
- WHERE project_id = ? AND status IN ('ready', 'requeued')
43
- ORDER BY created_at ASC, id ASC
44
- LIMIT 1`, [project.id]);
55
+ const { job, transition } = prepareMergeClaim(context, input);
45
56
  if (!job) {
46
57
  context.db.exec("COMMIT");
47
58
  return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
@@ -59,7 +70,7 @@ export function claimNextMergeJob(context, input) {
59
70
  eventType: "merge_queue.claimed",
60
71
  payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
61
72
  });
62
- transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
73
+ transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
63
74
  claimedProtocolId = job.protocol_id;
64
75
  context.db.exec("COMMIT");
65
76
  }
@@ -74,32 +85,14 @@ export function claimNextMergeJob(context, input) {
74
85
  });
75
86
  }
76
87
  export function claimMergeJob(context, input) {
77
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
78
- const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
79
- if (stopState.stopped) {
80
- throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim another merge job", 1, {
81
- worker_id: input.workerId,
82
- status: stopState.status,
83
- reason: stopState.reason
84
- });
85
- }
86
- requireLaneLockOwner(context, {
87
- projectRoot: project.root,
88
- lane: "merge",
89
- workerId: input.workerId,
90
- workspacePath: input.workspacePath
91
- });
88
+ requireMergeText(input.protocolId, "protocol-id");
89
+ const { project } = prepareMergeClaim(context, input);
92
90
  const now = context.now();
93
91
  context.db.beginWriteTransaction();
94
92
  try {
95
- const job = queueJobByProtocol(context, project.id, input.protocolId);
96
- if (!job || !["ready", "requeued"].includes(job.status)) {
97
- throw new AppError("merge_job_not_claimable", "Requested merge job is not ready to claim", 1, {
98
- protocol_id: input.protocolId,
99
- status: job?.status ?? "missing",
100
- allowed: ["ready", "requeued"]
101
- });
102
- }
93
+ const prepared = prepareMergeClaim(context, input);
94
+ const job = prepared.job;
95
+ const transition = prepared.transition;
103
96
  const update = context.db.run(`UPDATE merge_queue
104
97
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
105
98
  WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
@@ -112,7 +105,7 @@ export function claimMergeJob(context, input) {
112
105
  eventType: "merge_queue.claimed",
113
106
  payload: { protocol_id: job.protocol_id, worker_id: input.workerId, targeted: true }
114
107
  });
115
- transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
108
+ transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
116
109
  context.db.exec("COMMIT");
117
110
  return mergeQueueResult(queueJobByProtocol(context, project.id, job.protocol_id), { ok: true, outcome: "claimed", claimed: true, targeted: true });
118
111
  }
@@ -121,7 +114,8 @@ export function claimMergeJob(context, input) {
121
114
  throw error;
122
115
  }
123
116
  }
124
- export function claimMergeBundle(context, input) {
117
+ export function prepareMergeBundleClaim(context, input) {
118
+ requireMergeText(input.workerId, "worker-id");
125
119
  const { project, protocolIds } = requireClaimableBranchBundle(context, {
126
120
  projectRoot: input.projectRoot,
127
121
  workspacePath: input.workspacePath
@@ -140,9 +134,16 @@ export function claimMergeBundle(context, input) {
140
134
  workerId: input.workerId,
141
135
  workspacePath: input.workspacePath
142
136
  });
137
+ const transitions = protocolIds.map((protocolId) => prepareClaimedProtocolTransition(context, project.id, protocolId));
138
+ return { project, protocolIds, transitions };
139
+ }
140
+ export function claimMergeBundle(context, input) {
141
+ const { project, protocolIds } = prepareMergeBundleClaim(context, input);
143
142
  const now = context.now();
144
143
  context.db.beginWriteTransaction();
145
144
  try {
145
+ const transitions = protocolIds.map((protocolId) => prepareClaimedProtocolTransition(context, project.id, protocolId));
146
+ // Claim every queue row before publishing any filesystem state.
146
147
  for (const protocolId of protocolIds) {
147
148
  const update = context.db.run(`UPDATE merge_queue
148
149
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
@@ -153,7 +154,10 @@ export function claimMergeBundle(context, input) {
153
154
  protocol_ids: protocolIds
154
155
  });
155
156
  }
156
- transitionClaimedProtocolToIntegration(context, project.id, protocolId, input.workerId, now);
157
+ }
158
+ for (const transition of transitions) {
159
+ const protocolId = transition.protocol.id;
160
+ transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
157
161
  appendAudit(context, {
158
162
  protocolId,
159
163
  projectId: project.id,
@@ -182,22 +186,38 @@ export function claimMergeBundle(context, input) {
182
186
  protocolIds
183
187
  });
184
188
  }
185
- export async function waitNextMergeJob(context, input) {
189
+ export function prepareMergeQueueWait(context, input) {
190
+ requireMergeText(input.workerId, "worker-id");
191
+ if (typeof input.acquireLock !== "boolean")
192
+ throw new AppError("validation", "acquire-lock must be boolean", 2);
193
+ const { timeoutSeconds, pollIntervalSeconds } = prepareLaneWaitTiming(input);
186
194
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
187
- const timeoutSeconds = input.timeoutSeconds ?? 0;
188
- const pollIntervalSeconds = input.pollIntervalSeconds ?? 10;
189
- if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
190
- throw new AppError("validation", "--timeout must be zero or a positive number", 2);
191
- }
192
- if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds <= 0) {
193
- throw new AppError("validation", "--poll-interval must be a positive number", 2);
194
- }
195
195
  if (input.acquireLock && pollIntervalSeconds >= mergeLockTtlSeconds) {
196
196
  throw new AppError("validation", "--poll-interval must be lower than the merge lock TTL when --acquire-lock true", 2, {
197
197
  lock_ttl_seconds: mergeLockTtlSeconds
198
198
  });
199
199
  }
200
200
  const initialStopState = stoppedMergeWorkerState(context, project.id, input.workerId);
201
+ if (!initialStopState.stopped) {
202
+ const next = context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND status IN ('ready', 'requeued') ORDER BY created_at ASC, id ASC LIMIT 1", [project.id]);
203
+ if (next)
204
+ prepareClaimedProtocolTransition(context, project.id, next.protocol_id);
205
+ if (input.acquireLock) {
206
+ if (laneRecord(context, project.id, "merge")) {
207
+ prepareLaneLock(context, { ...input, lane: "merge", reason: "merge queue wait-next", ttlSeconds: mergeLockTtlSeconds }, "wait-acquire");
208
+ }
209
+ else {
210
+ prepareLaneWorkspace(context, { ...input, lane: "merge" });
211
+ }
212
+ }
213
+ else {
214
+ requireLaneLockOwner(context, { ...input, lane: "merge" });
215
+ }
216
+ }
217
+ return { project, timeoutSeconds, pollIntervalSeconds, initialStopState };
218
+ }
219
+ export async function waitNextMergeJob(context, input) {
220
+ const { project, timeoutSeconds, pollIntervalSeconds, initialStopState } = prepareMergeQueueWait(context, input);
201
221
  if (initialStopState.stopped) {
202
222
  return mergeQueueResult(null, {
203
223
  ok: true,
@@ -325,7 +345,9 @@ export async function waitNextMergeJob(context, input) {
325
345
  await delay(pollIntervalSeconds * 1000);
326
346
  }
327
347
  }
328
- export function completeMergeJob(context, input) {
348
+ export function prepareMergeJobCompletion(context, input) {
349
+ requireMergeText(input.summary, "summary");
350
+ requireMergeText(input.workerId, "worker-id");
329
351
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
330
352
  const protocol = requireProtocol(context, input.protocolId, project.id);
331
353
  const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
@@ -335,14 +357,18 @@ export function completeMergeJob(context, input) {
335
357
  workerId: input.workerId,
336
358
  workspacePath: input.workspacePath
337
359
  });
338
- const now = context.now();
339
- context.db.run(`UPDATE merge_queue
340
- SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
341
- WHERE id = ?`, [input.summary, now, now, job.id]);
342
360
  const state = readProtocolRuntimeState(context, protocol).state;
343
361
  const flowContract = flowContractForState(state);
344
362
  const targetStage = flowContract.merge_queue.complete.target_stage;
345
363
  const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
364
+ return { protocol, job, state, flowContract, targetStage, targetStatus };
365
+ }
366
+ export function completeMergeJob(context, input) {
367
+ const { protocol, job, state, flowContract, targetStage, targetStatus } = prepareMergeJobCompletion(context, input);
368
+ const now = context.now();
369
+ context.db.run(`UPDATE merge_queue
370
+ SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
371
+ WHERE id = ?`, [input.summary, now, now, job.id]);
346
372
  persistProtocolState(context, protocol, {
347
373
  ...state,
348
374
  stage: targetStage,
@@ -365,7 +391,9 @@ export function completeMergeJob(context, input) {
365
391
  stop_after_current
366
392
  });
367
393
  }
368
- export function completeMergeBundle(context, input) {
394
+ export function prepareMergeBundleCompletion(context, input) {
395
+ requireMergeText(input.summary, "summary");
396
+ requireMergeText(input.workerId, "worker-id");
369
397
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
370
398
  requireLaneLockOwner(context, {
371
399
  projectRoot: project.root,
@@ -380,19 +408,22 @@ export function completeMergeBundle(context, input) {
380
408
  workspace_path: input.workspacePath
381
409
  });
382
410
  }
411
+ const jobs = protocolIds.map((protocolId) => prepareMergeJobCompletion(context, { ...input, protocolId }));
412
+ return { project, protocolIds, jobs };
413
+ }
414
+ export function completeMergeBundle(context, input) {
415
+ prepareMergeBundleCompletion(context, input);
383
416
  const now = context.now();
417
+ let prepared;
384
418
  context.db.beginWriteTransaction();
385
419
  try {
386
- for (const protocolId of protocolIds) {
387
- const protocol = requireProtocol(context, protocolId, project.id);
388
- const job = requireClaimedJob(context, project.id, protocolId, input.workerId);
420
+ prepared = prepareMergeBundleCompletion(context, input);
421
+ const { project, protocolIds, jobs } = prepared;
422
+ for (const { protocol, job, state, flowContract, targetStage, targetStatus } of jobs) {
423
+ const protocolId = protocol.id;
389
424
  context.db.run(`UPDATE merge_queue
390
425
  SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
391
426
  WHERE id = ?`, [input.summary, now, now, job.id]);
392
- const state = readProtocolRuntimeState(context, protocol).state;
393
- const flowContract = flowContractForState(state);
394
- const targetStage = flowContract.merge_queue.complete.target_stage;
395
- const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
396
427
  persistProtocolState(context, protocol, {
397
428
  ...state,
398
429
  stage: targetStage,
@@ -418,6 +449,7 @@ export function completeMergeBundle(context, input) {
418
449
  context.db.exec("ROLLBACK");
419
450
  throw error;
420
451
  }
452
+ const { project, protocolIds } = prepared;
421
453
  const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle complete after stop request");
422
454
  return mergeBundleResult(context, {
423
455
  ok: true,
@@ -430,7 +462,9 @@ export function completeMergeBundle(context, input) {
430
462
  stop_after_current
431
463
  });
432
464
  }
433
- export function noteMergeJob(context, input) {
465
+ export function prepareMergeJobNote(context, input) {
466
+ requireMergeText(input.summary, "summary");
467
+ requireMergeText(input.workerId, "worker-id");
434
468
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
435
469
  const protocol = requireProtocol(context, input.protocolId, project.id);
436
470
  const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
@@ -449,6 +483,10 @@ export function noteMergeJob(context, input) {
449
483
  claimed_by_session_id: job.claimed_by_session_id
450
484
  });
451
485
  }
486
+ return { protocol, job };
487
+ }
488
+ export function noteMergeJob(context, input) {
489
+ const { protocol, job } = prepareMergeJobNote(context, input);
452
490
  const now = context.now();
453
491
  context.db.run(`UPDATE merge_queue
454
492
  SET last_reason = ?, updated_at = ?
@@ -461,7 +499,11 @@ export function noteMergeJob(context, input) {
461
499
  });
462
500
  return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "noted" });
463
501
  }
464
- export function failMergeJob(context, input) {
502
+ export function prepareMergeJobFailure(context, input) {
503
+ requireMergeText(input.reason, "reason");
504
+ requireMergeText(input.workerId, "worker-id");
505
+ if (typeof input.requeue !== "boolean")
506
+ throw new AppError("validation", "requeue must be boolean", 2);
465
507
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
466
508
  const protocol = requireProtocol(context, input.protocolId, project.id);
467
509
  const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
@@ -471,13 +513,17 @@ export function failMergeJob(context, input) {
471
513
  workerId: input.workerId,
472
514
  workspacePath: input.workspacePath
473
515
  });
516
+ const state = input.requeue ? readProtocolRuntimeState(context, protocol).state : null;
517
+ return { protocol, job, state };
518
+ }
519
+ export function failMergeJob(context, input) {
520
+ const { protocol, job, state } = prepareMergeJobFailure(context, input);
474
521
  const now = context.now();
475
522
  const status = input.requeue ? "requeued" : "failed";
476
523
  context.db.run(`UPDATE merge_queue
477
524
  SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
478
525
  WHERE id = ?`, [status, input.reason, now, job.id]);
479
- if (input.requeue) {
480
- const state = readProtocolRuntimeState(context, protocol).state;
526
+ if (state) {
481
527
  persistProtocolState(context, protocol, {
482
528
  ...state,
483
529
  stage: "ready_for_merge",
@@ -500,7 +546,11 @@ export function failMergeJob(context, input) {
500
546
  stop_after_current
501
547
  });
502
548
  }
503
- export function failMergeBundle(context, input) {
549
+ export function prepareMergeBundleFailure(context, input) {
550
+ requireMergeText(input.reason, "reason");
551
+ requireMergeText(input.workerId, "worker-id");
552
+ if (typeof input.requeue !== "boolean")
553
+ throw new AppError("validation", "requeue must be boolean", 2);
504
554
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
505
555
  requireLaneLockOwner(context, {
506
556
  projectRoot: project.root,
@@ -515,18 +565,24 @@ export function failMergeBundle(context, input) {
515
565
  workspace_path: input.workspacePath
516
566
  });
517
567
  }
568
+ const jobs = protocolIds.map((protocolId) => prepareMergeJobFailure(context, { ...input, protocolId }));
569
+ return { project, protocolIds, jobs };
570
+ }
571
+ export function failMergeBundle(context, input) {
572
+ prepareMergeBundleFailure(context, input);
518
573
  const now = context.now();
519
574
  const status = input.requeue ? "requeued" : "failed";
575
+ let prepared;
520
576
  context.db.beginWriteTransaction();
521
577
  try {
522
- for (const protocolId of protocolIds) {
523
- const protocol = requireProtocol(context, protocolId, project.id);
524
- const job = requireClaimedJob(context, project.id, protocolId, input.workerId);
578
+ prepared = prepareMergeBundleFailure(context, input);
579
+ const { project, protocolIds, jobs } = prepared;
580
+ for (const { protocol, job, state } of jobs) {
581
+ const protocolId = protocol.id;
525
582
  context.db.run(`UPDATE merge_queue
526
583
  SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
527
584
  WHERE id = ?`, [status, input.reason, now, job.id]);
528
- if (input.requeue) {
529
- const state = readProtocolRuntimeState(context, protocol).state;
585
+ if (state) {
530
586
  persistProtocolState(context, protocol, {
531
587
  ...state,
532
588
  stage: "ready_for_merge",
@@ -555,6 +611,7 @@ export function failMergeBundle(context, input) {
555
611
  context.db.exec("ROLLBACK");
556
612
  throw error;
557
613
  }
614
+ const { project, protocolIds } = prepared;
558
615
  const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle fail after stop request");
559
616
  return mergeBundleResult(context, {
560
617
  ok: true,
@@ -566,47 +623,57 @@ export function failMergeBundle(context, input) {
566
623
  stop_after_current
567
624
  });
568
625
  }
569
- export function cancelMergeQueueJob(context, input) {
626
+ export function prepareMergeQueueCancellation(context, input) {
627
+ requireMergeText(input.reason, "reason");
628
+ if (input.workerId !== undefined)
629
+ requireMergeText(input.workerId, "worker-id");
630
+ if (typeof input.force !== "boolean")
631
+ throw new AppError("validation", "force must be boolean", 2);
570
632
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
571
633
  const protocol = requireProtocol(context, input.protocolId, project.id);
572
634
  const reason = input.reason.trim();
573
- if (!reason) {
574
- throw new AppError("validation", "merge-queue cancel requires --reason", 2);
635
+ const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
636
+ if (!job) {
637
+ throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
638
+ }
639
+ if (job.status === "cancelled") {
640
+ return { protocol, job, reason };
641
+ }
642
+ if (job.status === "claimed" && !input.force) {
643
+ if (!input.workerId) {
644
+ throw new AppError("merge_job_claimed", "Claimed jobs require --worker-id or --force", 1, { protocol_id: protocol.id });
645
+ }
646
+ if (job.claimed_by_session_id !== input.workerId) {
647
+ throw new AppError("merge_job_claimed_by_other_session", "Merge job is claimed by another worker", 1, {
648
+ protocol_id: protocol.id,
649
+ claimed_by_session_id: job.claimed_by_session_id
650
+ });
651
+ }
652
+ requireLaneLockOwner(context, {
653
+ projectRoot: protocol.project_root,
654
+ lane: "merge",
655
+ workerId: input.workerId,
656
+ workspacePath: input.workspacePath
657
+ });
658
+ }
659
+ if (["merged", "failed"].includes(job.status) && !input.force) {
660
+ throw new AppError("merge_job_terminal", `Merge job is ${job.status}; cancellation requires --force --reason`, 1, {
661
+ protocol_id: protocol.id,
662
+ status: job.status
663
+ });
575
664
  }
665
+ return { protocol, job, reason };
666
+ }
667
+ export function cancelMergeQueueJob(context, input) {
668
+ const { protocol } = prepareMergeQueueCancellation(context, input);
576
669
  const now = context.now();
577
670
  context.db.beginWriteTransaction();
578
671
  try {
579
- const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
580
- if (!job) {
581
- throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
582
- }
672
+ const { job, reason } = prepareMergeQueueCancellation(context, input);
583
673
  if (job.status === "cancelled") {
584
674
  context.db.exec("COMMIT");
585
675
  return mergeQueueResult(job, { ok: true, outcome: "cancelled", cancelled: false, reason: "already_cancelled" });
586
676
  }
587
- if (job.status === "claimed" && !input.force) {
588
- if (!input.workerId) {
589
- throw new AppError("merge_job_claimed", "Claimed jobs require --worker-id or --force", 1, { protocol_id: protocol.id });
590
- }
591
- if (job.claimed_by_session_id !== input.workerId) {
592
- throw new AppError("merge_job_claimed_by_other_session", "Merge job is claimed by another worker", 1, {
593
- protocol_id: protocol.id,
594
- claimed_by_session_id: job.claimed_by_session_id
595
- });
596
- }
597
- requireLaneLockOwner(context, {
598
- projectRoot: protocol.project_root,
599
- lane: "merge",
600
- workerId: input.workerId,
601
- workspacePath: input.workspacePath
602
- });
603
- }
604
- if (["merged", "failed"].includes(job.status) && !input.force) {
605
- throw new AppError("merge_job_terminal", `Merge job is ${job.status}; cancellation requires --force --reason`, 1, {
606
- protocol_id: protocol.id,
607
- status: job.status
608
- });
609
- }
610
677
  context.db.run(`UPDATE merge_queue
611
678
  SET status = 'cancelled', last_reason = ?, updated_at = ?
612
679
  WHERE id = ?`, [reason, now, job.id]);
@@ -629,6 +696,10 @@ export function cancelMergeQueueJob(context, input) {
629
696
  export function queueForProject(context, projectId) {
630
697
  return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
631
698
  }
699
+ function requireMergeText(value, parameter) {
700
+ if (typeof value !== "string" || !value.trim())
701
+ throw new AppError("validation", `--${parameter} must be nonempty text`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter });
702
+ }
632
703
  function queueJobByProtocol(context, projectId, protocolId) {
633
704
  return context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
634
705
  }
@@ -688,7 +759,7 @@ function requireClaimedJob(context, projectId, protocolId, sessionId) {
688
759
  }
689
760
  return job;
690
761
  }
691
- function transitionClaimedProtocolToIntegration(context, projectId, protocolId, workerId, now) {
762
+ function prepareClaimedProtocolTransition(context, projectId, protocolId) {
692
763
  const protocol = requireProtocol(context, protocolId, projectId);
693
764
  const state = readProtocolRuntimeState(context, protocol).state;
694
765
  const flowContract = flowContractForState(state);
@@ -707,6 +778,10 @@ function transitionClaimedProtocolToIntegration(context, projectId, protocolId,
707
778
  diagnostics: blockingDiagnostics
708
779
  });
709
780
  }
781
+ return { protocol, state, flowContract };
782
+ }
783
+ function transitionClaimedProtocolToIntegration(context, prepared, workerId, now) {
784
+ const { protocol, state, flowContract } = prepared;
710
785
  persistProtocolState(context, protocol, {
711
786
  ...state,
712
787
  stage: "integration",
@@ -715,10 +790,10 @@ function transitionClaimedProtocolToIntegration(context, projectId, protocolId,
715
790
  updated_at: now
716
791
  });
717
792
  appendAudit(context, {
718
- protocolId,
793
+ protocolId: protocol.id,
719
794
  projectId: protocol.project_id,
720
795
  eventType: "protocol.integration_claimed",
721
- payload: { protocol_id: protocolId, worker_id: workerId, flow_contract_id: flowContract.id }
796
+ payload: { protocol_id: protocol.id, worker_id: workerId, flow_contract_id: flowContract.id }
722
797
  });
723
798
  }
724
799
  function releaseLaneLockIfOwned(context, input) {
@@ -10,6 +10,7 @@ import { resolveProjectRoot } from "../storage/paths.js";
10
10
  import { classifyCliOperation } from "./cli-operation-classifier.js";
11
11
  import { compatibilityContexts, selectEngine } from "./engines.js";
12
12
  import { getCliBuildInfo } from "./build-info.js";
13
+ import { prepareJsonFile } from "../cli/input-preparation.js";
13
14
  export function assessMigrationImpact(context, input) {
14
15
  const root = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
15
16
  if (!root.root)
@@ -147,7 +148,7 @@ export function planMigration(context, input) {
147
148
  }
148
149
  };
149
150
  }
150
- export function applyMigration(context, input) {
151
+ export function prepareMigrationApply(context, input) {
151
152
  const classification = input.classification ?? classifyCliOperation(["migration", "apply"], context.env);
152
153
  if (classification.mode !== "mb_upgrade") {
153
154
  throw new AppError("upgrade_authorization_required", "Migration apply requires explicit mb-upgrade authorization", 1, {
@@ -197,6 +198,13 @@ export function applyMigration(context, input) {
197
198
  });
198
199
  }
199
200
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
201
+ const current = activeStateSummary(context, project.id);
202
+ if (activeStateBlockers(current).length)
203
+ throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: current });
204
+ return { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain };
205
+ }
206
+ export function applyMigration(context, input, prepared = prepareMigrationApply(context, input)) {
207
+ const { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain } = prepared;
200
208
  const writeDb = getDatabase(context.ddFlowHome, "migrate");
201
209
  const already = writeDb.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, sourceVersion, targetVersion)]);
202
210
  if (already) {
@@ -218,6 +226,9 @@ export function applyMigration(context, input) {
218
226
  };
219
227
  try {
220
228
  writeDb.beginWriteTransaction();
229
+ const current = activeStateSummary({ ...context, db: writeDb }, project.id);
230
+ if (activeStateBlockers(current).length)
231
+ throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: current });
221
232
  writeDb.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, [applyKey(project.id, sourceVersion, targetVersion), JSON.stringify(result), context.now(), context.now()]);
222
233
  writeDb.exec("COMMIT");
223
234
  }
@@ -513,13 +524,7 @@ function activeStateBlockers(active) {
513
524
  return blockers;
514
525
  }
515
526
  function readJsonObject(filePath) {
516
- let parsed;
517
- try {
518
- parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
519
- }
520
- catch (error) {
521
- throw new AppError("invalid_json", `Cannot read migration report: ${filePath}`, 2, { cause: String(error) });
522
- }
527
+ const { value: parsed } = prepareJsonFile(filePath, "Migration report");
523
528
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
524
529
  throw new AppError("validation", "Migration report must be a JSON object", 2, { file: filePath });
525
530
  }
@@ -6,17 +6,23 @@ import { parseJsonObject } from "../shared/json.js";
6
6
  import { refreshRunSessionProjection } from "./run-projection.js";
7
7
  export function readCanonicalPlan(planPath, protocolId) {
8
8
  if (!fs.existsSync(planPath) || !fs.statSync(planPath).isFile()) {
9
- throw new AppError("not_found", `Canonical protocol plan is missing: ${planPath}`, 1, { plan_path: planPath });
9
+ throw new AppError("runtime_artifact_missing", `Canonical protocol plan is missing: ${planPath}`, 1, { plan_path: planPath });
10
10
  }
11
11
  const raw = fs.readFileSync(planPath, "utf8");
12
- const value = parseJsonObject(raw, planPath);
13
- const plan = validatePlan(value, protocolId);
14
- const revisionValue = value.revision;
15
- const revision = typeof revisionValue === "number" && Number.isInteger(revisionValue) && revisionValue > 0 ? revisionValue : 1;
16
- return { plan, raw, sha256: crypto.createHash("sha256").update(raw).digest("hex"), revision };
12
+ try {
13
+ const value = parseJsonObject(raw, planPath);
14
+ const plan = validatePlan(value, protocolId);
15
+ const revisionValue = value.revision;
16
+ const revision = typeof revisionValue === "number" && Number.isInteger(revisionValue) && revisionValue > 0 ? revisionValue : 1;
17
+ return { plan, raw, sha256: crypto.createHash("sha256").update(raw).digest("hex"), revision };
18
+ }
19
+ catch (error) {
20
+ if (!(error instanceof AppError) || error.code !== "validation")
21
+ throw error;
22
+ throw new AppError("runtime_artifact_invalid", "Canonical protocol plan is invalid", 1, { plan_path: planPath, cause_code: error.code });
23
+ }
17
24
  }
18
- export function boundCanonicalPlan(context, input) {
19
- const canonical = readCanonicalPlan(input.planPath, input.protocolId);
25
+ export function prepareCanonicalPlan(context, input, canonical = readCanonicalPlan(input.planPath, input.protocolId)) {
20
26
  const binding = context.db.get("SELECT plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
21
27
  if (binding && (binding.plan_revision !== canonical.revision || binding.plan_sha256 !== canonical.sha256)) {
22
28
  throw new AppError("plan_stale", "Canonical plan changed after the runtime binding was created", 1, {
@@ -28,6 +34,11 @@ export function boundCanonicalPlan(context, input) {
28
34
  plan_path: input.planPath
29
35
  });
30
36
  }
37
+ return canonical;
38
+ }
39
+ export function boundCanonicalPlan(context, input, prepared) {
40
+ const canonical = prepareCanonicalPlan(context, input, prepared);
41
+ const binding = context.db.get("SELECT plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
31
42
  const now = context.now();
32
43
  if (!binding) {
33
44
  context.db.run(`INSERT INTO plan_bindings (project_id, protocol_id, plan_revision, plan_sha256, plan_path, bound_at, updated_at)