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

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 (47) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +76 -5
  3. package/dist/build-info.json +10 -6
  4. package/dist/cli/help.js +165 -20
  5. package/dist/cli/run-cli.js +427 -17
  6. package/dist/schemas/compatibility.schema.json +105 -0
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +90 -0
  9. package/dist/schemas/flow-run-index.schema.json +30 -4
  10. package/dist/schemas/global-dashboard-data.schema.json +126 -0
  11. package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
  12. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  13. package/dist/schemas/plan-stage-report.schema.json +83 -0
  14. package/dist/schemas/project-dashboard-data.schema.json +122 -0
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
  16. package/dist/schemas/project-summary.schema.json +73 -0
  17. package/dist/schemas/protocol-dashboard-data.schema.json +112 -0
  18. package/dist/schemas/status-report.schema.json +38 -2
  19. package/dist/schemas/version-report.schema.json +22 -0
  20. package/dist/services/build-info.js +26 -3
  21. package/dist/services/canon.js +93 -22
  22. package/dist/services/cleanup.js +45 -1
  23. package/dist/services/cli-operation-classifier.js +104 -0
  24. package/dist/services/compatibility-preflight.js +124 -0
  25. package/dist/services/config.js +31 -0
  26. package/dist/services/dashboard-targets.js +95 -0
  27. package/dist/services/dashboard.js +972 -10
  28. package/dist/services/engines.js +532 -0
  29. package/dist/services/flow-guidance.js +221 -0
  30. package/dist/services/hooks.js +1 -1
  31. package/dist/services/ids.js +106 -0
  32. package/dist/services/lanes.js +333 -1
  33. package/dist/services/merge-queue.js +106 -16
  34. package/dist/services/merge-worker.js +67 -4
  35. package/dist/services/migrations.js +231 -0
  36. package/dist/services/project-summary.js +122 -0
  37. package/dist/services/projects.js +44 -3
  38. package/dist/services/protocol-lifecycle.js +144 -0
  39. package/dist/services/protocols.js +660 -7
  40. package/dist/services/runs.js +98 -21
  41. package/dist/services/schema-validation.js +84 -4
  42. package/dist/services/sessions.js +21 -4
  43. package/dist/services/status.js +199 -1
  44. package/dist/services/version-status.js +59 -9
  45. package/dist/storage/database.js +31 -0
  46. package/dist/storage/paths.js +33 -0
  47. package/package.json +3 -2
@@ -11,15 +11,20 @@ const defaultPollIntervalSeconds = 10;
11
11
  export function getLaneStatus(context, input) {
12
12
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
13
13
  expireStaleLocks(context, project.id);
14
+ expireStaleWaiters(context, project.id);
14
15
  return {
15
16
  ok: true,
16
17
  lanes: lanesForProject(context, project.id, input.lane),
17
- locks: laneLocksForProject(context, project.id, input.lane)
18
+ locks: laneLocksForProject(context, project.id, input.lane),
19
+ waiters: laneWaitersForProject(context, project.id, input.lane)
18
20
  };
19
21
  }
20
22
  export function expireProjectLaneLocks(context, projectId) {
21
23
  expireStaleLocks(context, projectId);
22
24
  }
25
+ export function expireProjectLaneWaiters(context, projectId) {
26
+ expireStaleWaiters(context, projectId);
27
+ }
23
28
  export function setLaneWorkspace(context, input) {
24
29
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
25
30
  const lane = normalizeLane(input.lane);
@@ -172,6 +177,124 @@ export async function waitForLaneLock(context, input) {
172
177
  await delay(pollIntervalSeconds * 1000);
173
178
  }
174
179
  }
180
+ export function getLaneWaiters(context, input) {
181
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
182
+ expireStaleLocks(context, project.id);
183
+ expireStaleWaiters(context, project.id);
184
+ return {
185
+ ok: true,
186
+ lane: input.lane ? normalizeLane(input.lane) : null,
187
+ waiters: laneWaitersForProject(context, project.id, input.lane).map((waiter) => ({
188
+ ...waiter,
189
+ position: waiter.status === "queued" ? queuedWaiterPosition(context, project.id, waiter.lane, waiter.id) : null
190
+ }))
191
+ };
192
+ }
193
+ export function cancelLaneWaiter(context, input) {
194
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
195
+ const lane = normalizeLane(input.lane);
196
+ const reason = input.reason.trim();
197
+ if (!reason) {
198
+ throw new AppError("validation", "lane waiter cancel requires --reason", 2);
199
+ }
200
+ expireStaleWaiters(context, project.id);
201
+ const now = context.now();
202
+ const waiter = activeLaneWaiterForWorker(context, project.id, lane, input.workerId);
203
+ if (!waiter) {
204
+ return { ok: true, cancelled: false, reason: "no_queued_waiter", lane, worker_id: input.workerId };
205
+ }
206
+ context.db.run(`UPDATE lane_waiters
207
+ SET status = 'cancelled', cancelled_at = ?, reason = ?, updated_at = ?
208
+ WHERE id = ? AND status = 'queued'`, [now, reason, now, waiter.id]);
209
+ appendAudit(context, {
210
+ projectId: project.id,
211
+ eventType: "lane_waiter.cancelled",
212
+ reason,
213
+ payload: { project_id: project.id, lane, worker_id: input.workerId, waiter_id: waiter.id }
214
+ });
215
+ return { ok: true, cancelled: true, waiter: laneWaiterById(context, waiter.id) };
216
+ }
217
+ export function cancelLaneWaitersForWorker(context, input) {
218
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
219
+ const now = context.now();
220
+ const waiters = context.db.all("SELECT * FROM lane_waiters WHERE project_id = ? AND worker_id = ? AND status = 'queued' ORDER BY id ASC", [project.id, input.workerId]);
221
+ if (waiters.length === 0) {
222
+ return { ok: true, cancelled: 0 };
223
+ }
224
+ context.db.run(`UPDATE lane_waiters
225
+ SET status = 'cancelled', cancelled_at = ?, reason = ?, updated_at = ?
226
+ WHERE project_id = ? AND worker_id = ? AND status = 'queued'`, [now, input.reason, now, project.id, input.workerId]);
227
+ appendAudit(context, {
228
+ projectId: project.id,
229
+ eventType: "lane_waiter.cancelled_for_worker",
230
+ reason: input.reason,
231
+ payload: { project_id: project.id, worker_id: input.workerId, waiter_ids: waiters.map((waiter) => waiter.id) }
232
+ });
233
+ return { ok: true, cancelled: waiters.length, waiter_ids: waiters.map((waiter) => waiter.id) };
234
+ }
235
+ export function expireLaneWaiterById(context, input) {
236
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
237
+ const now = context.now();
238
+ const result = context.db.run(`UPDATE lane_waiters
239
+ SET status = 'expired', reason = ?, updated_at = ?
240
+ WHERE project_id = ? AND id = ? AND status = 'queued'`, [input.reason, now, project.id, input.waiterId]);
241
+ if (result.changes === 1) {
242
+ appendAudit(context, {
243
+ projectId: project.id,
244
+ eventType: "lane_waiter.expired",
245
+ reason: input.reason,
246
+ payload: { project_id: project.id, waiter_id: input.waiterId }
247
+ });
248
+ }
249
+ return { ok: true, expired: result.changes === 1, waiter_id: input.waiterId };
250
+ }
251
+ export async function waitAcquireLaneLock(context, input) {
252
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
253
+ const lane = normalizeLane(input.lane);
254
+ requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
255
+ assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
256
+ const timeoutSeconds = normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout");
257
+ const pollIntervalSeconds = normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval");
258
+ const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
259
+ const startedAt = Date.now();
260
+ const waiterDeadline = timeoutSeconds > 0 ? new Date(startedAt + timeoutSeconds * 1000).toISOString() : null;
261
+ while (true) {
262
+ const result = tryAcquireQueuedLaneLock(context, {
263
+ projectRoot: project.root,
264
+ projectId: project.id,
265
+ lane,
266
+ workerId: input.workerId,
267
+ workspacePath: input.workspacePath,
268
+ ttlSeconds,
269
+ reason: input.reason,
270
+ waiterDeadline,
271
+ invocationStartedAt: new Date(startedAt).toISOString()
272
+ });
273
+ if (result.acquired) {
274
+ return result.payload;
275
+ }
276
+ if (result.payload.status === "expired" && timeoutSeconds > 0 && Date.now() - startedAt >= timeoutSeconds * 1000) {
277
+ return markLaneWaiterTimedOut(context, {
278
+ projectId: project.id,
279
+ lane,
280
+ workerId: input.workerId,
281
+ timeoutSeconds
282
+ });
283
+ }
284
+ if (result.payload.status === "cancelled" || result.payload.status === "expired") {
285
+ return result.payload;
286
+ }
287
+ if (timeoutSeconds > 0 && Date.now() - startedAt >= timeoutSeconds * 1000) {
288
+ return markLaneWaiterTimedOut(context, {
289
+ projectId: project.id,
290
+ lane,
291
+ workerId: input.workerId,
292
+ timeoutSeconds
293
+ });
294
+ }
295
+ await delay(pollIntervalSeconds * 1000);
296
+ }
297
+ }
175
298
  export function requireLaneLockOwner(context, input) {
176
299
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
177
300
  const lane = normalizeLane(input.lane);
@@ -236,12 +359,85 @@ function laneLocksForProject(context, projectId, lane) {
236
359
  }
237
360
  return context.db.all(`SELECT * FROM lane_locks WHERE project_id = ? ORDER BY updated_at DESC, id DESC LIMIT 50`, [projectId]);
238
361
  }
362
+ function laneWaitersForProject(context, projectId, lane) {
363
+ if (lane) {
364
+ return context.db.all(`SELECT * FROM lane_waiters
365
+ WHERE project_id = ? AND lane = ?
366
+ ORDER BY
367
+ CASE status WHEN 'queued' THEN 0 ELSE 1 END ASC,
368
+ CASE status WHEN 'queued' THEN queued_at ELSE updated_at END ASC,
369
+ id ASC
370
+ LIMIT 50`, [projectId, normalizeLane(lane)]);
371
+ }
372
+ return context.db.all(`SELECT * FROM lane_waiters
373
+ WHERE project_id = ?
374
+ ORDER BY
375
+ CASE status WHEN 'queued' THEN 0 ELSE 1 END ASC,
376
+ CASE status WHEN 'queued' THEN queued_at ELSE updated_at END ASC,
377
+ id ASC
378
+ LIMIT 100`, [projectId]);
379
+ }
239
380
  function activeLaneLock(context, projectId, lane) {
240
381
  expireStaleLocks(context, projectId);
241
382
  return context.db.get(`SELECT * FROM lane_locks
242
383
  WHERE project_id = ? AND lane = ? AND status = 'active'
243
384
  ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, lane]);
244
385
  }
386
+ function activeLaneWaiterForWorker(context, projectId, lane, workerId) {
387
+ return context.db.get(`SELECT * FROM lane_waiters
388
+ WHERE project_id = ? AND lane = ? AND worker_id = ? AND status = 'queued'
389
+ ORDER BY id ASC LIMIT 1`, [projectId, lane, workerId]);
390
+ }
391
+ function latestLaneWaiterForWorker(context, projectId, lane, workerId) {
392
+ return context.db.get(`SELECT * FROM lane_waiters
393
+ WHERE project_id = ? AND lane = ? AND worker_id = ?
394
+ ORDER BY id DESC LIMIT 1`, [projectId, lane, workerId]);
395
+ }
396
+ function firstQueuedLaneWaiter(context, projectId, lane) {
397
+ return context.db.get(`SELECT * FROM lane_waiters
398
+ WHERE project_id = ? AND lane = ? AND status = 'queued'
399
+ ORDER BY queued_at ASC, id ASC LIMIT 1`, [projectId, lane]);
400
+ }
401
+ function laneWaiterById(context, id) {
402
+ return context.db.get("SELECT * FROM lane_waiters WHERE id = ?", [id]);
403
+ }
404
+ function queuedWaiterPosition(context, projectId, lane, waiterId) {
405
+ const waiters = context.db.all(`SELECT id FROM lane_waiters
406
+ WHERE project_id = ? AND lane = ? AND status = 'queued'
407
+ ORDER BY queued_at ASC, id ASC`, [projectId, lane]);
408
+ const index = waiters.findIndex((waiter) => waiter.id === waiterId);
409
+ return index >= 0 ? index + 1 : null;
410
+ }
411
+ function markLaneWaiterTimedOut(context, input) {
412
+ const now = context.now();
413
+ const latest = latestLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId);
414
+ if (latest && ["queued", "expired"].includes(latest.status)) {
415
+ context.db.run(`UPDATE lane_waiters
416
+ SET status = 'timed_out', reason = ?, updated_at = ?
417
+ WHERE id = ? AND status IN ('queued', 'expired')`, ["lane lock wait-acquire timeout", now, latest.id]);
418
+ }
419
+ appendAudit(context, {
420
+ projectId: input.projectId,
421
+ eventType: "lane_waiter.wait_timeout",
422
+ payload: {
423
+ project_id: input.projectId,
424
+ lane: input.lane,
425
+ worker_id: input.workerId,
426
+ timeout_seconds: input.timeoutSeconds,
427
+ waiter_id: latest?.id ?? null
428
+ }
429
+ });
430
+ return {
431
+ ok: true,
432
+ acquired: false,
433
+ status: "timed_out",
434
+ timed_out: true,
435
+ lane: input.lane,
436
+ worker_id: input.workerId,
437
+ waiter: latestLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId) ?? null,
438
+ lock: activeLaneLock(context, input.projectId, input.lane) ?? null
439
+ };
440
+ }
245
441
  function requireOwnedActiveLock(context, projectId, lane, workerId, leaseToken) {
246
442
  const lock = activeLaneLock(context, projectId, lane);
247
443
  if (!lock) {
@@ -288,6 +484,142 @@ function expireStaleLocks(context, projectId) {
288
484
  SET status = 'expired', updated_at = ?
289
485
  WHERE project_id = ? AND status = 'active' AND expires_at <= ?`, [now, projectId, now]);
290
486
  }
487
+ function expireStaleWaiters(context, projectId) {
488
+ const now = context.now();
489
+ context.db.run(`UPDATE lane_waiters
490
+ SET status = 'expired', updated_at = ?
491
+ WHERE project_id = ? AND status = 'queued' AND expires_at IS NOT NULL AND expires_at <= ?`, [now, projectId, now]);
492
+ }
493
+ function ensureQueuedLaneWaiter(context, input) {
494
+ const existing = activeLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId);
495
+ if (existing) {
496
+ return existing;
497
+ }
498
+ const now = context.now();
499
+ context.db.run(`INSERT INTO lane_waiters
500
+ (project_id, lane, worker_id, status, queued_at, acquired_at, cancelled_at,
501
+ expires_at, reason, metadata_json, created_at, updated_at)
502
+ VALUES (?, ?, ?, 'queued', ?, NULL, NULL, ?, ?, '{}', ?, ?)`, [input.projectId, input.lane, input.workerId, now, input.expiresAt, input.reason, now, now]);
503
+ const waiter = activeLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId);
504
+ if (!waiter) {
505
+ throw new AppError("lane_waiter_create_failed", "Failed to create lane waiter", 1, {
506
+ lane: input.lane,
507
+ worker_id: input.workerId
508
+ });
509
+ }
510
+ appendAudit(context, {
511
+ projectId: input.projectId,
512
+ eventType: "lane_waiter.queued",
513
+ reason: input.reason,
514
+ payload: { project_id: input.projectId, lane: input.lane, worker_id: input.workerId, waiter_id: waiter.id }
515
+ });
516
+ return waiter;
517
+ }
518
+ function tryAcquireQueuedLaneLock(context, input) {
519
+ expireStaleLocks(context, input.projectId);
520
+ expireStaleWaiters(context, input.projectId);
521
+ context.db.exec("BEGIN IMMEDIATE");
522
+ try {
523
+ const existingLock = activeLaneLock(context, input.projectId, input.lane);
524
+ if (existingLock?.worker_id === input.workerId) {
525
+ context.db.exec("COMMIT");
526
+ return {
527
+ acquired: true,
528
+ payload: {
529
+ ok: true,
530
+ acquired: true,
531
+ status: "acquired",
532
+ lane: input.lane,
533
+ worker_id: input.workerId,
534
+ waiter: null,
535
+ lock: existingLock,
536
+ reused: true,
537
+ position: 0
538
+ }
539
+ };
540
+ }
541
+ const latest = latestLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId);
542
+ if (latest && ["cancelled", "expired"].includes(latest.status) && latest.updated_at >= input.invocationStartedAt) {
543
+ context.db.exec("COMMIT");
544
+ return {
545
+ acquired: false,
546
+ payload: {
547
+ ok: true,
548
+ acquired: false,
549
+ status: latest.status,
550
+ lane: input.lane,
551
+ worker_id: input.workerId,
552
+ waiter: latest,
553
+ lock: existingLock ?? null,
554
+ position: null
555
+ }
556
+ };
557
+ }
558
+ const waiter = ensureQueuedLaneWaiter(context, {
559
+ projectId: input.projectId,
560
+ lane: input.lane,
561
+ workerId: input.workerId,
562
+ reason: input.reason,
563
+ expiresAt: input.waiterDeadline
564
+ });
565
+ const head = firstQueuedLaneWaiter(context, input.projectId, input.lane);
566
+ if (!existingLock && head?.id === waiter.id) {
567
+ const lockResult = acquireLaneLock(context, {
568
+ projectRoot: input.projectRoot,
569
+ lane: input.lane,
570
+ workerId: input.workerId,
571
+ workspacePath: input.workspacePath,
572
+ ttlSeconds: input.ttlSeconds,
573
+ reason: input.reason
574
+ });
575
+ const now = context.now();
576
+ context.db.run(`UPDATE lane_waiters
577
+ SET status = 'acquired', acquired_at = ?, updated_at = ?
578
+ WHERE id = ? AND status = 'queued'`, [now, now, waiter.id]);
579
+ const acquiredWaiter = laneWaiterById(context, waiter.id);
580
+ appendAudit(context, {
581
+ projectId: input.projectId,
582
+ eventType: "lane_waiter.acquired",
583
+ reason: input.reason,
584
+ payload: { project_id: input.projectId, lane: input.lane, worker_id: input.workerId, waiter_id: waiter.id }
585
+ });
586
+ context.db.exec("COMMIT");
587
+ return {
588
+ acquired: true,
589
+ payload: {
590
+ ok: true,
591
+ acquired: true,
592
+ status: "acquired",
593
+ lane: input.lane,
594
+ worker_id: input.workerId,
595
+ waiter: acquiredWaiter,
596
+ lock: lockResult.lock,
597
+ reused: lockResult.reused,
598
+ position: 0
599
+ }
600
+ };
601
+ }
602
+ const position = queuedWaiterPosition(context, input.projectId, input.lane, waiter.id);
603
+ context.db.exec("COMMIT");
604
+ return {
605
+ acquired: false,
606
+ payload: {
607
+ ok: true,
608
+ acquired: false,
609
+ status: "queued",
610
+ lane: input.lane,
611
+ worker_id: input.workerId,
612
+ waiter,
613
+ lock: existingLock ?? null,
614
+ position
615
+ }
616
+ };
617
+ }
618
+ catch (error) {
619
+ context.db.exec("ROLLBACK");
620
+ throw error;
621
+ }
622
+ }
291
623
  function resolveExistingPath(value) {
292
624
  const absolute = path.resolve(value);
293
625
  if (!fs.existsSync(absolute)) {
@@ -2,9 +2,9 @@ import { loadProjectFlowContract } from "../domain/flow-contract.js";
2
2
  import { AppError } from "../shared/errors.js";
3
3
  import { appendAudit } from "./audit.js";
4
4
  import { requireProjectByRoot } from "./projects.js";
5
- import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
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
9
  const mergeLockTtlSeconds = 300;
10
10
  export function getMergeQueueStatus(context, input) {
@@ -40,14 +40,14 @@ export function claimNextMergeJob(context, input) {
40
40
  LIMIT 1`, [project.id]);
41
41
  if (!job) {
42
42
  context.db.exec("COMMIT");
43
- return { ok: true, job: null };
43
+ return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
44
44
  }
45
45
  const update = context.db.run(`UPDATE merge_queue
46
46
  SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
47
47
  WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
48
48
  if (update.changes !== 1) {
49
49
  context.db.exec("COMMIT");
50
- return { ok: true, job: null };
50
+ return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
51
51
  }
52
52
  appendAudit(context, {
53
53
  protocolId: job.protocol_id,
@@ -63,7 +63,11 @@ export function claimNextMergeJob(context, input) {
63
63
  context.db.exec("ROLLBACK");
64
64
  throw error;
65
65
  }
66
- return { ok: true, job: claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null };
66
+ return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null, {
67
+ ok: true,
68
+ outcome: claimedProtocolId ? "claimed" : "empty",
69
+ claimed: Boolean(claimedProtocolId)
70
+ });
67
71
  }
68
72
  export async function waitNextMergeJob(context, input) {
69
73
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -82,19 +86,43 @@ export async function waitNextMergeJob(context, input) {
82
86
  }
83
87
  const initialStopState = stoppedMergeWorkerState(context, project.id, input.workerId);
84
88
  if (initialStopState.stopped) {
85
- return { ok: true, job: null, waited: false, timed_out: false, stopped: true, reason: initialStopState.reason };
89
+ return mergeQueueResult(null, {
90
+ ok: true,
91
+ outcome: "stopped",
92
+ wait_requested: input.acquireLock,
93
+ waited: false,
94
+ timed_out: false,
95
+ stopped: true,
96
+ reason: initialStopState.reason
97
+ });
86
98
  }
87
99
  let releaseLockOnTimeout = false;
88
100
  if (input.acquireLock) {
89
101
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath: input.workspacePath });
90
- const lockResult = acquireLaneLock(context, {
102
+ const lockResult = (await waitAcquireLaneLock(context, {
91
103
  projectRoot: project.root,
92
104
  lane: "merge",
93
105
  workerId: input.workerId,
94
106
  workspacePath: input.workspacePath,
95
107
  ttlSeconds: mergeLockTtlSeconds,
108
+ timeoutSeconds,
109
+ pollIntervalSeconds,
96
110
  reason: "merge queue wait-next"
97
- });
111
+ }));
112
+ if (!lockResult.acquired) {
113
+ const outcome = lockResult.timed_out ? "wait_timeout" : "blocked";
114
+ return mergeQueueResult(null, {
115
+ ok: true,
116
+ outcome,
117
+ wait_requested: true,
118
+ waited: true,
119
+ timed_out: Boolean(lockResult.timed_out),
120
+ lane_waiter: lockResult.waiter ?? null,
121
+ lane_lock: lockResult.lock ?? null,
122
+ lane_waiter_status: lockResult.status ?? null,
123
+ position: lockResult.position ?? null
124
+ });
125
+ }
98
126
  releaseLockOnTimeout = !lockResult.reused;
99
127
  }
100
128
  else {
@@ -124,7 +152,15 @@ export async function waitNextMergeJob(context, input) {
124
152
  ...(stopState.reason ? { reason: stopState.reason } : {}),
125
153
  payload: { project_id: project.id, worker_id: input.workerId }
126
154
  });
127
- return { ok: true, job: null, waited: true, timed_out: false, stopped: true, reason: stopState.reason };
155
+ return mergeQueueResult(null, {
156
+ ok: true,
157
+ outcome: "stopped",
158
+ wait_requested: input.acquireLock,
159
+ waited: true,
160
+ timed_out: false,
161
+ stopped: true,
162
+ reason: stopState.reason
163
+ });
128
164
  }
129
165
  if (input.acquireLock) {
130
166
  heartbeatLaneLock(context, {
@@ -141,7 +177,14 @@ export async function waitNextMergeJob(context, input) {
141
177
  workspacePath: input.workspacePath
142
178
  });
143
179
  if (claimed.job) {
144
- return { ...claimed, waited: true, timed_out: false };
180
+ return {
181
+ ...claimed,
182
+ outcome: "claimed",
183
+ claimed: true,
184
+ wait_requested: input.acquireLock,
185
+ waited: true,
186
+ timed_out: false
187
+ };
145
188
  }
146
189
  if (timeoutSeconds > 0 && Date.now() - start >= timeoutSeconds * 1000) {
147
190
  appendAudit(context, {
@@ -158,7 +201,13 @@ export async function waitNextMergeJob(context, input) {
158
201
  reason: "merge queue wait-next timeout"
159
202
  });
160
203
  }
161
- return { ok: true, job: null, waited: true, timed_out: true };
204
+ return mergeQueueResult(null, {
205
+ ok: true,
206
+ outcome: "wait_timeout",
207
+ wait_requested: input.acquireLock,
208
+ waited: true,
209
+ timed_out: true
210
+ });
162
211
  }
163
212
  await delay(pollIntervalSeconds * 1000);
164
213
  }
@@ -194,7 +243,13 @@ export function completeMergeJob(context, input) {
194
243
  payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
195
244
  });
196
245
  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 };
246
+ return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
247
+ ok: true,
248
+ outcome: "merged",
249
+ merged: true,
250
+ next_stage: targetStage,
251
+ stop_after_current
252
+ });
198
253
  }
199
254
  export function noteMergeJob(context, input) {
200
255
  const protocol = requireProtocol(context, input.protocolId);
@@ -224,7 +279,7 @@ export function noteMergeJob(context, input) {
224
279
  eventType: "merge_queue.note",
225
280
  payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary }
226
281
  });
227
- return { ok: true, job: queueJobByProtocol(context, protocol.id) };
282
+ return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "noted" });
228
283
  }
229
284
  export function failMergeJob(context, input) {
230
285
  const protocol = requireProtocol(context, input.protocolId);
@@ -258,7 +313,11 @@ export function failMergeJob(context, input) {
258
313
  payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
259
314
  });
260
315
  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 };
316
+ return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
317
+ ok: true,
318
+ outcome: input.requeue ? "requeued" : "failed",
319
+ stop_after_current
320
+ });
262
321
  }
263
322
  export function cancelMergeQueueJob(context, input) {
264
323
  const protocol = requireProtocol(context, input.protocolId);
@@ -275,7 +334,7 @@ export function cancelMergeQueueJob(context, input) {
275
334
  }
276
335
  if (job.status === "cancelled") {
277
336
  context.db.exec("COMMIT");
278
- return { ok: true, job, cancelled: false, reason: "already_cancelled" };
337
+ return mergeQueueResult(job, { ok: true, outcome: "cancelled", cancelled: false, reason: "already_cancelled" });
279
338
  }
280
339
  if (job.status === "claimed" && !input.force) {
281
340
  if (!input.workerId) {
@@ -317,7 +376,7 @@ export function cancelMergeQueueJob(context, input) {
317
376
  context.db.exec("ROLLBACK");
318
377
  throw error;
319
378
  }
320
- return { ok: true, job: queueJobByProtocol(context, protocol.id), cancelled: true };
379
+ return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
321
380
  }
322
381
  export function queueForProject(context, projectId) {
323
382
  return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
@@ -325,6 +384,29 @@ export function queueForProject(context, projectId) {
325
384
  function queueJobByProtocol(context, protocolId) {
326
385
  return context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocolId]);
327
386
  }
387
+ function mergeQueueResult(job, extra) {
388
+ const queueItem = job ?? null;
389
+ return {
390
+ ...extra,
391
+ queue_item: queueItem,
392
+ protocol: queueItem
393
+ ? {
394
+ id: queueItem.protocol_id,
395
+ queue_status: queueItem.status,
396
+ project_id: queueItem.project_id
397
+ }
398
+ : null,
399
+ claim: queueItem?.claimed_by_session_id
400
+ ? {
401
+ protocol_id: queueItem.protocol_id,
402
+ worker_id: queueItem.claimed_by_session_id,
403
+ claimed_at: queueItem.claimed_at,
404
+ status: queueItem.status
405
+ }
406
+ : null,
407
+ job: queueItem
408
+ };
409
+ }
328
410
  function requireClaimedJob(context, protocolId, sessionId) {
329
411
  const job = queueJobByProtocol(context, protocolId);
330
412
  if (!job || job.status !== "claimed") {
@@ -349,6 +431,14 @@ function transitionClaimedProtocolToIntegration(context, protocolId, workerId, n
349
431
  allowed: ["ready_for_merge", "queued_for_merge"]
350
432
  });
351
433
  }
434
+ const diagnostics = protocolRunDiagnostics(context, protocol, state).diagnostics;
435
+ const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
436
+ if (blockingDiagnostics.length > 0) {
437
+ throw new AppError("merge_protocol_run_mismatch", "Cannot claim a protocol while protocol and run evidence disagree", 1, {
438
+ protocol_id: protocolId,
439
+ diagnostics: blockingDiagnostics
440
+ });
441
+ }
352
442
  persistProtocolState(context, protocol, {
353
443
  ...state,
354
444
  stage: "integration",