@deksden-com/dd-flow-cli 0.3.0 → 0.4.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +74 -5
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +116 -18
  5. package/dist/cli/run-cli.js +361 -29
  6. package/dist/schemas/compatibility.schema.json +81 -2
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +17 -0
  9. package/dist/schemas/global-dashboard-data.schema.json +60 -2
  10. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  11. package/dist/schemas/project-dashboard-data.schema.json +26 -2
  12. package/dist/schemas/project-summary.schema.json +73 -0
  13. package/dist/schemas/protocol-dashboard-data.schema.json +25 -2
  14. package/dist/schemas/status-report.schema.json +4 -2
  15. package/dist/services/branch-context.js +254 -0
  16. package/dist/services/cleanup.js +31 -0
  17. package/dist/services/cli-operation-classifier.js +104 -0
  18. package/dist/services/compatibility-preflight.js +127 -0
  19. package/dist/services/config.js +6 -0
  20. package/dist/services/dashboard-targets.js +95 -0
  21. package/dist/services/dashboard.js +376 -59
  22. package/dist/services/engines.js +532 -0
  23. package/dist/services/flow-guidance.js +8 -1
  24. package/dist/services/hooks.js +1 -1
  25. package/dist/services/lanes.js +333 -1
  26. package/dist/services/merge-queue.js +310 -15
  27. package/dist/services/merge-worker.js +44 -3
  28. package/dist/services/migrations.js +231 -0
  29. package/dist/services/project-summary.js +122 -0
  30. package/dist/services/projects.js +41 -6
  31. package/dist/services/protocol-lifecycle.js +144 -0
  32. package/dist/services/protocols.js +34 -7
  33. package/dist/services/sessions.js +21 -4
  34. package/dist/services/status.js +10 -0
  35. package/dist/services/version-status.js +39 -15
  36. package/dist/storage/database.js +25 -0
  37. package/dist/storage/paths.js +12 -0
  38. package/package.json +3 -2
@@ -4,8 +4,9 @@ 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 { acquireLaneLock, ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner } from "./lanes.js";
7
+ import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner, waitAcquireLaneLock } from "./lanes.js";
8
8
  import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
9
+ import { getProtocolBranchContext, requireClaimableBranchBundle } from "./branch-context.js";
9
10
  const mergeLockTtlSeconds = 300;
10
11
  export function getMergeQueueStatus(context, input) {
11
12
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -14,6 +15,9 @@ export function getMergeQueueStatus(context, input) {
14
15
  queue: queueForProject(context, project.id)
15
16
  };
16
17
  }
18
+ export function getMergeBundleStatus(context, input) {
19
+ return getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath });
20
+ }
17
21
  export function claimNextMergeJob(context, input) {
18
22
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
19
23
  const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
@@ -40,14 +44,14 @@ export function claimNextMergeJob(context, input) {
40
44
  LIMIT 1`, [project.id]);
41
45
  if (!job) {
42
46
  context.db.exec("COMMIT");
43
- return { ok: true, job: null };
47
+ return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
44
48
  }
45
49
  const update = context.db.run(`UPDATE merge_queue
46
50
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
47
51
  WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
48
52
  if (update.changes !== 1) {
49
53
  context.db.exec("COMMIT");
50
- return { ok: true, job: null };
54
+ return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
51
55
  }
52
56
  appendAudit(context, {
53
57
  protocolId: job.protocol_id,
@@ -63,7 +67,72 @@ export function claimNextMergeJob(context, input) {
63
67
  context.db.exec("ROLLBACK");
64
68
  throw error;
65
69
  }
66
- return { ok: true, job: claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null };
70
+ return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null, {
71
+ ok: true,
72
+ outcome: claimedProtocolId ? "claimed" : "empty",
73
+ claimed: Boolean(claimedProtocolId)
74
+ });
75
+ }
76
+ export function claimMergeBundle(context, input) {
77
+ const { project, protocolIds } = requireClaimableBranchBundle(context, {
78
+ projectRoot: input.projectRoot,
79
+ workspacePath: input.workspacePath
80
+ });
81
+ const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
82
+ if (stopState.stopped) {
83
+ throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim a merge bundle", 1, {
84
+ worker_id: input.workerId,
85
+ status: stopState.status,
86
+ reason: stopState.reason
87
+ });
88
+ }
89
+ requireLaneLockOwner(context, {
90
+ projectRoot: project.root,
91
+ lane: "merge",
92
+ workerId: input.workerId,
93
+ workspacePath: input.workspacePath
94
+ });
95
+ const now = context.now();
96
+ context.db.exec("BEGIN IMMEDIATE");
97
+ try {
98
+ for (const protocolId of protocolIds) {
99
+ const update = context.db.run(`UPDATE merge_queue
100
+ SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
101
+ WHERE protocol_id = ? AND project_id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, protocolId, project.id]);
102
+ if (update.changes !== 1) {
103
+ throw new AppError("merge_bundle_claim_race", "Branch bundle changed while claiming", 1, {
104
+ protocol_id: protocolId,
105
+ protocol_ids: protocolIds
106
+ });
107
+ }
108
+ transitionClaimedProtocolToIntegration(context, protocolId, input.workerId, now);
109
+ appendAudit(context, {
110
+ protocolId,
111
+ projectId: project.id,
112
+ eventType: "merge_bundle.claimed_protocol",
113
+ payload: { protocol_id: protocolId, worker_id: input.workerId, protocol_ids: protocolIds }
114
+ });
115
+ }
116
+ appendAudit(context, {
117
+ projectId: project.id,
118
+ eventType: "merge_bundle.claimed",
119
+ payload: { project_id: project.id, worker_id: input.workerId, protocol_ids: protocolIds, workspace_path: input.workspacePath }
120
+ });
121
+ context.db.exec("COMMIT");
122
+ }
123
+ catch (error) {
124
+ context.db.exec("ROLLBACK");
125
+ throw error;
126
+ }
127
+ return mergeBundleResult(context, {
128
+ ok: true,
129
+ outcome: "claimed",
130
+ claimed: true,
131
+ projectRoot: project.root,
132
+ workspacePath: input.workspacePath,
133
+ workerId: input.workerId,
134
+ protocolIds
135
+ });
67
136
  }
68
137
  export async function waitNextMergeJob(context, input) {
69
138
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -82,19 +151,43 @@ export async function waitNextMergeJob(context, input) {
82
151
  }
83
152
  const initialStopState = stoppedMergeWorkerState(context, project.id, input.workerId);
84
153
  if (initialStopState.stopped) {
85
- return { ok: true, job: null, waited: false, timed_out: false, stopped: true, reason: initialStopState.reason };
154
+ return mergeQueueResult(null, {
155
+ ok: true,
156
+ outcome: "stopped",
157
+ wait_requested: input.acquireLock,
158
+ waited: false,
159
+ timed_out: false,
160
+ stopped: true,
161
+ reason: initialStopState.reason
162
+ });
86
163
  }
87
164
  let releaseLockOnTimeout = false;
88
165
  if (input.acquireLock) {
89
166
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath: input.workspacePath });
90
- const lockResult = acquireLaneLock(context, {
167
+ const lockResult = (await waitAcquireLaneLock(context, {
91
168
  projectRoot: project.root,
92
169
  lane: "merge",
93
170
  workerId: input.workerId,
94
171
  workspacePath: input.workspacePath,
95
172
  ttlSeconds: mergeLockTtlSeconds,
173
+ timeoutSeconds,
174
+ pollIntervalSeconds,
96
175
  reason: "merge queue wait-next"
97
- });
176
+ }));
177
+ if (!lockResult.acquired) {
178
+ const outcome = lockResult.timed_out ? "wait_timeout" : "blocked";
179
+ return mergeQueueResult(null, {
180
+ ok: true,
181
+ outcome,
182
+ wait_requested: true,
183
+ waited: true,
184
+ timed_out: Boolean(lockResult.timed_out),
185
+ lane_waiter: lockResult.waiter ?? null,
186
+ lane_lock: lockResult.lock ?? null,
187
+ lane_waiter_status: lockResult.status ?? null,
188
+ position: lockResult.position ?? null
189
+ });
190
+ }
98
191
  releaseLockOnTimeout = !lockResult.reused;
99
192
  }
100
193
  else {
@@ -124,7 +217,15 @@ export async function waitNextMergeJob(context, input) {
124
217
  ...(stopState.reason ? { reason: stopState.reason } : {}),
125
218
  payload: { project_id: project.id, worker_id: input.workerId }
126
219
  });
127
- return { ok: true, job: null, waited: true, timed_out: false, stopped: true, reason: stopState.reason };
220
+ return mergeQueueResult(null, {
221
+ ok: true,
222
+ outcome: "stopped",
223
+ wait_requested: input.acquireLock,
224
+ waited: true,
225
+ timed_out: false,
226
+ stopped: true,
227
+ reason: stopState.reason
228
+ });
128
229
  }
129
230
  if (input.acquireLock) {
130
231
  heartbeatLaneLock(context, {
@@ -141,7 +242,14 @@ export async function waitNextMergeJob(context, input) {
141
242
  workspacePath: input.workspacePath
142
243
  });
143
244
  if (claimed.job) {
144
- return { ...claimed, waited: true, timed_out: false };
245
+ return {
246
+ ...claimed,
247
+ outcome: "claimed",
248
+ claimed: true,
249
+ wait_requested: input.acquireLock,
250
+ waited: true,
251
+ timed_out: false
252
+ };
145
253
  }
146
254
  if (timeoutSeconds > 0 && Date.now() - start >= timeoutSeconds * 1000) {
147
255
  appendAudit(context, {
@@ -158,7 +266,13 @@ export async function waitNextMergeJob(context, input) {
158
266
  reason: "merge queue wait-next timeout"
159
267
  });
160
268
  }
161
- return { ok: true, job: null, waited: true, timed_out: true };
269
+ return mergeQueueResult(null, {
270
+ ok: true,
271
+ outcome: "wait_timeout",
272
+ wait_requested: input.acquireLock,
273
+ waited: true,
274
+ timed_out: true
275
+ });
162
276
  }
163
277
  await delay(pollIntervalSeconds * 1000);
164
278
  }
@@ -194,7 +308,78 @@ export function completeMergeJob(context, input) {
194
308
  payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
195
309
  });
196
310
  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 };
311
+ return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
312
+ ok: true,
313
+ outcome: "merged",
314
+ merged: true,
315
+ next_stage: targetStage,
316
+ stop_after_current
317
+ });
318
+ }
319
+ export function completeMergeBundle(context, input) {
320
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
321
+ requireLaneLockOwner(context, {
322
+ projectRoot: project.root,
323
+ lane: "merge",
324
+ workerId: input.workerId,
325
+ workspacePath: input.workspacePath
326
+ });
327
+ const protocolIds = claimedBundleProtocolIds(context, { projectRoot: project.root, workspacePath: input.workspacePath, workerId: input.workerId });
328
+ if (protocolIds.length === 0) {
329
+ throw new AppError("merge_bundle_not_claimed", "No claimed protocols found for this branch bundle and worker", 1, {
330
+ worker_id: input.workerId,
331
+ workspace_path: input.workspacePath
332
+ });
333
+ }
334
+ const now = context.now();
335
+ context.db.exec("BEGIN IMMEDIATE");
336
+ try {
337
+ for (const protocolId of protocolIds) {
338
+ const protocol = requireProtocol(context, protocolId);
339
+ const job = requireClaimedJob(context, protocolId, input.workerId);
340
+ context.db.run(`UPDATE merge_queue
341
+ SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
342
+ WHERE id = ?`, [input.summary, now, now, job.id]);
343
+ const state = readProtocolRuntimeState(context, protocol).state;
344
+ const flowContract = loadProjectFlowContract(protocol.project_root);
345
+ const targetStage = flowContract.merge_queue.complete.target_stage;
346
+ const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
347
+ persistProtocolState(context, protocol, {
348
+ ...state,
349
+ stage: targetStage,
350
+ status: targetStatus,
351
+ next_action: flowContract.merge_queue.complete.next_action,
352
+ updated_at: now
353
+ });
354
+ appendAudit(context, {
355
+ protocolId,
356
+ projectId: project.id,
357
+ eventType: "merge_bundle.completed_protocol",
358
+ payload: { protocol_id: protocolId, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
359
+ });
360
+ }
361
+ appendAudit(context, {
362
+ projectId: project.id,
363
+ eventType: "merge_bundle.completed",
364
+ payload: { project_id: project.id, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
365
+ });
366
+ context.db.exec("COMMIT");
367
+ }
368
+ catch (error) {
369
+ context.db.exec("ROLLBACK");
370
+ throw error;
371
+ }
372
+ const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle complete after stop request");
373
+ return mergeBundleResult(context, {
374
+ ok: true,
375
+ outcome: "merged",
376
+ merged: true,
377
+ projectRoot: project.root,
378
+ workspacePath: input.workspacePath,
379
+ workerId: input.workerId,
380
+ protocolIds,
381
+ stop_after_current
382
+ });
198
383
  }
199
384
  export function noteMergeJob(context, input) {
200
385
  const protocol = requireProtocol(context, input.protocolId);
@@ -224,7 +409,7 @@ export function noteMergeJob(context, input) {
224
409
  eventType: "merge_queue.note",
225
410
  payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary }
226
411
  });
227
- return { ok: true, job: queueJobByProtocol(context, protocol.id) };
412
+ return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "noted" });
228
413
  }
229
414
  export function failMergeJob(context, input) {
230
415
  const protocol = requireProtocol(context, input.protocolId);
@@ -258,7 +443,77 @@ export function failMergeJob(context, input) {
258
443
  payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
259
444
  });
260
445
  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 };
446
+ return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
447
+ ok: true,
448
+ outcome: input.requeue ? "requeued" : "failed",
449
+ stop_after_current
450
+ });
451
+ }
452
+ export function failMergeBundle(context, input) {
453
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
454
+ requireLaneLockOwner(context, {
455
+ projectRoot: project.root,
456
+ lane: "merge",
457
+ workerId: input.workerId,
458
+ workspacePath: input.workspacePath
459
+ });
460
+ const protocolIds = claimedBundleProtocolIds(context, { projectRoot: project.root, workspacePath: input.workspacePath, workerId: input.workerId });
461
+ if (protocolIds.length === 0) {
462
+ throw new AppError("merge_bundle_not_claimed", "No claimed protocols found for this branch bundle and worker", 1, {
463
+ worker_id: input.workerId,
464
+ workspace_path: input.workspacePath
465
+ });
466
+ }
467
+ const now = context.now();
468
+ const status = input.requeue ? "requeued" : "failed";
469
+ context.db.exec("BEGIN IMMEDIATE");
470
+ try {
471
+ for (const protocolId of protocolIds) {
472
+ const protocol = requireProtocol(context, protocolId);
473
+ const job = requireClaimedJob(context, protocolId, input.workerId);
474
+ context.db.run(`UPDATE merge_queue
475
+ SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
476
+ WHERE id = ?`, [status, input.reason, now, job.id]);
477
+ if (input.requeue) {
478
+ const state = readProtocolRuntimeState(context, protocol).state;
479
+ persistProtocolState(context, protocol, {
480
+ ...state,
481
+ stage: "ready_for_merge",
482
+ status: "running",
483
+ next_action: "merge_requeued",
484
+ updated_at: now
485
+ });
486
+ }
487
+ appendAudit(context, {
488
+ protocolId,
489
+ projectId: project.id,
490
+ eventType: "merge_bundle.failed_protocol",
491
+ reason: input.reason,
492
+ payload: { protocol_id: protocolId, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
493
+ });
494
+ }
495
+ appendAudit(context, {
496
+ projectId: project.id,
497
+ eventType: "merge_bundle.failed",
498
+ reason: input.reason,
499
+ payload: { project_id: project.id, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
500
+ });
501
+ context.db.exec("COMMIT");
502
+ }
503
+ catch (error) {
504
+ context.db.exec("ROLLBACK");
505
+ throw error;
506
+ }
507
+ const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle fail after stop request");
508
+ return mergeBundleResult(context, {
509
+ ok: true,
510
+ outcome: input.requeue ? "requeued" : "failed",
511
+ projectRoot: project.root,
512
+ workspacePath: input.workspacePath,
513
+ workerId: input.workerId,
514
+ protocolIds,
515
+ stop_after_current
516
+ });
262
517
  }
263
518
  export function cancelMergeQueueJob(context, input) {
264
519
  const protocol = requireProtocol(context, input.protocolId);
@@ -275,7 +530,7 @@ export function cancelMergeQueueJob(context, input) {
275
530
  }
276
531
  if (job.status === "cancelled") {
277
532
  context.db.exec("COMMIT");
278
- return { ok: true, job, cancelled: false, reason: "already_cancelled" };
533
+ return mergeQueueResult(job, { ok: true, outcome: "cancelled", cancelled: false, reason: "already_cancelled" });
279
534
  }
280
535
  if (job.status === "claimed" && !input.force) {
281
536
  if (!input.workerId) {
@@ -317,7 +572,7 @@ export function cancelMergeQueueJob(context, input) {
317
572
  context.db.exec("ROLLBACK");
318
573
  throw error;
319
574
  }
320
- return { ok: true, job: queueJobByProtocol(context, protocol.id), cancelled: true };
575
+ return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
321
576
  }
322
577
  export function queueForProject(context, projectId) {
323
578
  return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
@@ -325,6 +580,46 @@ export function queueForProject(context, projectId) {
325
580
  function queueJobByProtocol(context, protocolId) {
326
581
  return context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocolId]);
327
582
  }
583
+ function mergeQueueResult(job, extra) {
584
+ const queueItem = job ?? null;
585
+ return {
586
+ ...extra,
587
+ queue_item: queueItem,
588
+ protocol: queueItem
589
+ ? {
590
+ id: queueItem.protocol_id,
591
+ queue_status: queueItem.status,
592
+ project_id: queueItem.project_id
593
+ }
594
+ : null,
595
+ claim: queueItem?.claimed_by_session_id
596
+ ? {
597
+ protocol_id: queueItem.protocol_id,
598
+ worker_id: queueItem.claimed_by_session_id,
599
+ claimed_at: queueItem.claimed_at,
600
+ status: queueItem.status
601
+ }
602
+ : null,
603
+ job: queueItem
604
+ };
605
+ }
606
+ function mergeBundleResult(context, input) {
607
+ const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath }).branch_context;
608
+ return {
609
+ ...input,
610
+ protocol_ids: input.protocolIds,
611
+ branch_context: branchContext,
612
+ queue_items: input.protocolIds.map((protocolId) => queueJobByProtocol(context, protocolId) ?? null)
613
+ };
614
+ }
615
+ function claimedBundleProtocolIds(context, input) {
616
+ const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath }).branch_context;
617
+ const protocols = Array.isArray(branchContext.protocols) ? branchContext.protocols : [];
618
+ return protocols
619
+ .filter((protocol) => protocol.queue_status === "claimed" && protocol.queue_claimed_by === input.workerId)
620
+ .map((protocol) => String(protocol.id))
621
+ .filter(Boolean);
622
+ }
328
623
  function requireClaimedJob(context, protocolId, sessionId) {
329
624
  const job = queueJobByProtocol(context, protocolId);
330
625
  if (!job || job.status !== "claimed") {
@@ -7,13 +7,17 @@ import { requireProjectByRoot } from "./projects.js";
7
7
  import { buildStaticFlowGuidance } from "./flow-guidance.js";
8
8
  import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
9
9
  import { registerFlowSession, stopMergeWorker } from "./sessions.js";
10
+ import { getProtocolBranchContext } from "./branch-context.js";
10
11
  export function getMergeWorkerStatus(context, input) {
11
12
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
12
13
  return {
13
14
  ok: true,
14
15
  project: { id: project.id, root: project.root },
15
16
  merge_worker: detectMergeWorkerState(context, project.id),
16
- queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job }))
17
+ queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job })),
18
+ branch_context: input.workspacePath
19
+ ? getProtocolBranchContext(context, { projectRoot: project.root, workspacePath: input.workspacePath }).branch_context
20
+ : null
17
21
  };
18
22
  }
19
23
  export function startMergeWorker(context, input) {
@@ -21,7 +25,17 @@ export function startMergeWorker(context, input) {
21
25
  const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
22
26
  const detection = detectMergeWorkerState(context, project.id);
23
27
  if (detection.state !== "clear") {
24
- return { ok: true, started: false, reason: "merge_worker_already_active", merge_worker: detection, queue: queueForProject(context, project.id) };
28
+ return {
29
+ ok: true,
30
+ started: false,
31
+ claimed: false,
32
+ mode: "status_only",
33
+ outcome: "status_only",
34
+ reason: "merge_worker_already_active",
35
+ merge_worker: detection,
36
+ queue: queueForProject(context, project.id),
37
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context
38
+ };
25
39
  }
26
40
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
27
41
  const registered = registerFlowSession(context, {
@@ -82,7 +96,16 @@ export function oneShotMergeClaim(context, input) {
82
96
  const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
83
97
  const detection = detectMergeWorkerState(context, project.id);
84
98
  if (detection.state !== "clear") {
85
- return { ok: true, claimed: false, mode: "status_only", reason: "active_merge_worker_or_lock", merge_worker: detection, queue: queueForProject(context, project.id) };
99
+ return {
100
+ ok: true,
101
+ claimed: false,
102
+ mode: "status_only",
103
+ outcome: "status_only",
104
+ reason: "active_merge_worker_or_lock",
105
+ merge_worker: detection,
106
+ queue: queueForProject(context, project.id),
107
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context
108
+ };
86
109
  }
87
110
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
88
111
  const lock = acquireLaneLock(context, {
@@ -107,8 +130,26 @@ export function oneShotMergeClaim(context, input) {
107
130
  ok: true,
108
131
  claimed: Boolean(claimed.job),
109
132
  mode: "one_shot",
133
+ outcome: claimed.job ? "claimed" : "empty",
134
+ queue_item: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
135
+ protocol: claimed.job
136
+ ? {
137
+ id: String(claimed.job.protocol_id ?? ""),
138
+ queue_status: String(claimed.job.status ?? ""),
139
+ project_id: String(claimed.job.project_id ?? "")
140
+ }
141
+ : null,
142
+ claim: claimed.job?.claimed_by_session_id
143
+ ? {
144
+ protocol_id: String(claimed.job.protocol_id ?? ""),
145
+ worker_id: String(claimed.job.claimed_by_session_id ?? ""),
146
+ claimed_at: claimed.job.claimed_at ?? null,
147
+ status: claimed.job.status ?? null
148
+ }
149
+ : null,
110
150
  job: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
111
151
  lock: claimed.job ? lock.lock : null,
152
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context,
112
153
  flow_guidance: claimed.job ? withJobGuidance(context, { ...claimed.job }).flow_guidance : undefined
113
154
  };
114
155
  }