@deksden-com/dd-flow-cli 0.9.0-beta.103 → 0.9.0-beta.105

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 (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +32 -11
  4. package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
  5. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
  6. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
  7. package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -0
  8. package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -1
  9. package/dist/harness-runtime/lib/managed-daemon.mjs +11 -6
  10. package/dist/protocol/local-files.js +18 -9
  11. package/dist/runtime/context.js +2 -2
  12. package/dist/services/cleanup.js +81 -32
  13. package/dist/services/code-checks.js +18 -19
  14. package/dist/services/continuation-outcome.js +1 -1
  15. package/dist/services/controller-fanout.js +3 -3
  16. package/dist/services/eval-snapshots.js +69 -41
  17. package/dist/services/execution-policy.js +12 -1
  18. package/dist/services/hooks.js +150 -42
  19. package/dist/services/lanes.js +23 -18
  20. package/dist/services/lifecycle-command.js +11 -2
  21. package/dist/services/lifecycle-invocations.js +291 -54
  22. package/dist/services/managed-processes.js +35 -23
  23. package/dist/services/merge-queue.js +71 -67
  24. package/dist/services/merge-server.js +3 -11
  25. package/dist/services/migrations.js +28 -12
  26. package/dist/services/plan-runtime.js +8 -4
  27. package/dist/services/projects.js +3 -11
  28. package/dist/services/protocols.js +98 -68
  29. package/dist/services/recovery-snapshot-database.js +43 -8
  30. package/dist/services/run-control-worker.js +15 -32
  31. package/dist/services/run-control.js +3 -17
  32. package/dist/services/run-controller-capture.js +58 -27
  33. package/dist/services/run-controller-process.js +21 -30
  34. package/dist/services/run-controller-recovery.js +36 -37
  35. package/dist/services/run-controller.js +81 -86
  36. package/dist/services/run-fork.js +10 -6
  37. package/dist/services/run-projection.js +19 -64
  38. package/dist/services/run-recovery-runtime.js +24 -14
  39. package/dist/services/run-recovery.js +56 -54
  40. package/dist/services/runs.js +218 -160
  41. package/dist/services/runtime-budget.js +19 -45
  42. package/dist/services/runtime-scope-capture.js +8 -6
  43. package/dist/services/runtime-scope-control.js +49 -39
  44. package/dist/services/runtime-scope-resume.js +134 -122
  45. package/dist/services/runtime-scope-worker.js +48 -49
  46. package/dist/services/scope-writer-cleanup.js +26 -0
  47. package/dist/services/stage-context.js +6 -1
  48. package/dist/services/stage-lifecycle.js +1 -1
  49. package/dist/services/stage-pause.js +13 -29
  50. package/dist/services/stage-report-renderer.js +116 -6
  51. package/dist/services/usage.js +2 -9
  52. package/dist/services/vnext-code-review.js +10 -7
  53. package/dist/services/vnext-code.js +9 -17
  54. package/dist/services/vnext-merge.js +64 -21
  55. package/dist/services/vnext-plan-review.js +43 -30
  56. package/dist/services/vnext-plan.js +28 -24
  57. package/dist/services/vnext-protocolize.js +183 -20
  58. package/dist/services/vnext-specify.js +27 -19
  59. package/dist/services/work-registry.js +229 -150
  60. package/dist/storage/database.js +286 -56
  61. package/dist/storage/foreign-key-transaction.js +39 -0
  62. package/dist/storage/immutable-input.js +45 -0
  63. package/dist/storage/import-guard.js +102 -0
  64. package/dist/storage/native-cleanup.js +52 -0
  65. package/dist/storage/projection-lock.js +73 -0
  66. package/dist/storage/writer-contract.js +3 -3
  67. package/dist/storage/writer-migration.js +24 -41
  68. package/package.json +1 -1
  69. package/tools/repair-paused-run-status.mjs +48 -23
@@ -86,24 +86,23 @@ export function registerManagedProcess(context, rawInput) {
86
86
  if (!input.uniqueActiveOperation)
87
87
  return insertManagedProcess(context, input, metadataJson);
88
88
  const db = registry(context);
89
- db.beginWriteTransaction();
90
- try {
89
+ if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth }))
90
+ throw new AppError("process_ownership_unknown", "EVAL observer changed before registration", 1);
91
+ return db.writeTransaction(() => {
91
92
  const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
92
93
  if (existing)
93
94
  throw new AppError("managed_operation_in_progress", "The operation already owns a managed process; observe it instead of spawning a duplicate", 1, { process_id: existing.id, operation_id: input.operationId });
94
95
  let result = insertManagedProcess(context, input, metadataJson);
95
96
  if (observer) {
96
- result = confirmManagedProcess(context, { id: result.id, leaseToken: result.lease_token, pid: input.pid, ...(input.processGroupId ? { processGroupId: input.processGroupId } : {}) });
97
- if (result.pid_started_at !== observerBirth || !processIsAlive(result))
98
- throw new AppError("process_ownership_unknown", "EVAL observer changed before registration committed", 1);
97
+ const metadata = JSON.parse(metadataJson);
98
+ if (input.processGroupId)
99
+ metadata.process_group_id = input.processGroupId;
100
+ const now = context.now();
101
+ db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ? WHERE id = ? AND lease_token = ? AND state = 'starting'", [input.pid, observerBirth, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), result.id, result.lease_token]);
102
+ result = requireProcess(db, result.id);
99
103
  }
100
- db.exec("COMMIT");
101
104
  return result;
102
- }
103
- catch (error) {
104
- db.exec("ROLLBACK");
105
- throw error;
106
- }
105
+ });
107
106
  }
108
107
  function insertManagedProcess(context, input, metadataJson) {
109
108
  const db = registry(context);
@@ -167,6 +166,21 @@ export function heartbeatManagedProcess(context, input) {
167
166
  const db = registry(context);
168
167
  return db.run("UPDATE managed_processes SET lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping')", [leaseExpiry(context.now(), input.leaseMs), context.now(), input.id, input.leaseToken]).changes === 1;
169
168
  }
169
+ /** Read-only lease check for a caller already holding a RUN writer. Renewal
170
+ * must happen before entering that writer, never from this assertion. */
171
+ export function assertManagedProcessLease(context, input) {
172
+ const reader = getResourceDatabase(resourceHome(context), "read_isolated");
173
+ try {
174
+ const current = reader.get("SELECT pid, lease_expires_at, state FROM managed_processes WHERE id = ? AND lease_token = ?", [input.id, input.leaseToken]);
175
+ if (!current || current.pid !== input.pid || !["starting", "running"].includes(current.state)
176
+ || Date.parse(current.lease_expires_at) <= Date.now()) {
177
+ throw new AppError("controller_lease_lost", "Managed owner lease changed or expired before productive dispatch", 1, { process_id: input.id });
178
+ }
179
+ }
180
+ finally {
181
+ reader.close?.();
182
+ }
183
+ }
170
184
  export function prepareManagedProcessFinish(context, input) {
171
185
  requireProcessText(input.id, "id");
172
186
  requireProcessText(input.leaseToken, "lease-token");
@@ -234,26 +248,24 @@ export function managedOwnerIsAlive(record) {
234
248
  export function claimExpiredManagedProcesses(context, ownerId) {
235
249
  const db = registry(context);
236
250
  const now = context.now();
237
- db.beginWriteTransaction();
238
- try {
239
- const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
251
+ const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]).filter(candidate => {
252
+ const ownerAlive = managedOwnerIsAlive(candidate);
253
+ return ownerAlive !== true && !(ownerAlive === null && processTreeIsAlive(candidate));
254
+ });
255
+ return db.writeTransaction(() => {
240
256
  const claimed = [];
241
257
  for (const candidate of candidates) {
242
- const ownerAlive = managedOwnerIsAlive(candidate);
243
- if (ownerAlive === true || (ownerAlive === null && processTreeIsAlive(candidate)))
258
+ // A heartbeat or replacement registration invalidates the pre-writer
259
+ // death proof; expiry alone never authorizes claiming that new owner.
260
+ if (JSON.stringify(db.get("SELECT * FROM managed_processes WHERE id = ?", [candidate.id])) !== JSON.stringify(candidate))
244
261
  continue;
245
262
  const token = crypto.randomUUID();
246
263
  const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
247
264
  if (result.changes === 1)
248
265
  claimed.push({ ...candidate, owner_id: ownerId, lease_token: token, state: "orphaned", updated_at: now });
249
266
  }
250
- db.exec("COMMIT");
251
267
  return claimed;
252
- }
253
- catch (error) {
254
- db.exec("ROLLBACK");
255
- throw error;
256
- }
268
+ });
257
269
  }
258
270
  /** Workspace gates are leases, not spawned processes. Children retain their own registrations. */
259
271
  export function managedLeaseReleased(record) {
@@ -400,7 +412,7 @@ function leaseExpiry(now, leaseMs = defaultLeaseMs) {
400
412
  throw new AppError("validation", "--lease-ms must be a positive integer within the supported date range", 2);
401
413
  return new Date(Date.parse(now) + leaseMs).toISOString();
402
414
  }
403
- export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
415
+ export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
404
416
  function releasePortClaims(db, claims) { for (const claim of claims)
405
417
  db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
406
418
  function terminateOwnedProcess(record, signal) {
@@ -8,6 +8,38 @@ import { ensureLaneWorkspace, heartbeatLaneLock, laneRecord, prepareLaneLock, pr
8
8
  import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
9
9
  import { getProtocolBranchContext, requireClaimableBranchBundle } from "./branch-context.js";
10
10
  const mergeLockTtlSeconds = 300;
11
+ /** SQL evidence comes from the same read snapshot as filesystem preparation.
12
+ * Bundle membership scans only branch metadata; unrelated RUN writes do not
13
+ * invalidate an individual job's accepted inputs. */
14
+ function prepareMergePublication(context, root, workerId, prepare, protocolIds, mode, requireLock = true) {
15
+ const project = requireProjectByRoot(context, resolveProjectRoot(root));
16
+ return context.db.readSnapshot(() => {
17
+ const value = prepare(), ids = JSON.stringify(protocolIds(value));
18
+ const observe = () => ({
19
+ root: context.db.get("SELECT root FROM projects WHERE id = ?", [project.id]),
20
+ lane: context.db.get("SELECT workspace_path, expected_branch, status FROM lanes WHERE project_id = ? AND name = 'merge'", [project.id]),
21
+ lock: context.db.get("SELECT id, worker_id, lease_token FROM lane_locks WHERE project_id = ? AND lane = 'merge' AND status = 'active'", [project.id]),
22
+ worker: workerId ? stoppedMergeWorkerState(context, project.id, workerId) : null,
23
+ queue: mode === "head" ? context.db.all("SELECT * FROM merge_queue WHERE project_id = ? AND status IN ('ready','requeued') ORDER BY created_at, id LIMIT 1", [project.id])
24
+ : context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ${mode === "bundle" ? "" : "AND protocol_id IN (SELECT value FROM json_each(?))"} ORDER BY id`, mode === "bundle" ? [project.id] : [project.id, ids]),
25
+ protocols: context.db.all("SELECT * FROM protocols WHERE project_id = ? AND id IN (SELECT value FROM json_each(?)) ORDER BY id", [project.id, ids]),
26
+ states: context.db.all("SELECT protocol_id, payload_json FROM audit_events WHERE id IN (SELECT MAX(id) FROM audit_events WHERE project_id = ? AND event_type = 'protocol.state_projection' AND protocol_id IN (SELECT value FROM json_each(?)) GROUP BY protocol_id) ORDER BY protocol_id", [project.id, ids]),
27
+ runs: context.db.all("SELECT id, subject_id, status, verdict, next_action, index_json, created_at, updated_at, completed_at FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id IN (SELECT value FROM json_each(?)) ORDER BY id", [project.id, ids]),
28
+ ...(mode === "bundle" ? {
29
+ membership: context.db.all("SELECT id, status, stage, next_action, workspace_json, route_json, active_def_json FROM protocols WHERE project_id = ? ORDER BY id", [project.id]),
30
+ worktrees: context.db.all("SELECT protocol_id, integration_branch, feature_branch, base_ref, worktree_path, status FROM worktree_records WHERE project_id = ? ORDER BY protocol_id", [project.id]),
31
+ sessions: context.db.all("SELECT session_id, protocol_id, worker_id, flow_kind, status, current_stage, next_action FROM sessions WHERE project_id = ? AND status IN ('pending','active','waiting_user','blocked','stopping') ORDER BY session_id", [project.id])
32
+ } : {})
33
+ });
34
+ const expected = JSON.stringify(observe());
35
+ return { value, assertCurrent: () => {
36
+ if (JSON.stringify(observe()) !== expected)
37
+ throw new AppError("merge_preparation_changed", "Merge ownership or input authority changed during preparation", 1, { effect: "no_effect" });
38
+ if (requireLock && !context.db.get("SELECT 1 FROM lane_locks WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND worker_id = ? AND expires_at > ?", [project.id, workerId, context.now()]))
39
+ throw new AppError("lane_lock_not_active", "Prepared merge lost its live lane lease", 1, { effect: "no_effect" });
40
+ } };
41
+ });
42
+ }
11
43
  export function getMergeQueueStatus(context, input) {
12
44
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
13
45
  return {
@@ -47,21 +79,19 @@ export function prepareMergeClaim(context, input) {
47
79
  return { project, job, transition };
48
80
  }
49
81
  export function claimNextMergeJob(context, input) {
50
- const { project } = prepareMergeClaim(context, input);
82
+ const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeClaim(context, input), value => value.job ? [value.job.protocol_id] : [], "head");
83
+ const { project, job, transition } = prepared.value;
51
84
  const now = context.now();
52
85
  let claimedProtocolId = null;
53
- context.db.beginWriteTransaction();
54
- try {
55
- const { job, transition } = prepareMergeClaim(context, input);
86
+ const early = context.db.writeTransaction(() => {
87
+ prepared.assertCurrent();
56
88
  if (!job) {
57
- context.db.exec("COMMIT");
58
89
  return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
59
90
  }
60
91
  const update = context.db.run(`UPDATE merge_queue
61
92
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
62
93
  WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
63
94
  if (update.changes !== 1) {
64
- context.db.exec("COMMIT");
65
95
  return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
66
96
  }
67
97
  appendAudit(context, {
@@ -72,12 +102,10 @@ export function claimNextMergeJob(context, input) {
72
102
  });
73
103
  transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
74
104
  claimedProtocolId = job.protocol_id;
75
- context.db.exec("COMMIT");
76
- }
77
- catch (error) {
78
- context.db.exec("ROLLBACK");
79
- throw error;
80
- }
105
+ return null;
106
+ });
107
+ if (early)
108
+ return early;
81
109
  return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, project.id, claimedProtocolId) : null, {
82
110
  ok: true,
83
111
  outcome: claimedProtocolId ? "claimed" : "empty",
@@ -86,13 +114,12 @@ export function claimNextMergeJob(context, input) {
86
114
  }
87
115
  export function claimMergeJob(context, input) {
88
116
  requireMergeText(input.protocolId, "protocol-id");
89
- const { project } = prepareMergeClaim(context, input);
117
+ const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeClaim(context, input), () => [input.protocolId], "target");
118
+ const { project, job: selectedJob, transition: selectedTransition } = prepared.value;
90
119
  const now = context.now();
91
- context.db.beginWriteTransaction();
92
- try {
93
- const prepared = prepareMergeClaim(context, input);
94
- const job = prepared.job;
95
- const transition = prepared.transition;
120
+ return context.db.writeTransaction(() => {
121
+ prepared.assertCurrent();
122
+ const job = selectedJob, transition = selectedTransition;
96
123
  const update = context.db.run(`UPDATE merge_queue
97
124
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
98
125
  WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
@@ -106,13 +133,8 @@ export function claimMergeJob(context, input) {
106
133
  payload: { protocol_id: job.protocol_id, worker_id: input.workerId, targeted: true }
107
134
  });
108
135
  transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
109
- context.db.exec("COMMIT");
110
136
  return mergeQueueResult(queueJobByProtocol(context, project.id, job.protocol_id), { ok: true, outcome: "claimed", claimed: true, targeted: true });
111
- }
112
- catch (error) {
113
- context.db.exec("ROLLBACK");
114
- throw error;
115
- }
137
+ });
116
138
  }
117
139
  export function prepareMergeBundleClaim(context, input) {
118
140
  requireMergeText(input.workerId, "worker-id");
@@ -138,11 +160,11 @@ export function prepareMergeBundleClaim(context, input) {
138
160
  return { project, protocolIds, transitions };
139
161
  }
140
162
  export function claimMergeBundle(context, input) {
141
- const { project, protocolIds } = prepareMergeBundleClaim(context, input);
163
+ const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeBundleClaim(context, input), value => value.protocolIds, "bundle");
164
+ const { project, protocolIds, transitions } = prepared.value;
142
165
  const now = context.now();
143
- context.db.beginWriteTransaction();
144
- try {
145
- const transitions = protocolIds.map((protocolId) => prepareClaimedProtocolTransition(context, project.id, protocolId));
166
+ context.db.writeTransaction(() => {
167
+ prepared.assertCurrent();
146
168
  // Claim every queue row before publishing any filesystem state.
147
169
  for (const protocolId of protocolIds) {
148
170
  const update = context.db.run(`UPDATE merge_queue
@@ -170,12 +192,7 @@ export function claimMergeBundle(context, input) {
170
192
  eventType: "merge_bundle.claimed",
171
193
  payload: { project_id: project.id, worker_id: input.workerId, protocol_ids: protocolIds, workspace_path: input.workspacePath }
172
194
  });
173
- context.db.exec("COMMIT");
174
- }
175
- catch (error) {
176
- context.db.exec("ROLLBACK");
177
- throw error;
178
- }
195
+ });
179
196
  return mergeBundleResult(context, {
180
197
  ok: true,
181
198
  outcome: "claimed",
@@ -412,12 +429,11 @@ export function prepareMergeBundleCompletion(context, input) {
412
429
  return { project, protocolIds, jobs };
413
430
  }
414
431
  export function completeMergeBundle(context, input) {
415
- prepareMergeBundleCompletion(context, input);
432
+ const publication = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeBundleCompletion(context, input), value => value.protocolIds, "bundle");
433
+ const prepared = publication.value;
416
434
  const now = context.now();
417
- let prepared;
418
- context.db.beginWriteTransaction();
419
- try {
420
- prepared = prepareMergeBundleCompletion(context, input);
435
+ context.db.writeTransaction(() => {
436
+ publication.assertCurrent();
421
437
  const { project, protocolIds, jobs } = prepared;
422
438
  for (const { protocol, job, state, flowContract, targetStage, targetStatus } of jobs) {
423
439
  const protocolId = protocol.id;
@@ -443,12 +459,7 @@ export function completeMergeBundle(context, input) {
443
459
  eventType: "merge_bundle.completed",
444
460
  payload: { project_id: project.id, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
445
461
  });
446
- context.db.exec("COMMIT");
447
- }
448
- catch (error) {
449
- context.db.exec("ROLLBACK");
450
- throw error;
451
- }
462
+ });
452
463
  const { project, protocolIds } = prepared;
453
464
  const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle complete after stop request");
454
465
  return mergeBundleResult(context, {
@@ -569,13 +580,12 @@ export function prepareMergeBundleFailure(context, input) {
569
580
  return { project, protocolIds, jobs };
570
581
  }
571
582
  export function failMergeBundle(context, input) {
572
- prepareMergeBundleFailure(context, input);
583
+ const publication = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeBundleFailure(context, input), value => value.protocolIds, "bundle");
584
+ const prepared = publication.value;
573
585
  const now = context.now();
574
586
  const status = input.requeue ? "requeued" : "failed";
575
- let prepared;
576
- context.db.beginWriteTransaction();
577
- try {
578
- prepared = prepareMergeBundleFailure(context, input);
587
+ context.db.writeTransaction(() => {
588
+ publication.assertCurrent();
579
589
  const { project, protocolIds, jobs } = prepared;
580
590
  for (const { protocol, job, state } of jobs) {
581
591
  const protocolId = protocol.id;
@@ -605,12 +615,7 @@ export function failMergeBundle(context, input) {
605
615
  reason: input.reason,
606
616
  payload: { project_id: project.id, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
607
617
  });
608
- context.db.exec("COMMIT");
609
- }
610
- catch (error) {
611
- context.db.exec("ROLLBACK");
612
- throw error;
613
- }
618
+ });
614
619
  const { project, protocolIds } = prepared;
615
620
  const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle fail after stop request");
616
621
  return mergeBundleResult(context, {
@@ -665,13 +670,14 @@ export function prepareMergeQueueCancellation(context, input) {
665
670
  return { protocol, job, reason };
666
671
  }
667
672
  export function cancelMergeQueueJob(context, input) {
668
- const { protocol } = prepareMergeQueueCancellation(context, input);
673
+ const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeQueueCancellation(context, input), value => [value.protocol.id], "target", false);
674
+ const { protocol, job, reason } = prepared.value;
669
675
  const now = context.now();
670
- context.db.beginWriteTransaction();
671
- try {
672
- const { job, reason } = prepareMergeQueueCancellation(context, input);
676
+ const early = context.db.writeTransaction(() => {
677
+ prepared.assertCurrent();
678
+ if (job.status === "claimed" && !input.force && !context.db.get("SELECT 1 FROM lane_locks WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND worker_id = ? AND expires_at > ?", [protocol.project_id, input.workerId, context.now()]))
679
+ throw new AppError("lane_lock_not_active", "Prepared cancellation lost its live lane lease", 1);
673
680
  if (job.status === "cancelled") {
674
- context.db.exec("COMMIT");
675
681
  return mergeQueueResult(job, { ok: true, outcome: "cancelled", cancelled: false, reason: "already_cancelled" });
676
682
  }
677
683
  context.db.run(`UPDATE merge_queue
@@ -685,12 +691,10 @@ export function cancelMergeQueueJob(context, input) {
685
691
  forced: input.force,
686
692
  payload: { protocol_id: protocol.id, from: job.status, worker_id: input.workerId ?? null }
687
693
  });
688
- context.db.exec("COMMIT");
689
- }
690
- catch (error) {
691
- context.db.exec("ROLLBACK");
692
- throw error;
693
- }
694
+ return null;
695
+ });
696
+ if (early)
697
+ return early;
694
698
  return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
695
699
  }
696
700
  export function queueForProject(context, projectId) {
@@ -63,26 +63,18 @@ export function stopMergeServer(context, input) { const changed = context.db.run
63
63
  throw new AppError("not_found", "Running merge-server was not found", 1, { server_id: input.serverId }); return { ok: true, server_id: input.serverId, status: "stopping" }; }
64
64
  /** Both dispatchers enter the same project FIFO before creating any provider Session. */
65
65
  export function claimQueuedMergeRequest(context, input) {
66
- context.db.beginWriteTransaction();
67
- try {
66
+ return context.db.writeTransaction(() => {
68
67
  const request = context.db.get("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE merge_request_id = ?", [input.requestId]);
69
68
  if (!request)
70
69
  throw new AppError("not_found", "MERGE request was not found", 1);
71
70
  const generation = assertRunMutationAllowed(context, request.project_id, request.run_id, input.expectedGeneration);
72
71
  const earlier = context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]);
73
- if (request.status !== "queued" || request.execution_route !== "server" || earlier) {
74
- context.db.exec("COMMIT");
72
+ if (request.status !== "queued" || request.execution_route !== "server" || earlier)
75
73
  return null;
76
- }
77
74
  const token = crypto.randomUUID();
78
75
  context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued'", [input.ownerId, token, new Date(Date.now() + 120_000).toISOString(), context.now(), request.merge_request_id]);
79
- context.db.exec("COMMIT");
80
76
  return { request, token, generation };
81
- }
82
- catch (error) {
83
- context.db.exec("ROLLBACK");
84
- throw error;
85
- }
77
+ });
86
78
  }
87
79
  async function dispatch(context, input) {
88
80
  const run = context.db.get("SELECT index_json, project_root FROM runs WHERE project_id = ? AND id = ?", [input.request.project_id, input.request.run_id]);
@@ -6,6 +6,7 @@ import { requireProjectByRoot } from "./projects.js";
6
6
  import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
7
7
  import { resolveCanonRoot } from "./canon.js";
8
8
  import { getDatabase } from "../storage/database.js";
9
+ import { cleanupScopeWriters } from "./scope-writer-cleanup.js";
9
10
  import { resolveProjectRoot } from "../storage/paths.js";
10
11
  import { classifyCliOperation } from "./cli-operation-classifier.js";
11
12
  import { compatibilityContexts, selectEngine } from "./engines.js";
@@ -204,8 +205,27 @@ export function prepareMigrationApply(context, input) {
204
205
  return { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain };
205
206
  }
206
207
  export function applyMigration(context, input, prepared = prepareMigrationApply(context, input)) {
207
- const { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain } = prepared;
208
208
  const writeDb = getDatabase(context.ddFlowHome, "migrate");
209
+ let result, primary, failed = false;
210
+ try {
211
+ result = applyPreparedMigration(context, prepared, writeDb);
212
+ }
213
+ catch (error) {
214
+ primary = error;
215
+ failed = true;
216
+ }
217
+ const cleanup = cleanupScopeWriters([{ name: writeDb.path, db: writeDb, rollback: false, close: "always" }]);
218
+ if (failed) {
219
+ if (primary instanceof AppError && cleanup.length)
220
+ primary.details.migration_cleanup_errors = cleanup;
221
+ throw primary;
222
+ }
223
+ if (cleanup.length)
224
+ throw new AppError("migration_cleanup_failed", "Migration receipt is available but its connection could not be closed", 1, { effect: result?.changed ? "committed" : "no_effect", migration_cleanup_errors: cleanup });
225
+ return result;
226
+ }
227
+ function applyPreparedMigration(context, prepared, writeDb) {
228
+ const { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain } = prepared;
209
229
  const already = writeDb.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, sourceVersion, targetVersion)]);
210
230
  if (already) {
211
231
  return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "verified_noop", changed: false, backup: verifiedBackup, recovery: JSON.parse(already.value_json) };
@@ -225,18 +245,14 @@ export function applyMigration(context, input, prepared = prepareMigrationApply(
225
245
  chain: chain.steps.map((step) => ({ ...step, status: "applied" }))
226
246
  };
227
247
  try {
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 });
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()]);
233
- writeDb.exec("COMMIT");
248
+ writeDb.writeTransaction(() => {
249
+ const current = activeStateSummary({ ...context, db: writeDb }, project.id);
250
+ if (activeStateBlockers(current).length)
251
+ throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: current });
252
+ 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()]);
253
+ });
234
254
  }
235
255
  catch (error) {
236
- try {
237
- writeDb.exec("ROLLBACK");
238
- }
239
- catch { /* preserve the original failure */ }
240
256
  throw new AppError("migration_apply_failed", "Migration apply failed before upgrade RUN creation or canon sync", 1, {
241
257
  recovery: {
242
258
  status: "required",
@@ -246,7 +262,7 @@ export function applyMigration(context, input, prepared = prepareMigrationApply(
246
262
  rollback_route: "restore the verified backup before retrying mb-upgrade apply",
247
263
  next_action: "inspect the failed apply and retry only with the same pinned target contract"
248
264
  },
249
- cause: String(error)
265
+ cause: error instanceof AppError ? { code: error.code, message: error.message, details: error.details } : String(error)
250
266
  });
251
267
  }
252
268
  return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "applied", changed: true, backup: verifiedBackup, recovery: result };
@@ -23,7 +23,7 @@ export function readCanonicalPlan(planPath, protocolId) {
23
23
  }
24
24
  }
25
25
  export function prepareCanonicalPlan(context, input, canonical = readCanonicalPlan(input.planPath, input.protocolId)) {
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]);
26
+ const binding = context.db.get("SELECT plan_id, plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
27
27
  if (binding && (binding.plan_revision !== canonical.revision || binding.plan_sha256 !== canonical.sha256)) {
28
28
  throw new AppError("plan_stale", "Canonical plan changed after the runtime binding was created", 1, {
29
29
  protocol_id: input.protocolId,
@@ -38,11 +38,15 @@ export function prepareCanonicalPlan(context, input, canonical = readCanonicalPl
38
38
  }
39
39
  export function boundCanonicalPlan(context, input, prepared) {
40
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]);
41
+ const binding = context.db.get("SELECT plan_id, plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
42
42
  const now = context.now();
43
43
  if (!binding) {
44
- context.db.run(`INSERT INTO plan_bindings (project_id, protocol_id, plan_revision, plan_sha256, plan_path, bound_at, updated_at)
45
- VALUES (?, ?, ?, ?, ?, ?, ?)`, [input.projectId, input.protocolId, canonical.revision, canonical.sha256, input.planPath, now, now]);
44
+ context.db.run(`INSERT INTO plan_bindings (project_id, protocol_id, plan_id, plan_revision, plan_sha256, plan_path, bound_at, updated_at)
45
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [input.projectId, input.protocolId, canonical.plan.plan_id, canonical.revision, canonical.sha256, input.planPath, now, now]);
46
+ }
47
+ else if (binding.plan_id === null) {
48
+ // Older bindings are upgraded when their canonical source is validated.
49
+ context.db.run("UPDATE plan_bindings SET plan_id = ?, updated_at = ? WHERE project_id = ? AND protocol_id = ? AND plan_id IS NULL", [canonical.plan.plan_id, now, input.projectId, input.protocolId]);
46
50
  }
47
51
  for (const item of canonical.plan.items) {
48
52
  context.db.run(`INSERT INTO plan_progress
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { foreignKeyTransaction } from "../storage/foreign-key-transaction.js";
3
4
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId, slugFromRoot } from "../domain/entity-ids.js";
4
5
  import { AppError } from "../shared/errors.js";
5
6
  import { projectRuntimeRoot, resolveProjectRoot } from "../storage/paths.js";
@@ -148,9 +149,7 @@ export function migrateProjectIds(context, input) {
148
149
  if (conflictingProject) {
149
150
  throw new AppError("conflict", `Target project id already exists: ${target.id}`, 1, plan);
150
151
  }
151
- context.db.exec("PRAGMA foreign_keys = OFF");
152
- context.db.beginWriteTransaction();
153
- try {
152
+ foreignKeyTransaction(context.db, () => {
154
153
  context.db.run("UPDATE projects SET id = ?, short_id = ?, slug = ?, state_root = ?, updated_at = ? WHERE id = ?", [target.id, target.short_id, target.slug, target.state_root, now, oldId]);
155
154
  for (const table of projectReferenceTables()) {
156
155
  context.db.run(`UPDATE ${table} SET project_id = ? WHERE project_id = ?`, [target.id, oldId]);
@@ -162,14 +161,7 @@ export function migrateProjectIds(context, input) {
162
161
  foreign_key_errors: foreignKeyErrors
163
162
  });
164
163
  }
165
- context.db.exec("COMMIT");
166
- context.db.exec("PRAGMA foreign_keys = ON");
167
- }
168
- catch (error) {
169
- context.db.exec("ROLLBACK");
170
- context.db.exec("PRAGMA foreign_keys = ON");
171
- throw error;
172
- }
164
+ });
173
165
  appendAudit(context, {
174
166
  projectId: target.id,
175
167
  eventType: "project.id_migrated",