@deksden-com/dd-flow-cli 0.1.0 → 0.2.0

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.
@@ -0,0 +1,227 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ const canonRootKey = "canon.root";
5
+ export function registerCanonRoot(context, input) {
6
+ const resolution = resolveCanonRoot(context, { explicitRoot: input.root, allowRegistered: false });
7
+ if (!resolution.ok || !resolution.canon) {
8
+ return {
9
+ ...resolution,
10
+ action: "register",
11
+ exit_code: 1
12
+ };
13
+ }
14
+ const now = context.now();
15
+ context.db.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at)
16
+ VALUES (?, ?, ?, ?)
17
+ ON CONFLICT(key) DO UPDATE SET
18
+ value_json = excluded.value_json,
19
+ updated_at = excluded.updated_at`, [canonRootKey, JSON.stringify({ root: resolution.canon.root }), now, now]);
20
+ return {
21
+ ok: true,
22
+ action: "register",
23
+ canon: {
24
+ ...resolution.canon,
25
+ source: "registered"
26
+ },
27
+ db_path: context.db.path
28
+ };
29
+ }
30
+ export function getCanonStatus(context, input = {}) {
31
+ const resolution = resolveCanonRoot(context, { explicitRoot: input.root });
32
+ return {
33
+ ...resolution,
34
+ dd_flow_home: context.ddFlowHome,
35
+ registered: readRegisteredCanonRoot(context)
36
+ };
37
+ }
38
+ export function resolveCanonRoot(context, input = {}) {
39
+ const candidates = canonCandidates(context, input);
40
+ const validCandidates = candidates.filter((candidate) => candidate.valid && candidate.realpath);
41
+ const uniqueValid = uniqueBy(validCandidates, (candidate) => candidate.realpath ?? candidate.root);
42
+ const blockers = candidates.flatMap((candidate) => candidate.blockers);
43
+ if (uniqueValid.length === 1) {
44
+ const candidate = uniqueValid[0];
45
+ if (!candidate) {
46
+ throw new Error("Expected one canonical candidate.");
47
+ }
48
+ const root = candidate.realpath ?? candidate.root;
49
+ const metadata = readCanonMetadata(root);
50
+ return {
51
+ ok: true,
52
+ canon: {
53
+ root,
54
+ source: candidate.source,
55
+ flow_root: path.join(root, "dd-flow"),
56
+ memorybank_root: root,
57
+ version: metadata.version,
58
+ commit: metadata.commit,
59
+ flow_contract: metadata.flow_contract,
60
+ metadata
61
+ },
62
+ candidates,
63
+ blockers: [],
64
+ bootstrap: []
65
+ };
66
+ }
67
+ if (uniqueValid.length > 1) {
68
+ return {
69
+ ok: false,
70
+ exit_code: 1,
71
+ candidates,
72
+ blockers: [
73
+ "Multiple different canonical Memory Bank roots are configured. Pass --root explicitly or make DD_MEMORYBANK and the registered root point to the same checkout."
74
+ ],
75
+ bootstrap: [
76
+ "dd-flow canon register --root \"$DD_MEMORYBANK\" --json",
77
+ "dd-flow canon resolve --root /absolute/path/to/dd-memorybank --json"
78
+ ]
79
+ };
80
+ }
81
+ return {
82
+ ok: false,
83
+ exit_code: 1,
84
+ candidates,
85
+ blockers: blockers.length > 0 ? blockers : ["Canonical Memory Bank root is not configured."],
86
+ bootstrap: [
87
+ "Clone the canonical dd-memorybank repository locally.",
88
+ "export DD_MEMORYBANK=/absolute/path/to/dd-memorybank",
89
+ "dd-flow canon register --root \"$DD_MEMORYBANK\" --json"
90
+ ]
91
+ };
92
+ }
93
+ function canonCandidates(context, input) {
94
+ const candidates = [];
95
+ if (input.explicitRoot) {
96
+ candidates.push(validateCanonCandidate("explicit", input.explicitRoot));
97
+ return candidates;
98
+ }
99
+ if (context.env.DD_MEMORYBANK) {
100
+ candidates.push(validateCanonCandidate("env", context.env.DD_MEMORYBANK));
101
+ }
102
+ if (input.allowRegistered !== false) {
103
+ const registered = readRegisteredCanonRoot(context);
104
+ if (registered?.root) {
105
+ candidates.push(validateCanonCandidate("registered", registered.root));
106
+ }
107
+ }
108
+ for (const known of knownCanonRoots()) {
109
+ candidates.push(validateCanonCandidate("known", known));
110
+ }
111
+ return uniqueBy(candidates, (candidate) => `${candidate.source}:${candidate.root}`);
112
+ }
113
+ function validateCanonCandidate(source, root) {
114
+ const absolute = path.resolve(root);
115
+ const blockers = [];
116
+ let realpath = null;
117
+ if (!fs.existsSync(absolute)) {
118
+ return { source, root: absolute, realpath, valid: false, blockers: [`${source} canonical root does not exist: ${absolute}`] };
119
+ }
120
+ realpath = fs.realpathSync(absolute);
121
+ if (!fs.statSync(realpath).isDirectory()) {
122
+ blockers.push(`${source} canonical root is not a directory: ${realpath}`);
123
+ }
124
+ const requiredFiles = [
125
+ "dd-flow/README.md",
126
+ "mbb/index.md",
127
+ "protocol/index.md",
128
+ "dd-flow/mb-init.md",
129
+ "dd-flow/mb-upgrade.md",
130
+ "dd-flow/mb-upgrade-review.md",
131
+ "dd-flow/mb-distill.md"
132
+ ];
133
+ for (const relativePath of requiredFiles) {
134
+ const file = path.join(realpath, relativePath);
135
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
136
+ blockers.push(`${source} canonical root is missing ${relativePath}: ${realpath}`);
137
+ }
138
+ }
139
+ return { source, root: absolute, realpath, valid: blockers.length === 0, blockers };
140
+ }
141
+ export function readCanonMetadata(root) {
142
+ const diagnostics = [];
143
+ const version = readTrimmed(path.join(root, "VERSION"));
144
+ if (!version)
145
+ diagnostics.push("canon_version_missing");
146
+ const commit = gitCommit(root);
147
+ if (!commit)
148
+ diagnostics.push("canon_commit_unknown");
149
+ const flowContract = readFlowContractId(root);
150
+ if (!flowContract)
151
+ diagnostics.push("flow_contract_unknown");
152
+ return {
153
+ version,
154
+ commit,
155
+ flow_contract: flowContract,
156
+ status: version && commit && flowContract ? "present" : diagnostics.length > 0 ? "degraded" : "unknown",
157
+ diagnostics
158
+ };
159
+ }
160
+ function readTrimmed(file) {
161
+ try {
162
+ const value = fs.readFileSync(file, "utf8").trim();
163
+ return value.length > 0 ? value : null;
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ }
169
+ function readFlowContractId(root) {
170
+ try {
171
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, "dd-flow", "flow-contract.json"), "utf8"));
172
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
173
+ const id = parsed.id;
174
+ return typeof id === "string" && id.length > 0 ? id : null;
175
+ }
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ return null;
181
+ }
182
+ function gitCommit(root) {
183
+ const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" });
184
+ return result.status === 0 ? result.stdout.trim() || null : null;
185
+ }
186
+ function readRegisteredCanonRoot(context) {
187
+ const row = context.db.get("SELECT key, value_json, updated_at FROM runtime_config WHERE key = ?", [canonRootKey]);
188
+ if (!row)
189
+ return null;
190
+ const value = JSON.parse(row.value_json);
191
+ return typeof value.root === "string" ? { root: value.root, updated_at: row.updated_at } : null;
192
+ }
193
+ function knownCanonRoots() {
194
+ const cwd = process.cwd();
195
+ const candidates = [];
196
+ for (const dir of ancestorDirs(cwd)) {
197
+ if (path.basename(dir) === "dd-memorybank") {
198
+ candidates.push(dir);
199
+ }
200
+ candidates.push(path.join(dir, "dd-memorybank"));
201
+ }
202
+ return uniqueBy(candidates.filter((candidate) => fs.existsSync(candidate)), (candidate) => fs.realpathSync(candidate));
203
+ }
204
+ function ancestorDirs(start) {
205
+ const dirs = [];
206
+ let current = path.resolve(start);
207
+ while (true) {
208
+ dirs.push(current);
209
+ const parent = path.dirname(current);
210
+ if (parent === current)
211
+ break;
212
+ current = parent;
213
+ }
214
+ return dirs;
215
+ }
216
+ function uniqueBy(items, keyFor) {
217
+ const seen = new Set();
218
+ const result = [];
219
+ for (const item of items) {
220
+ const key = keyFor(item);
221
+ if (!seen.has(key)) {
222
+ seen.add(key);
223
+ result.push(item);
224
+ }
225
+ }
226
+ return result;
227
+ }
@@ -17,6 +17,9 @@ export function getLaneStatus(context, input) {
17
17
  locks: laneLocksForProject(context, project.id, input.lane)
18
18
  };
19
19
  }
20
+ export function expireProjectLaneLocks(context, projectId) {
21
+ expireStaleLocks(context, projectId);
22
+ }
20
23
  export function setLaneWorkspace(context, input) {
21
24
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
22
25
  const lane = normalizeLane(input.lane);
@@ -267,10 +270,11 @@ function assertWorkerMayAcquireLaneLock(context, projectId, lane, workerId) {
267
270
  const latest = context.db.get(`SELECT status, stop_reason, flow_kind FROM flow_sessions
268
271
  WHERE project_id = ? AND worker_id = ? AND flow_kind IN ('merge_worker', 'merge_job')
269
272
  ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
270
- if (latest?.status === "stopped") {
271
- throw new AppError("merge_worker_stopped", "Stopped merge worker cannot acquire the merge lane lock until it registers a new active session", 1, {
273
+ if (latest && ["stopped", "stopping"].includes(latest.status)) {
274
+ throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot acquire the merge lane lock until it registers a new active session", 1, {
272
275
  worker_id: workerId,
273
276
  flow_kind: latest.flow_kind,
277
+ status: latest.status,
274
278
  stop_reason: latest.stop_reason
275
279
  });
276
280
  }
@@ -5,7 +5,7 @@ import { requireProjectByRoot } from "./projects.js";
5
5
  import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { acquireLaneLock, ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner } from "./lanes.js";
8
- import { stoppedMergeWorkerState } from "./sessions.js";
8
+ import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
9
9
  const mergeLockTtlSeconds = 300;
10
10
  export function getMergeQueueStatus(context, input) {
11
11
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -16,6 +16,14 @@ export function getMergeQueueStatus(context, input) {
16
16
  }
17
17
  export function claimNextMergeJob(context, input) {
18
18
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
19
+ const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
20
+ if (stopState.stopped) {
21
+ throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim another merge job", 1, {
22
+ worker_id: input.workerId,
23
+ status: stopState.status,
24
+ reason: stopState.reason
25
+ });
26
+ }
19
27
  requireLaneLockOwner(context, {
20
28
  projectRoot: project.root,
21
29
  lane: "merge",
@@ -47,6 +55,7 @@ export function claimNextMergeJob(context, input) {
47
55
  eventType: "merge_queue.claimed",
48
56
  payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
49
57
  });
58
+ transitionClaimedProtocolToIntegration(context, job.protocol_id, input.workerId, now);
50
59
  claimedProtocolId = job.protocol_id;
51
60
  context.db.exec("COMMIT");
52
61
  }
@@ -184,7 +193,8 @@ export function completeMergeJob(context, input) {
184
193
  eventType: "merge_queue.completed",
185
194
  payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
186
195
  });
187
- return { ok: true, job: queueJobByProtocol(context, protocol.id), next_stage: targetStage };
196
+ const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge complete after stop request");
197
+ return { ok: true, job: queueJobByProtocol(context, protocol.id), next_stage: targetStage, stop_after_current };
188
198
  }
189
199
  export function noteMergeJob(context, input) {
190
200
  const protocol = requireProtocol(context, input.protocolId);
@@ -230,6 +240,16 @@ export function failMergeJob(context, input) {
230
240
  context.db.run(`UPDATE merge_queue
231
241
  SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
232
242
  WHERE id = ?`, [status, input.reason, now, job.id]);
243
+ if (input.requeue) {
244
+ const state = readProtocolRuntimeState(context, protocol).state;
245
+ persistProtocolState(context, protocol, {
246
+ ...state,
247
+ stage: "ready_for_merge",
248
+ status: "running",
249
+ next_action: "merge_requeued",
250
+ updated_at: now
251
+ });
252
+ }
233
253
  appendAudit(context, {
234
254
  protocolId: protocol.id,
235
255
  projectId: protocol.project_id,
@@ -237,7 +257,8 @@ export function failMergeJob(context, input) {
237
257
  reason: input.reason,
238
258
  payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
239
259
  });
240
- return { ok: true, job: queueJobByProtocol(context, protocol.id) };
260
+ const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge fail after stop request");
261
+ return { ok: true, job: queueJobByProtocol(context, protocol.id), stop_after_current };
241
262
  }
242
263
  export function cancelMergeQueueJob(context, input) {
243
264
  const protocol = requireProtocol(context, input.protocolId);
@@ -317,6 +338,31 @@ function requireClaimedJob(context, protocolId, sessionId) {
317
338
  }
318
339
  return job;
319
340
  }
341
+ function transitionClaimedProtocolToIntegration(context, protocolId, workerId, now) {
342
+ const protocol = requireProtocol(context, protocolId);
343
+ const state = readProtocolRuntimeState(context, protocol).state;
344
+ const flowContract = loadProjectFlowContract(protocol.project_root);
345
+ if (!["ready_for_merge", "queued_for_merge"].includes(state.stage)) {
346
+ throw new AppError("merge_protocol_not_ready", "Cannot claim a protocol whose runtime state is not ready for merge", 1, {
347
+ protocol_id: protocolId,
348
+ stage: state.stage,
349
+ allowed: ["ready_for_merge", "queued_for_merge"]
350
+ });
351
+ }
352
+ persistProtocolState(context, protocol, {
353
+ ...state,
354
+ stage: "integration",
355
+ status: flowContract.stages.integration?.terminal ? "integration" : "running",
356
+ next_action: "run_integration",
357
+ updated_at: now
358
+ });
359
+ appendAudit(context, {
360
+ protocolId,
361
+ projectId: protocol.project_id,
362
+ eventType: "protocol.integration_claimed",
363
+ payload: { protocol_id: protocolId, worker_id: workerId, flow_contract_id: flowContract.id }
364
+ });
365
+ }
320
366
  function releaseLaneLockIfOwned(context, input) {
321
367
  try {
322
368
  releaseLaneLock(context, input);
@@ -328,6 +374,16 @@ function releaseLaneLockIfOwned(context, input) {
328
374
  throw error;
329
375
  }
330
376
  }
377
+ function stopAfterCurrentIfRequested(context, projectId, projectRoot, workerId, reason) {
378
+ const requested = context.db.get(`SELECT session_id FROM flow_sessions
379
+ WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
380
+ AND status = 'stopping' AND current_stage = 'stop_after_current'
381
+ ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
382
+ if (!requested) {
383
+ return { requested: false };
384
+ }
385
+ return { requested: true, stop: stopMergeWorker(context, { projectRoot, workerId, reason }) };
386
+ }
331
387
  async function delay(milliseconds) {
332
388
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
333
389
  }
@@ -0,0 +1,148 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { resolveProjectRoot } from "../storage/paths.js";
3
+ import { appendAudit } from "./audit.js";
4
+ import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock, expireProjectLaneLocks } from "./lanes.js";
5
+ import { claimNextMergeJob, queueForProject } from "./merge-queue.js";
6
+ import { requireProjectByRoot } from "./projects.js";
7
+ import { registerFlowSession, stopMergeWorker } from "./sessions.js";
8
+ export function getMergeWorkerStatus(context, input) {
9
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
10
+ return {
11
+ ok: true,
12
+ project: { id: project.id, root: project.root },
13
+ merge_worker: detectMergeWorkerState(context, project.id),
14
+ queue: queueForProject(context, project.id)
15
+ };
16
+ }
17
+ export function startMergeWorker(context, input) {
18
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
19
+ const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
20
+ const detection = detectMergeWorkerState(context, project.id);
21
+ if (detection.state !== "clear") {
22
+ return { ok: true, started: false, reason: "merge_worker_already_active", merge_worker: detection, queue: queueForProject(context, project.id) };
23
+ }
24
+ ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
25
+ const registered = registerFlowSession(context, {
26
+ payloadJson: JSON.stringify({
27
+ project_root: project.root,
28
+ flow_kind: "merge_worker",
29
+ protocol_id: null,
30
+ worker_id: input.workerId,
31
+ workspace_path: workspacePath,
32
+ continuation_policy: "merge_queue",
33
+ current_stage: "waiting_for_merge_job",
34
+ next_action: "merge_queue_wait_next"
35
+ })
36
+ });
37
+ appendAudit(context, {
38
+ projectId: project.id,
39
+ eventType: "merge_worker.started",
40
+ payload: { project_id: project.id, worker_id: input.workerId, workspace_path: workspacePath }
41
+ });
42
+ return { ok: true, started: true, session: registered.session, merge_worker: detectMergeWorkerState(context, project.id) };
43
+ }
44
+ export function stopProjectMergeWorker(context, input) {
45
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
46
+ const workers = activeMergeWorkerSessions(context, project.id);
47
+ const targetWorkerId = input.workerId ?? uniqueWorkerId(workers);
48
+ if (!targetWorkerId) {
49
+ return { ok: true, stopped: false, reason: "no_active_merge_worker", merge_worker: detectMergeWorkerState(context, project.id) };
50
+ }
51
+ const targetSessions = workers.filter((session) => session.worker_id === targetWorkerId);
52
+ if (targetSessions.length === 0) {
53
+ throw new AppError("merge_worker_not_found", "Merge worker is not active", 1, { worker_id: targetWorkerId });
54
+ }
55
+ const claimed = claimedJobsForWorker(context, project.id, targetWorkerId);
56
+ if (claimed.length > 0) {
57
+ const now = context.now();
58
+ for (const session of targetSessions) {
59
+ context.db.run(`UPDATE flow_sessions
60
+ SET status = 'stopping', stop_reason = ?, current_stage = ?, next_action = ?, updated_at = ?
61
+ WHERE project_id = ? AND session_id = ?`, [input.reason, "stop_after_current", "finish_current_merge_job_then_stop", now, project.id, session.session_id]);
62
+ }
63
+ appendAudit(context, {
64
+ projectId: project.id,
65
+ eventType: "merge_worker.stop_after_current",
66
+ reason: input.reason,
67
+ payload: { project_id: project.id, worker_id: targetWorkerId, claimed_jobs: claimed.map((job) => job.protocol_id) }
68
+ });
69
+ return { ok: true, stopped: false, stop_after_current: true, worker_id: targetWorkerId, claimed_jobs: claimed, merge_worker: detectMergeWorkerState(context, project.id) };
70
+ }
71
+ return {
72
+ ...stopMergeWorker(context, { projectRoot: project.root, workerId: targetWorkerId, reason: input.reason }),
73
+ stopped: true,
74
+ worker_id: targetWorkerId,
75
+ merge_worker: detectMergeWorkerState(context, project.id)
76
+ };
77
+ }
78
+ export function oneShotMergeClaim(context, input) {
79
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
80
+ const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
81
+ const detection = detectMergeWorkerState(context, project.id);
82
+ if (detection.state !== "clear") {
83
+ return { ok: true, claimed: false, mode: "status_only", reason: "active_merge_worker_or_lock", merge_worker: detection, queue: queueForProject(context, project.id) };
84
+ }
85
+ ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
86
+ const lock = acquireLaneLock(context, {
87
+ projectRoot: project.root,
88
+ lane: "merge",
89
+ workerId: input.workerId,
90
+ workspacePath,
91
+ ttlSeconds: 300,
92
+ reason: "one-shot merge"
93
+ });
94
+ const claimed = claimNextMergeJob(context, { projectRoot: project.root, workerId: input.workerId, workspacePath });
95
+ if (!claimed.job) {
96
+ releaseLaneLock(context, {
97
+ projectRoot: project.root,
98
+ lane: "merge",
99
+ workerId: input.workerId,
100
+ workspacePath,
101
+ reason: "one-shot merge no job"
102
+ });
103
+ }
104
+ return { ok: true, claimed: Boolean(claimed.job), mode: "one_shot", job: claimed.job ?? null, lock: claimed.job ? lock.lock : null };
105
+ }
106
+ function detectMergeWorkerState(context, projectId) {
107
+ expireProjectLaneLocks(context, projectId);
108
+ const workers = activeMergeWorkerSessions(context, projectId);
109
+ const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
110
+ const claimed = context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
111
+ WHERE project_id = ? AND status = 'claimed'
112
+ ORDER BY updated_at DESC, id DESC`, [projectId]);
113
+ const lock = context.db.get(`SELECT worker_id, status, expires_at, reason FROM lane_locks
114
+ WHERE project_id = ? AND lane = 'merge' AND status = 'active'
115
+ ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId]);
116
+ if (workerIds.length > 1) {
117
+ return { state: "blocked", reason: "multiple_active_merge_workers", workers };
118
+ }
119
+ if (workerIds.length === 1) {
120
+ return { state: "active_worker", worker_id: workerIds[0], workers, claimed_jobs: claimed.filter((job) => job.claimed_by_session_id === workerIds[0]), lock: lock ?? null };
121
+ }
122
+ if (claimed.length > 0) {
123
+ return { state: "blocked", reason: "claimed_job_without_active_worker", claimed_jobs: claimed, lock: lock ?? null };
124
+ }
125
+ if (lock) {
126
+ return { state: "blocked", reason: "active_merge_lock_without_worker", lock };
127
+ }
128
+ return { state: "clear" };
129
+ }
130
+ function activeMergeWorkerSessions(context, projectId) {
131
+ return context.db.all(`SELECT session_id, worker_id, status, current_stage, next_action, updated_at FROM flow_sessions
132
+ WHERE project_id = ? AND flow_kind = 'merge_worker' AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
133
+ ORDER BY updated_at DESC, rowid DESC`, [projectId]);
134
+ }
135
+ function claimedJobsForWorker(context, projectId, workerId) {
136
+ return context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
137
+ WHERE project_id = ? AND status = 'claimed' AND claimed_by_session_id = ?
138
+ ORDER BY updated_at DESC, id DESC`, [projectId, workerId]);
139
+ }
140
+ function uniqueWorkerId(workers) {
141
+ const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
142
+ if (workerIds.length === 0)
143
+ return undefined;
144
+ if (workerIds.length > 1) {
145
+ throw new AppError("merge_worker_ambiguous", "Multiple active merge workers exist; pass --worker-id", 1, { worker_ids: workerIds });
146
+ }
147
+ return workerIds[0];
148
+ }
@@ -141,6 +141,18 @@ export function readyForMerge(context, input) {
141
141
  const state = readProtocolRuntimeState(context, protocol).state;
142
142
  const flowContract = flowContractForState(state);
143
143
  const stage = requireStage(state.stage, flowContract);
144
+ const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
145
+ protocol.id
146
+ ]);
147
+ if (existingQueueJob && ["claimed", "merged", "cancelled", "failed"].includes(existingQueueJob.status)) {
148
+ appendAudit(context, {
149
+ protocolId: protocol.id,
150
+ projectId: protocol.project_id,
151
+ eventType: "protocol.ready_for_merge_unchanged",
152
+ payload: { protocol_id: protocol.id, queue_status: existingQueueJob.status, flow_contract_id: flowContract.id }
153
+ });
154
+ return { ok: true, protocol_id: protocol.id, queue_status: existingQueueJob.status, state };
155
+ }
144
156
  if (!flowContract.readiness.allowed_from.includes(stage)) {
145
157
  throw new AppError("readiness_invalid_stage", "Protocol state does not allow ready-for-merge", 1, {
146
158
  stage: state.stage,
@@ -166,18 +178,6 @@ export function readyForMerge(context, input) {
166
178
  const nextStage = flowContract.readiness.target_stage;
167
179
  const nextState = { ...state, stage: nextStage, status: statusForStage(nextStage, flowContract), updated_at: now };
168
180
  persistProtocolState(context, protocol, nextState);
169
- const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
170
- protocol.id
171
- ]);
172
- if (existingQueueJob && ["claimed", "merged", "cancelled", "failed"].includes(existingQueueJob.status)) {
173
- appendAudit(context, {
174
- protocolId: protocol.id,
175
- projectId: protocol.project_id,
176
- eventType: "protocol.ready_for_merge_unchanged",
177
- payload: { protocol_id: protocol.id, queue_status: existingQueueJob.status, flow_contract_id: flowContract.id }
178
- });
179
- return { ok: true, protocol_id: protocol.id, queue_status: existingQueueJob.status, state: nextState };
180
- }
181
181
  context.db.run(`INSERT INTO merge_queue
182
182
  (protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
183
183
  VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
@@ -50,11 +50,10 @@ function resolveSchema(options) {
50
50
  if (options.schemaDir) {
51
51
  candidates.push({ path: path.join(path.resolve(options.schemaDir), fileName), source: "schema_dir" });
52
52
  }
53
- else {
54
- const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
55
- candidates.push({ path: path.join(projectRoot, ".memory-bank", "dd-flow", "schemas", fileName), source: "project" });
56
- candidates.push({ path: path.join(bundledSchemaDir(), fileName), source: "bundled" });
57
- }
53
+ const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
54
+ candidates.push({ path: path.join(projectRoot, ".memory-bank", "dd-flow", "schemas", fileName), source: "project" });
55
+ candidates.push({ path: path.join(projectRoot, "dd-flow", "schemas", fileName), source: "canonical" });
56
+ candidates.push({ path: path.join(bundledSchemaDir(), fileName), source: "bundled" });
58
57
  const found = candidates.find((candidate) => fs.existsSync(candidate.path));
59
58
  if (!found) {
60
59
  throw new AppError("schema_not_found", `Schema not found: ${options.schemaName}`, 2, {
@@ -60,8 +60,9 @@ export function stoppedMergeWorkerState(context, projectId, workerId) {
60
60
  WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
61
61
  ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
62
62
  return {
63
- stopped: latest?.status === "stopped",
64
- reason: latest?.stop_reason ?? null
63
+ stopped: latest ? ["stopped", "stopping"].includes(latest.status) : false,
64
+ reason: latest?.stop_reason ?? null,
65
+ status: latest?.status ?? null
65
66
  };
66
67
  }
67
68
  export function activeFlowSessionsForProject(context, projectId) {