@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.111

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 (162) hide show
  1. package/CHANGELOG.md +526 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +77 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/native-hook-ingress.js +48 -0
  9. package/dist/cli/run-cli.js +1465 -384
  10. package/dist/cli.js +8 -2
  11. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  12. package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
  13. package/dist/harness-runtime/bin/dd-codex.mjs +26 -0
  14. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  15. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  16. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  17. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  18. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  19. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  20. package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
  21. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  22. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -0
  23. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  24. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  25. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
  26. package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
  27. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  28. package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
  29. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +394 -0
  30. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  31. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
  32. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
  35. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  36. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  37. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  38. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  39. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  40. package/dist/harness-runtime/lib/managed-daemon.mjs +327 -0
  41. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  42. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
  43. package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
  44. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  45. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  46. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  47. package/dist/harness-runtime/lib/process-json.mjs +71 -0
  48. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  49. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  50. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  51. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  52. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  53. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  54. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  55. package/dist/protocol/local-files.js +18 -9
  56. package/dist/runtime/context.js +2 -2
  57. package/dist/schemas/agent-profile.schema.json +1 -1
  58. package/dist/schemas/code-review-result.schema.json +1 -1
  59. package/dist/schemas/code-work-batch.schema.json +3 -3
  60. package/dist/schemas/code-work-result.schema.json +3 -3
  61. package/dist/schemas/plan-review-result.schema.json +2 -2
  62. package/dist/schemas/run-control-receipt.schema.json +99 -0
  63. package/dist/schemas/run-control-request.schema.json +36 -0
  64. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  65. package/dist/services/canon.js +11 -8
  66. package/dist/services/cleanup.js +132 -39
  67. package/dist/services/cli-operation-classifier.js +22 -24
  68. package/dist/services/code-checks.js +589 -106
  69. package/dist/services/codex-hook-delivery.js +28 -0
  70. package/dist/services/command-context.js +80 -0
  71. package/dist/services/config.js +11 -8
  72. package/dist/services/continuation-outcome.js +20 -0
  73. package/dist/services/controller-fanout.js +173 -0
  74. package/dist/services/dashboard.js +103 -31
  75. package/dist/services/engines.js +121 -76
  76. package/dist/services/eval-snapshots.js +994 -97
  77. package/dist/services/execution-policy.js +181 -0
  78. package/dist/services/external-work-launch.js +166 -0
  79. package/dist/services/harness-adapter.js +147 -24
  80. package/dist/services/harness-config.js +2 -0
  81. package/dist/services/hooks.js +702 -327
  82. package/dist/services/lanes.js +78 -65
  83. package/dist/services/lifecycle-command.js +113 -18
  84. package/dist/services/lifecycle-contract.js +30 -0
  85. package/dist/services/lifecycle-invocations.js +1379 -0
  86. package/dist/services/managed-daemon-binding.js +38 -0
  87. package/dist/services/managed-processes.js +302 -34
  88. package/dist/services/merge-queue.js +228 -149
  89. package/dist/services/merge-server.js +66 -28
  90. package/dist/services/migrations.js +37 -16
  91. package/dist/services/native-daemon-history.js +49 -0
  92. package/dist/services/native-session-control.js +39 -0
  93. package/dist/services/plan-runtime.js +26 -11
  94. package/dist/services/plans.js +28 -25
  95. package/dist/services/projects.js +20 -15
  96. package/dist/services/prompts.js +25 -16
  97. package/dist/services/protocols.js +160 -86
  98. package/dist/services/recovery-observation-budget.js +61 -0
  99. package/dist/services/recovery-snapshot-database.js +142 -0
  100. package/dist/services/repair-intents.js +40 -0
  101. package/dist/services/review-copy.js +136 -0
  102. package/dist/services/run-control-receipt.js +56 -0
  103. package/dist/services/run-control-worker.js +351 -0
  104. package/dist/services/run-control.js +888 -0
  105. package/dist/services/run-controller-adapter.js +231 -0
  106. package/dist/services/run-controller-capture.js +167 -0
  107. package/dist/services/run-controller-process.js +180 -0
  108. package/dist/services/run-controller-recovery.js +221 -0
  109. package/dist/services/run-controller-state.js +36 -0
  110. package/dist/services/run-controller.js +798 -0
  111. package/dist/services/run-engine-bindings.js +1 -1
  112. package/dist/services/run-fork.js +142 -0
  113. package/dist/services/run-observations.js +112 -0
  114. package/dist/services/run-projection.js +22 -64
  115. package/dist/services/run-recovery-runtime.js +80 -0
  116. package/dist/services/run-recovery.js +345 -0
  117. package/dist/services/runs.js +536 -181
  118. package/dist/services/runtime-budget.js +413 -0
  119. package/dist/services/runtime-command.js +43 -0
  120. package/dist/services/runtime-scope-capture.js +52 -0
  121. package/dist/services/runtime-scope-control.js +460 -0
  122. package/dist/services/runtime-scope-resume.js +577 -0
  123. package/dist/services/runtime-scope-stop.js +159 -0
  124. package/dist/services/runtime-scope-worker.js +242 -0
  125. package/dist/services/runtime-service.js +97 -0
  126. package/dist/services/schema-validation.js +10 -8
  127. package/dist/services/scope-writer-cleanup.js +26 -0
  128. package/dist/services/sessions.js +35 -69
  129. package/dist/services/stage-blocker.js +17 -7
  130. package/dist/services/stage-context.js +50 -19
  131. package/dist/services/stage-lifecycle.js +100 -103
  132. package/dist/services/stage-pause.js +88 -75
  133. package/dist/services/stage-report-renderer.js +116 -6
  134. package/dist/services/stage-work-graph.js +35 -0
  135. package/dist/services/usage.js +35 -98
  136. package/dist/services/vnext-code-review.js +197 -84
  137. package/dist/services/vnext-code.js +278 -251
  138. package/dist/services/vnext-execution-profile.js +5 -3
  139. package/dist/services/vnext-fanout.js +172 -17
  140. package/dist/services/vnext-merge.js +345 -111
  141. package/dist/services/vnext-plan-review.js +171 -111
  142. package/dist/services/vnext-plan.js +180 -87
  143. package/dist/services/vnext-protocolize.js +320 -74
  144. package/dist/services/vnext-specify.js +115 -77
  145. package/dist/services/work-registry.js +1001 -187
  146. package/dist/services/workspace-bootstrap.js +76 -0
  147. package/dist/services/workspace-files.js +83 -0
  148. package/dist/services/worktrees.js +88 -36
  149. package/dist/shared/entity-references.js +8 -0
  150. package/dist/shared/errors.js +12 -0
  151. package/dist/storage/database.js +692 -35
  152. package/dist/storage/foreign-key-transaction.js +39 -0
  153. package/dist/storage/immutable-input.js +45 -0
  154. package/dist/storage/import-guard.js +102 -0
  155. package/dist/storage/native-cleanup.js +52 -0
  156. package/dist/storage/paths.js +17 -4
  157. package/dist/storage/projection-lock.js +73 -0
  158. package/dist/storage/work-references.js +23 -0
  159. package/dist/storage/writer-contract.js +77 -0
  160. package/dist/storage/writer-migration.js +85 -0
  161. package/package.json +24 -13
  162. package/tools/repair-paused-run-status.mjs +84 -0
@@ -4,10 +4,42 @@ 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;
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 {
@@ -18,7 +50,10 @@ export function getMergeQueueStatus(context, input) {
18
50
  export function getMergeBundleStatus(context, input) {
19
51
  return getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath });
20
52
  }
21
- export function claimNextMergeJob(context, input) {
53
+ export function prepareMergeClaim(context, input) {
54
+ requireMergeText(input.workerId, "worker-id");
55
+ if (input.protocolId !== undefined)
56
+ requireMergeText(input.protocolId, "protocol-id");
22
57
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
23
58
  const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
24
59
  if (stopState.stopped) {
@@ -34,23 +69,29 @@ export function claimNextMergeJob(context, input) {
34
69
  workerId: input.workerId,
35
70
  workspacePath: input.workspacePath
36
71
  });
72
+ const job = input.protocolId !== undefined
73
+ ? queueJobByProtocol(context, project.id, input.protocolId)
74
+ : 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]);
75
+ if (input.protocolId !== undefined && (!job || !["ready", "requeued"].includes(job.status))) {
76
+ 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"] });
77
+ }
78
+ const transition = job ? prepareClaimedProtocolTransition(context, project.id, job.protocol_id) : null;
79
+ return { project, job, transition };
80
+ }
81
+ export function claimNextMergeJob(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;
37
84
  const now = context.now();
38
85
  let claimedProtocolId = null;
39
- context.db.exec("BEGIN IMMEDIATE");
40
- 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]);
86
+ const early = context.db.writeTransaction(() => {
87
+ prepared.assertCurrent();
45
88
  if (!job) {
46
- context.db.exec("COMMIT");
47
89
  return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
48
90
  }
49
91
  const update = context.db.run(`UPDATE merge_queue
50
92
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
51
93
  WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
52
94
  if (update.changes !== 1) {
53
- context.db.exec("COMMIT");
54
95
  return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
55
96
  }
56
97
  appendAudit(context, {
@@ -59,14 +100,12 @@ export function claimNextMergeJob(context, input) {
59
100
  eventType: "merge_queue.claimed",
60
101
  payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
61
102
  });
62
- transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
103
+ transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
63
104
  claimedProtocolId = job.protocol_id;
64
- context.db.exec("COMMIT");
65
- }
66
- catch (error) {
67
- context.db.exec("ROLLBACK");
68
- throw error;
69
- }
105
+ return null;
106
+ });
107
+ if (early)
108
+ return early;
70
109
  return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, project.id, claimedProtocolId) : null, {
71
110
  ok: true,
72
111
  outcome: claimedProtocolId ? "claimed" : "empty",
@@ -74,32 +113,13 @@ export function claimNextMergeJob(context, input) {
74
113
  });
75
114
  }
76
115
  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
- });
116
+ requireMergeText(input.protocolId, "protocol-id");
117
+ const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeClaim(context, input), () => [input.protocolId], "target");
118
+ const { project, job: selectedJob, transition: selectedTransition } = prepared.value;
92
119
  const now = context.now();
93
- context.db.exec("BEGIN IMMEDIATE");
94
- 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
- }
120
+ return context.db.writeTransaction(() => {
121
+ prepared.assertCurrent();
122
+ const job = selectedJob, transition = selectedTransition;
103
123
  const update = context.db.run(`UPDATE merge_queue
104
124
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
105
125
  WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
@@ -112,16 +132,12 @@ export function claimMergeJob(context, input) {
112
132
  eventType: "merge_queue.claimed",
113
133
  payload: { protocol_id: job.protocol_id, worker_id: input.workerId, targeted: true }
114
134
  });
115
- transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
116
- context.db.exec("COMMIT");
135
+ transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
117
136
  return mergeQueueResult(queueJobByProtocol(context, project.id, job.protocol_id), { ok: true, outcome: "claimed", claimed: true, targeted: true });
118
- }
119
- catch (error) {
120
- context.db.exec("ROLLBACK");
121
- throw error;
122
- }
137
+ });
123
138
  }
124
- export function claimMergeBundle(context, input) {
139
+ export function prepareMergeBundleClaim(context, input) {
140
+ requireMergeText(input.workerId, "worker-id");
125
141
  const { project, protocolIds } = requireClaimableBranchBundle(context, {
126
142
  projectRoot: input.projectRoot,
127
143
  workspacePath: input.workspacePath
@@ -140,9 +156,16 @@ export function claimMergeBundle(context, input) {
140
156
  workerId: input.workerId,
141
157
  workspacePath: input.workspacePath
142
158
  });
159
+ const transitions = protocolIds.map((protocolId) => prepareClaimedProtocolTransition(context, project.id, protocolId));
160
+ return { project, protocolIds, transitions };
161
+ }
162
+ export function claimMergeBundle(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;
143
165
  const now = context.now();
144
- context.db.exec("BEGIN IMMEDIATE");
145
- try {
166
+ context.db.writeTransaction(() => {
167
+ prepared.assertCurrent();
168
+ // Claim every queue row before publishing any filesystem state.
146
169
  for (const protocolId of protocolIds) {
147
170
  const update = context.db.run(`UPDATE merge_queue
148
171
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
@@ -153,7 +176,10 @@ export function claimMergeBundle(context, input) {
153
176
  protocol_ids: protocolIds
154
177
  });
155
178
  }
156
- transitionClaimedProtocolToIntegration(context, project.id, protocolId, input.workerId, now);
179
+ }
180
+ for (const transition of transitions) {
181
+ const protocolId = transition.protocol.id;
182
+ transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
157
183
  appendAudit(context, {
158
184
  protocolId,
159
185
  projectId: project.id,
@@ -166,12 +192,7 @@ export function claimMergeBundle(context, input) {
166
192
  eventType: "merge_bundle.claimed",
167
193
  payload: { project_id: project.id, worker_id: input.workerId, protocol_ids: protocolIds, workspace_path: input.workspacePath }
168
194
  });
169
- context.db.exec("COMMIT");
170
- }
171
- catch (error) {
172
- context.db.exec("ROLLBACK");
173
- throw error;
174
- }
195
+ });
175
196
  return mergeBundleResult(context, {
176
197
  ok: true,
177
198
  outcome: "claimed",
@@ -182,22 +203,38 @@ export function claimMergeBundle(context, input) {
182
203
  protocolIds
183
204
  });
184
205
  }
185
- export async function waitNextMergeJob(context, input) {
206
+ export function prepareMergeQueueWait(context, input) {
207
+ requireMergeText(input.workerId, "worker-id");
208
+ if (typeof input.acquireLock !== "boolean")
209
+ throw new AppError("validation", "acquire-lock must be boolean", 2);
210
+ const { timeoutSeconds, pollIntervalSeconds } = prepareLaneWaitTiming(input);
186
211
  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
212
  if (input.acquireLock && pollIntervalSeconds >= mergeLockTtlSeconds) {
196
213
  throw new AppError("validation", "--poll-interval must be lower than the merge lock TTL when --acquire-lock true", 2, {
197
214
  lock_ttl_seconds: mergeLockTtlSeconds
198
215
  });
199
216
  }
200
217
  const initialStopState = stoppedMergeWorkerState(context, project.id, input.workerId);
218
+ if (!initialStopState.stopped) {
219
+ 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]);
220
+ if (next)
221
+ prepareClaimedProtocolTransition(context, project.id, next.protocol_id);
222
+ if (input.acquireLock) {
223
+ if (laneRecord(context, project.id, "merge")) {
224
+ prepareLaneLock(context, { ...input, lane: "merge", reason: "merge queue wait-next", ttlSeconds: mergeLockTtlSeconds }, "wait-acquire");
225
+ }
226
+ else {
227
+ prepareLaneWorkspace(context, { ...input, lane: "merge" });
228
+ }
229
+ }
230
+ else {
231
+ requireLaneLockOwner(context, { ...input, lane: "merge" });
232
+ }
233
+ }
234
+ return { project, timeoutSeconds, pollIntervalSeconds, initialStopState };
235
+ }
236
+ export async function waitNextMergeJob(context, input) {
237
+ const { project, timeoutSeconds, pollIntervalSeconds, initialStopState } = prepareMergeQueueWait(context, input);
201
238
  if (initialStopState.stopped) {
202
239
  return mergeQueueResult(null, {
203
240
  ok: true,
@@ -325,7 +362,9 @@ export async function waitNextMergeJob(context, input) {
325
362
  await delay(pollIntervalSeconds * 1000);
326
363
  }
327
364
  }
328
- export function completeMergeJob(context, input) {
365
+ export function prepareMergeJobCompletion(context, input) {
366
+ requireMergeText(input.summary, "summary");
367
+ requireMergeText(input.workerId, "worker-id");
329
368
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
330
369
  const protocol = requireProtocol(context, input.protocolId, project.id);
331
370
  const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
@@ -335,14 +374,18 @@ export function completeMergeJob(context, input) {
335
374
  workerId: input.workerId,
336
375
  workspacePath: input.workspacePath
337
376
  });
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
377
  const state = readProtocolRuntimeState(context, protocol).state;
343
378
  const flowContract = flowContractForState(state);
344
379
  const targetStage = flowContract.merge_queue.complete.target_stage;
345
380
  const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
381
+ return { protocol, job, state, flowContract, targetStage, targetStatus };
382
+ }
383
+ export function completeMergeJob(context, input) {
384
+ const { protocol, job, state, flowContract, targetStage, targetStatus } = prepareMergeJobCompletion(context, input);
385
+ const now = context.now();
386
+ context.db.run(`UPDATE merge_queue
387
+ SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
388
+ WHERE id = ?`, [input.summary, now, now, job.id]);
346
389
  persistProtocolState(context, protocol, {
347
390
  ...state,
348
391
  stage: targetStage,
@@ -365,7 +408,9 @@ export function completeMergeJob(context, input) {
365
408
  stop_after_current
366
409
  });
367
410
  }
368
- export function completeMergeBundle(context, input) {
411
+ export function prepareMergeBundleCompletion(context, input) {
412
+ requireMergeText(input.summary, "summary");
413
+ requireMergeText(input.workerId, "worker-id");
369
414
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
370
415
  requireLaneLockOwner(context, {
371
416
  projectRoot: project.root,
@@ -380,19 +425,21 @@ export function completeMergeBundle(context, input) {
380
425
  workspace_path: input.workspacePath
381
426
  });
382
427
  }
428
+ const jobs = protocolIds.map((protocolId) => prepareMergeJobCompletion(context, { ...input, protocolId }));
429
+ return { project, protocolIds, jobs };
430
+ }
431
+ export function completeMergeBundle(context, input) {
432
+ const publication = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeBundleCompletion(context, input), value => value.protocolIds, "bundle");
433
+ const prepared = publication.value;
383
434
  const now = context.now();
384
- context.db.exec("BEGIN IMMEDIATE");
385
- 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);
435
+ context.db.writeTransaction(() => {
436
+ publication.assertCurrent();
437
+ const { project, protocolIds, jobs } = prepared;
438
+ for (const { protocol, job, state, flowContract, targetStage, targetStatus } of jobs) {
439
+ const protocolId = protocol.id;
389
440
  context.db.run(`UPDATE merge_queue
390
441
  SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
391
442
  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
443
  persistProtocolState(context, protocol, {
397
444
  ...state,
398
445
  stage: targetStage,
@@ -412,12 +459,8 @@ export function completeMergeBundle(context, input) {
412
459
  eventType: "merge_bundle.completed",
413
460
  payload: { project_id: project.id, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
414
461
  });
415
- context.db.exec("COMMIT");
416
- }
417
- catch (error) {
418
- context.db.exec("ROLLBACK");
419
- throw error;
420
- }
462
+ });
463
+ const { project, protocolIds } = prepared;
421
464
  const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle complete after stop request");
422
465
  return mergeBundleResult(context, {
423
466
  ok: true,
@@ -430,7 +473,9 @@ export function completeMergeBundle(context, input) {
430
473
  stop_after_current
431
474
  });
432
475
  }
433
- export function noteMergeJob(context, input) {
476
+ export function prepareMergeJobNote(context, input) {
477
+ requireMergeText(input.summary, "summary");
478
+ requireMergeText(input.workerId, "worker-id");
434
479
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
435
480
  const protocol = requireProtocol(context, input.protocolId, project.id);
436
481
  const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
@@ -449,6 +494,10 @@ export function noteMergeJob(context, input) {
449
494
  claimed_by_session_id: job.claimed_by_session_id
450
495
  });
451
496
  }
497
+ return { protocol, job };
498
+ }
499
+ export function noteMergeJob(context, input) {
500
+ const { protocol, job } = prepareMergeJobNote(context, input);
452
501
  const now = context.now();
453
502
  context.db.run(`UPDATE merge_queue
454
503
  SET last_reason = ?, updated_at = ?
@@ -461,7 +510,11 @@ export function noteMergeJob(context, input) {
461
510
  });
462
511
  return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "noted" });
463
512
  }
464
- export function failMergeJob(context, input) {
513
+ export function prepareMergeJobFailure(context, input) {
514
+ requireMergeText(input.reason, "reason");
515
+ requireMergeText(input.workerId, "worker-id");
516
+ if (typeof input.requeue !== "boolean")
517
+ throw new AppError("validation", "requeue must be boolean", 2);
465
518
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
466
519
  const protocol = requireProtocol(context, input.protocolId, project.id);
467
520
  const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
@@ -471,13 +524,17 @@ export function failMergeJob(context, input) {
471
524
  workerId: input.workerId,
472
525
  workspacePath: input.workspacePath
473
526
  });
527
+ const state = input.requeue ? readProtocolRuntimeState(context, protocol).state : null;
528
+ return { protocol, job, state };
529
+ }
530
+ export function failMergeJob(context, input) {
531
+ const { protocol, job, state } = prepareMergeJobFailure(context, input);
474
532
  const now = context.now();
475
533
  const status = input.requeue ? "requeued" : "failed";
476
534
  context.db.run(`UPDATE merge_queue
477
535
  SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
478
536
  WHERE id = ?`, [status, input.reason, now, job.id]);
479
- if (input.requeue) {
480
- const state = readProtocolRuntimeState(context, protocol).state;
537
+ if (state) {
481
538
  persistProtocolState(context, protocol, {
482
539
  ...state,
483
540
  stage: "ready_for_merge",
@@ -500,7 +557,11 @@ export function failMergeJob(context, input) {
500
557
  stop_after_current
501
558
  });
502
559
  }
503
- export function failMergeBundle(context, input) {
560
+ export function prepareMergeBundleFailure(context, input) {
561
+ requireMergeText(input.reason, "reason");
562
+ requireMergeText(input.workerId, "worker-id");
563
+ if (typeof input.requeue !== "boolean")
564
+ throw new AppError("validation", "requeue must be boolean", 2);
504
565
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
505
566
  requireLaneLockOwner(context, {
506
567
  projectRoot: project.root,
@@ -515,18 +576,23 @@ export function failMergeBundle(context, input) {
515
576
  workspace_path: input.workspacePath
516
577
  });
517
578
  }
579
+ const jobs = protocolIds.map((protocolId) => prepareMergeJobFailure(context, { ...input, protocolId }));
580
+ return { project, protocolIds, jobs };
581
+ }
582
+ export function failMergeBundle(context, input) {
583
+ const publication = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeBundleFailure(context, input), value => value.protocolIds, "bundle");
584
+ const prepared = publication.value;
518
585
  const now = context.now();
519
586
  const status = input.requeue ? "requeued" : "failed";
520
- context.db.exec("BEGIN IMMEDIATE");
521
- 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);
587
+ context.db.writeTransaction(() => {
588
+ publication.assertCurrent();
589
+ const { project, protocolIds, jobs } = prepared;
590
+ for (const { protocol, job, state } of jobs) {
591
+ const protocolId = protocol.id;
525
592
  context.db.run(`UPDATE merge_queue
526
593
  SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
527
594
  WHERE id = ?`, [status, input.reason, now, job.id]);
528
- if (input.requeue) {
529
- const state = readProtocolRuntimeState(context, protocol).state;
595
+ if (state) {
530
596
  persistProtocolState(context, protocol, {
531
597
  ...state,
532
598
  stage: "ready_for_merge",
@@ -549,12 +615,8 @@ export function failMergeBundle(context, input) {
549
615
  reason: input.reason,
550
616
  payload: { project_id: project.id, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
551
617
  });
552
- context.db.exec("COMMIT");
553
- }
554
- catch (error) {
555
- context.db.exec("ROLLBACK");
556
- throw error;
557
- }
618
+ });
619
+ const { project, protocolIds } = prepared;
558
620
  const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle fail after stop request");
559
621
  return mergeBundleResult(context, {
560
622
  ok: true,
@@ -566,47 +628,58 @@ export function failMergeBundle(context, input) {
566
628
  stop_after_current
567
629
  });
568
630
  }
569
- export function cancelMergeQueueJob(context, input) {
631
+ export function prepareMergeQueueCancellation(context, input) {
632
+ requireMergeText(input.reason, "reason");
633
+ if (input.workerId !== undefined)
634
+ requireMergeText(input.workerId, "worker-id");
635
+ if (typeof input.force !== "boolean")
636
+ throw new AppError("validation", "force must be boolean", 2);
570
637
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
571
638
  const protocol = requireProtocol(context, input.protocolId, project.id);
572
639
  const reason = input.reason.trim();
573
- if (!reason) {
574
- throw new AppError("validation", "merge-queue cancel requires --reason", 2);
640
+ const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
641
+ if (!job) {
642
+ throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
575
643
  }
576
- const now = context.now();
577
- context.db.exec("BEGIN IMMEDIATE");
578
- 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
- }
583
- if (job.status === "cancelled") {
584
- context.db.exec("COMMIT");
585
- return mergeQueueResult(job, { ok: true, outcome: "cancelled", cancelled: false, reason: "already_cancelled" });
586
- }
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
- });
644
+ if (job.status === "cancelled") {
645
+ return { protocol, job, reason };
646
+ }
647
+ if (job.status === "claimed" && !input.force) {
648
+ if (!input.workerId) {
649
+ throw new AppError("merge_job_claimed", "Claimed jobs require --worker-id or --force", 1, { protocol_id: protocol.id });
603
650
  }
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, {
651
+ if (job.claimed_by_session_id !== input.workerId) {
652
+ throw new AppError("merge_job_claimed_by_other_session", "Merge job is claimed by another worker", 1, {
606
653
  protocol_id: protocol.id,
607
- status: job.status
654
+ claimed_by_session_id: job.claimed_by_session_id
608
655
  });
609
656
  }
657
+ requireLaneLockOwner(context, {
658
+ projectRoot: protocol.project_root,
659
+ lane: "merge",
660
+ workerId: input.workerId,
661
+ workspacePath: input.workspacePath
662
+ });
663
+ }
664
+ if (["merged", "failed"].includes(job.status) && !input.force) {
665
+ throw new AppError("merge_job_terminal", `Merge job is ${job.status}; cancellation requires --force --reason`, 1, {
666
+ protocol_id: protocol.id,
667
+ status: job.status
668
+ });
669
+ }
670
+ return { protocol, job, reason };
671
+ }
672
+ export function cancelMergeQueueJob(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;
675
+ const now = context.now();
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);
680
+ if (job.status === "cancelled") {
681
+ return mergeQueueResult(job, { ok: true, outcome: "cancelled", cancelled: false, reason: "already_cancelled" });
682
+ }
610
683
  context.db.run(`UPDATE merge_queue
611
684
  SET status = 'cancelled', last_reason = ?, updated_at = ?
612
685
  WHERE id = ?`, [reason, now, job.id]);
@@ -618,17 +691,19 @@ export function cancelMergeQueueJob(context, input) {
618
691
  forced: input.force,
619
692
  payload: { protocol_id: protocol.id, from: job.status, worker_id: input.workerId ?? null }
620
693
  });
621
- context.db.exec("COMMIT");
622
- }
623
- catch (error) {
624
- context.db.exec("ROLLBACK");
625
- throw error;
626
- }
694
+ return null;
695
+ });
696
+ if (early)
697
+ return early;
627
698
  return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
628
699
  }
629
700
  export function queueForProject(context, projectId) {
630
701
  return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
631
702
  }
703
+ function requireMergeText(value, parameter) {
704
+ if (typeof value !== "string" || !value.trim())
705
+ throw new AppError("validation", `--${parameter} must be nonempty text`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter });
706
+ }
632
707
  function queueJobByProtocol(context, projectId, protocolId) {
633
708
  return context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
634
709
  }
@@ -688,7 +763,7 @@ function requireClaimedJob(context, projectId, protocolId, sessionId) {
688
763
  }
689
764
  return job;
690
765
  }
691
- function transitionClaimedProtocolToIntegration(context, projectId, protocolId, workerId, now) {
766
+ function prepareClaimedProtocolTransition(context, projectId, protocolId) {
692
767
  const protocol = requireProtocol(context, protocolId, projectId);
693
768
  const state = readProtocolRuntimeState(context, protocol).state;
694
769
  const flowContract = flowContractForState(state);
@@ -707,6 +782,10 @@ function transitionClaimedProtocolToIntegration(context, projectId, protocolId,
707
782
  diagnostics: blockingDiagnostics
708
783
  });
709
784
  }
785
+ return { protocol, state, flowContract };
786
+ }
787
+ function transitionClaimedProtocolToIntegration(context, prepared, workerId, now) {
788
+ const { protocol, state, flowContract } = prepared;
710
789
  persistProtocolState(context, protocol, {
711
790
  ...state,
712
791
  stage: "integration",
@@ -715,10 +794,10 @@ function transitionClaimedProtocolToIntegration(context, projectId, protocolId,
715
794
  updated_at: now
716
795
  });
717
796
  appendAudit(context, {
718
- protocolId,
797
+ protocolId: protocol.id,
719
798
  projectId: protocol.project_id,
720
799
  eventType: "protocol.integration_claimed",
721
- payload: { protocol_id: protocolId, worker_id: workerId, flow_contract_id: flowContract.id }
800
+ payload: { protocol_id: protocol.id, worker_id: workerId, flow_contract_id: flowContract.id }
722
801
  });
723
802
  }
724
803
  function releaseLaneLockIfOwned(context, input) {