switchman-dev 0.1.5 → 0.1.7

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.
package/src/core/queue.js CHANGED
@@ -1,8 +1,35 @@
1
- import { getMergeQueueItem, listMergeQueue, listTasks, listWorktrees, markMergeQueueState, startMergeQueueItem } from './db.js';
2
- import { gitBranchExists, gitMergeBranchInto, gitRebaseOnto } from './git.js';
1
+ import { finishOperationJournalEntry, getMergeQueueItem, getTaskSpec, listDependencyInvalidations, listMergeQueue, listTasks, listWorktrees, markMergeQueueState, startMergeQueueItem, startOperationJournalEntry } from './db.js';
2
+ import { gitAssessBranchFreshness, gitBranchExists, gitMergeBranchInto, gitRebaseOnto } from './git.js';
3
3
  import { runAiMergeGate } from './merge-gate.js';
4
+ import { evaluatePipelinePolicyGate, getPipelineStaleWaveContext, preparePipelineLandingTarget } from './pipeline.js';
4
5
  import { scanAllWorktrees } from './detector.js';
5
6
 
7
+ const QUEUE_RETRY_BACKOFF_BASE_MS = 30_000;
8
+ const QUEUE_RETRY_BACKOFF_MAX_MS = 5 * 60_000;
9
+
10
+ function formatQueueTimestamp(value) {
11
+ const timestamp = Date.parse(String(value || ''));
12
+ if (!Number.isFinite(timestamp)) return null;
13
+ return new Date(timestamp).toISOString();
14
+ }
15
+
16
+ function isQueueBackoffActive(item) {
17
+ const raw = item?.backoff_until;
18
+ if (!raw) return false;
19
+ const timestamp = Date.parse(String(raw));
20
+ return Number.isFinite(timestamp) && timestamp > Date.now();
21
+ }
22
+
23
+ function computeQueueRetryBackoff(item) {
24
+ const retriesUsed = Number(item?.retry_count || 0);
25
+ const delayMs = Math.min(QUEUE_RETRY_BACKOFF_MAX_MS, QUEUE_RETRY_BACKOFF_BASE_MS * (2 ** retriesUsed));
26
+ const backoffUntil = new Date(Date.now() + delayMs).toISOString();
27
+ return {
28
+ delay_ms: delayMs,
29
+ backoff_until: backoffUntil,
30
+ };
31
+ }
32
+
6
33
  function describeQueueError(err) {
7
34
  const message = String(err?.stderr || err?.message || err || '').trim();
8
35
  if (/conflict/i.test(message)) {
@@ -35,14 +62,16 @@ function scheduleRetryOrBlock(db, item, failure) {
35
62
  const retriesUsed = Number(item.retry_count || 0);
36
63
  const maxRetries = Number(item.max_retries || 0);
37
64
  if (failure.retryable && retriesUsed < maxRetries) {
65
+ const backoff = computeQueueRetryBackoff(item);
38
66
  return {
39
67
  status: 'retrying',
40
68
  item: markMergeQueueState(db, item.id, {
41
69
  status: 'retrying',
42
70
  lastErrorCode: failure.code,
43
71
  lastErrorSummary: failure.summary,
44
- nextAction: `Retry ${retriesUsed + 1} of ${maxRetries} scheduled automatically. Run \`switchman queue run\` again after fixing any underlying branch drift if needed.`,
72
+ nextAction: `Retry ${retriesUsed + 1} of ${maxRetries} is waiting until ${backoff.backoff_until}. Run \`switchman queue retry ${item.id}\` to retry sooner after fixing any underlying branch drift.`,
45
73
  incrementRetry: true,
74
+ backoffUntil: backoff.backoff_until,
46
75
  }),
47
76
  };
48
77
  }
@@ -87,9 +116,13 @@ export function resolveQueueSource(db, repoRoot, item) {
87
116
  }
88
117
 
89
118
  if (item.source_type === 'branch') {
119
+ const worktree = listWorktrees(db).find((entry) =>
120
+ (item.source_worktree && entry.name === item.source_worktree)
121
+ || entry.branch === item.source_ref);
90
122
  return {
91
123
  branch: item.source_ref,
92
- worktree: item.source_worktree || null,
124
+ worktree: worktree?.name || item.source_worktree || null,
125
+ worktree_path: worktree?.path || null,
93
126
  pipeline_id: item.source_pipeline_id || null,
94
127
  };
95
128
  }
@@ -108,20 +141,21 @@ export function resolveQueueSource(db, repoRoot, item) {
108
141
  }
109
142
 
110
143
  if (item.source_type === 'pipeline') {
111
- const tasks = listTasks(db).filter((task) => task.id.startsWith(`${item.source_pipeline_id || item.source_ref}-`));
112
- const implementationTask = tasks.find((task) => task.worktree);
113
- if (!implementationTask?.worktree) {
114
- throw new Error(`Pipeline ${item.source_pipeline_id || item.source_ref} has no landed worktree branch to queue.`);
115
- }
116
- const worktree = listWorktrees(db).find((entry) => entry.name === implementationTask.worktree);
117
- if (!worktree) {
118
- throw new Error(`Queued pipeline worktree ${implementationTask.worktree} is not registered.`);
119
- }
144
+ const pipelineId = item.source_pipeline_id || item.source_ref;
145
+ const landingTarget = preparePipelineLandingTarget(db, repoRoot, pipelineId, {
146
+ baseBranch: item.target_branch || 'main',
147
+ requireCompleted: true,
148
+ allowCurrentBranchFallback: false,
149
+ });
150
+ const worktree = landingTarget.worktree
151
+ ? listWorktrees(db).find((entry) => entry.name === landingTarget.worktree) || null
152
+ : null;
153
+
120
154
  return {
121
- branch: worktree.branch,
122
- worktree: worktree.name,
123
- worktree_path: worktree.path,
124
- pipeline_id: item.source_pipeline_id || item.source_ref,
155
+ branch: landingTarget.branch,
156
+ worktree: worktree?.name || null,
157
+ worktree_path: worktree?.path || null,
158
+ pipeline_id: pipelineId,
125
159
  };
126
160
  }
127
161
 
@@ -135,27 +169,515 @@ export function inferQueueNextAction(item) {
135
169
  return null;
136
170
  }
137
171
 
138
- export function buildQueueStatusSummary(items) {
172
+ function summarizeQueueGoalContext(db, item) {
173
+ const pipelineId = item.source_pipeline_id || (item.source_type === 'pipeline' ? item.source_ref : null);
174
+ if (!db || !pipelineId) {
175
+ return {
176
+ pipeline_id: pipelineId,
177
+ goal_priority: null,
178
+ goal_title: null,
179
+ integration_risk: 'normal',
180
+ task_count: 0,
181
+ };
182
+ }
183
+
184
+ const pipelineTasks = listTasks(db)
185
+ .map((task) => ({ ...task, task_spec: getTaskSpec(db, task.id) }))
186
+ .filter((task) => task.task_spec?.pipeline_id === pipelineId);
187
+ const goalPriority = pipelineTasks.reduce((highest, task) => Math.max(highest, Number(task.priority || 0)), 0) || null;
188
+ const goalTitle = pipelineTasks[0]?.title || pipelineId;
189
+ const riskLevels = new Set(pipelineTasks.map((task) => task.task_spec?.risk_level).filter(Boolean));
190
+ const integrationRisk = riskLevels.has('high')
191
+ ? 'high'
192
+ : riskLevels.has('medium')
193
+ ? 'medium'
194
+ : 'normal';
195
+
196
+ return {
197
+ pipeline_id: pipelineId,
198
+ goal_priority: goalPriority,
199
+ goal_title: goalTitle,
200
+ integration_risk: integrationRisk,
201
+ task_count: pipelineTasks.length,
202
+ };
203
+ }
204
+
205
+ function assessQueueCandidate(db, repoRoot, item) {
206
+ if (!db || !repoRoot || !['queued', 'retrying', 'held', 'wave_blocked', 'escalated'].includes(item.status)) {
207
+ return {
208
+ freshness: 'unknown',
209
+ revalidation_state: 'unknown',
210
+ stale_invalidation_count: 0,
211
+ stale_severity: 'clear',
212
+ branch_availability: 'unknown',
213
+ goal_priority: null,
214
+ integration_risk: 'normal',
215
+ priority_score: 99,
216
+ reason: item.status === 'retrying'
217
+ ? 'retrying item waiting for another landing attempt'
218
+ : item.status === 'held'
219
+ ? 'held item waiting for a safe landing window'
220
+ : item.status === 'wave_blocked'
221
+ ? 'wave-blocked item waiting for coordinated revalidation across the same stale wave'
222
+ : item.status === 'escalated'
223
+ ? 'escalated item waiting for operator review'
224
+ : 'queued item waiting to land',
225
+ };
226
+ }
227
+
228
+ try {
229
+ const resolved = resolveQueueSource(db, repoRoot, item);
230
+ const sourceBranchExists = gitBranchExists(repoRoot, resolved.branch);
231
+ const targetBranchExists = gitBranchExists(repoRoot, item.target_branch || 'main');
232
+ if (!sourceBranchExists || !targetBranchExists) {
233
+ return {
234
+ freshness: 'unknown',
235
+ revalidation_state: 'unknown',
236
+ stale_invalidation_count: 0,
237
+ stale_severity: 'clear',
238
+ branch_availability: !sourceBranchExists ? 'source_missing' : 'target_missing',
239
+ goal_priority: null,
240
+ integration_risk: 'normal',
241
+ priority_score: 50,
242
+ reason: !sourceBranchExists
243
+ ? 'source branch is missing, so landing should surface an explicit queue block'
244
+ : 'target branch is missing, so landing should surface an explicit queue block',
245
+ };
246
+ }
247
+ const freshness = gitAssessBranchFreshness(repoRoot, item.target_branch || 'main', resolved.branch);
248
+ const goalContext = summarizeQueueGoalContext(db, item);
249
+ const pipelineId = goalContext.pipeline_id;
250
+ const staleInvalidations = pipelineId
251
+ ? listDependencyInvalidations(db, { pipelineId }).filter((entry) => entry.affected_pipeline_id === pipelineId)
252
+ : [];
253
+ const staleWaveContext = pipelineId
254
+ ? getPipelineStaleWaveContext(db, pipelineId)
255
+ : { shared_wave_count: 0, largest_wave_size: 0, primary_wave: null };
256
+ const statusWeight = item.status === 'queued' ? 0 : 1;
257
+ const freshnessWeight = freshness.state === 'fresh' ? 0 : freshness.state === 'behind' ? 2 : 4;
258
+ const urgencyWeight = goalContext.goal_priority >= 8 ? -2 : goalContext.goal_priority >= 6 ? -1 : 0;
259
+ const staleSeverity = staleInvalidations.some((entry) => entry.severity === 'blocked')
260
+ ? 'block'
261
+ : staleInvalidations.length > 0
262
+ ? 'warn'
263
+ : 'clear';
264
+ const revalidationWeight = staleSeverity === 'block' ? 6 : staleSeverity === 'warn' ? 3 : 0;
265
+ const waveWeight = staleWaveContext.largest_wave_size >= 3 ? 3 : staleWaveContext.largest_wave_size >= 2 ? 2 : 0;
266
+ const integrationWeight = goalContext.integration_risk === 'high' ? 1 : 0;
267
+ const backoffWaiting = item.status === 'retrying' && isQueueBackoffActive(item);
268
+ const backoffWeight = backoffWaiting ? 3 : 0;
269
+ const freshnessReason = freshness.state === 'fresh'
270
+ ? 'fresh branch is most likely to land cleanly next'
271
+ : freshness.state === 'behind'
272
+ ? `branch is behind ${item.target_branch || 'main'}, so fresher queue items land first`
273
+ : 'freshness is unknown, so this item stays behind clearly fresher work';
274
+ const urgencyReason = goalContext.goal_priority >= 8
275
+ ? `goal priority ${goalContext.goal_priority} raises this landing candidate above lower-priority work`
276
+ : goalContext.goal_priority >= 6
277
+ ? `goal priority ${goalContext.goal_priority} gives this candidate a small landing preference`
278
+ : null;
279
+ const revalidationReason = staleSeverity === 'block'
280
+ ? `pipeline ${pipelineId} has stale work to revalidate before it should land`
281
+ : staleSeverity === 'warn'
282
+ ? `pipeline ${pipelineId} has stale work to revalidate, so clearer landing candidates land first`
283
+ : null;
284
+ const waveReason = staleWaveContext.primary_wave && staleWaveContext.largest_wave_size > 1
285
+ ? `the same stale wave also affects ${staleWaveContext.primary_wave.related_affected_pipelines.filter((entry) => entry !== pipelineId).join(', ')}`
286
+ : null;
287
+ const riskReason = goalContext.integration_risk === 'high'
288
+ ? `pipeline ${pipelineId} carries high integration risk and may need escalation if it is not clearly ready`
289
+ : goalContext.integration_risk === 'medium'
290
+ ? `pipeline ${pipelineId} carries moderate integration risk`
291
+ : null;
292
+ const backoffReason = backoffWaiting
293
+ ? `automatic retry backoff is active until ${formatQueueTimestamp(item.backoff_until)}`
294
+ : null;
295
+ return {
296
+ freshness: freshness.state,
297
+ revalidation_state: staleSeverity === 'clear' ? 'clear' : 'stale',
298
+ stale_invalidation_count: staleInvalidations.length,
299
+ stale_severity: staleSeverity,
300
+ stale_wave_count: staleWaveContext.shared_wave_count,
301
+ stale_wave_size: staleWaveContext.largest_wave_size,
302
+ stale_wave_summary: staleWaveContext.primary_wave?.summary || null,
303
+ branch_availability: 'ready',
304
+ goal_priority: goalContext.goal_priority,
305
+ goal_title: goalContext.goal_title,
306
+ integration_risk: goalContext.integration_risk,
307
+ priority_score: freshnessWeight + statusWeight + revalidationWeight + waveWeight + integrationWeight + urgencyWeight + backoffWeight,
308
+ reason: [freshnessReason, urgencyReason, revalidationReason, waveReason, riskReason, backoffReason].filter(Boolean).join('; '),
309
+ freshness_details: freshness,
310
+ backoff_until: item.backoff_until || null,
311
+ backoff_active: backoffWaiting,
312
+ next_action: staleInvalidations.length > 0 && pipelineId
313
+ ? `switchman task retry-stale --pipeline ${pipelineId}`
314
+ : null,
315
+ };
316
+ } catch {
317
+ return {
318
+ freshness: 'unknown',
319
+ revalidation_state: 'unknown',
320
+ stale_invalidation_count: 0,
321
+ stale_severity: 'clear',
322
+ branch_availability: 'unknown',
323
+ goal_priority: null,
324
+ integration_risk: 'normal',
325
+ priority_score: 60,
326
+ reason: 'queue source could not be resolved cleanly yet',
327
+ };
328
+ }
329
+ }
330
+
331
+ function rankQueueItems(items, { db = null, repoRoot = null } = {}) {
332
+ return items
333
+ .filter((item) => ['queued', 'retrying', 'held', 'wave_blocked', 'escalated'].includes(item.status))
334
+ .map((item) => ({
335
+ ...item,
336
+ queue_assessment: assessQueueCandidate(db, repoRoot, item),
337
+ }))
338
+ .sort((left, right) => {
339
+ const scoreDelta = (left.queue_assessment?.priority_score ?? 99) - (right.queue_assessment?.priority_score ?? 99);
340
+ if (scoreDelta !== 0) return scoreDelta;
341
+ return String(left.created_at || '').localeCompare(String(right.created_at || ''));
342
+ });
343
+ }
344
+
345
+ function annotateQueueCandidates(items, { db = null, repoRoot = null } = {}) {
346
+ return rankQueueItems(items, { db, repoRoot }).map((item) => ({
347
+ ...item,
348
+ recommendation: recommendQueueAction(item),
349
+ }));
350
+ }
351
+
352
+ function recommendQueueAction(item) {
353
+ const assessment = item.queue_assessment || {};
354
+ if (item.status === 'retrying') {
355
+ if (assessment.backoff_active) {
356
+ return {
357
+ action: 'retry',
358
+ summary: `wait for retry backoff until ${assessment.backoff_until}, or run \`switchman queue retry ${item.id}\` to retry sooner`,
359
+ command: `switchman queue retry ${item.id}`,
360
+ };
361
+ }
362
+ return {
363
+ action: 'retry',
364
+ summary: item.next_action || 'retry the item after the underlying landing issue is resolved',
365
+ command: 'switchman queue run',
366
+ };
367
+ }
368
+
369
+ if (item.status === 'held' && assessment.stale_invalidation_count > 0) {
370
+ return {
371
+ action: 'hold',
372
+ summary: item.next_action || (assessment.stale_wave_size > 1
373
+ ? `hold for coordinated revalidation: ${assessment.stale_wave_summary || 'the same stale wave'} affects ${assessment.stale_wave_size} goals`
374
+ : assessment.next_action) || 'hold until the stale pipeline work is revalidated',
375
+ command: assessment.next_action || 'switchman queue retry <itemId>',
376
+ };
377
+ }
378
+
379
+ if (item.status === 'wave_blocked' && assessment.stale_invalidation_count > 0) {
380
+ return {
381
+ action: 'hold',
382
+ summary: item.next_action || `hold for coordinated revalidation: ${assessment.stale_wave_summary || 'shared stale wave'} affects ${assessment.stale_wave_size} goals`,
383
+ command: assessment.next_action || 'switchman queue status',
384
+ };
385
+ }
386
+
387
+ if (item.status === 'escalated' && assessment.integration_risk === 'high' && (assessment.stale_invalidation_count > 0 || assessment.freshness !== 'fresh')) {
388
+ return {
389
+ action: 'escalate',
390
+ summary: item.last_error_summary || 'escalate before landing: high-risk work is not clearly ready yet',
391
+ command: item.next_action || `switchman explain queue ${item.id}`,
392
+ };
393
+ }
394
+
395
+ if (assessment.branch_availability === 'source_missing' || assessment.branch_availability === 'target_missing') {
396
+ return {
397
+ action: 'retry',
398
+ summary: assessment.branch_availability === 'source_missing'
399
+ ? 'attempt landing so Switchman can block the missing source branch explicitly'
400
+ : 'attempt landing so Switchman can block the missing target branch explicitly',
401
+ command: 'switchman queue run',
402
+ };
403
+ }
404
+
405
+ if (assessment.integration_risk === 'high' && (assessment.stale_invalidation_count > 0 || assessment.freshness !== 'fresh')) {
406
+ return {
407
+ action: 'escalate',
408
+ summary: assessment.next_action
409
+ ? `escalate before landing: high-risk work is not clearly ready and still needs ${assessment.next_action}`
410
+ : 'escalate before landing: high-risk work is not clearly ready yet',
411
+ command: `switchman explain queue ${item.id}`,
412
+ };
413
+ }
414
+
415
+ if (assessment.stale_invalidation_count > 0) {
416
+ return {
417
+ action: 'hold',
418
+ summary: assessment.stale_wave_size > 1
419
+ ? `hold for coordinated revalidation first: ${assessment.stale_wave_summary || 'shared stale wave'} affects ${assessment.stale_wave_size} goals`
420
+ : assessment.next_action
421
+ ? `hold for revalidation first: ${assessment.next_action}`
422
+ : 'hold until the stale pipeline work is revalidated',
423
+ command: assessment.next_action || 'switchman queue status',
424
+ };
425
+ }
426
+
427
+ if (assessment.freshness === 'behind') {
428
+ return {
429
+ action: 'hold',
430
+ summary: `hold until fresher ${item.target_branch || 'main'} candidates land first`,
431
+ command: 'switchman queue run',
432
+ };
433
+ }
434
+
435
+ if (assessment.freshness === 'unknown') {
436
+ return {
437
+ action: 'hold',
438
+ summary: 'hold until branch freshness can be resolved cleanly',
439
+ command: 'switchman queue status',
440
+ };
441
+ }
442
+
443
+ return {
444
+ action: 'land_now',
445
+ summary: assessment.integration_risk === 'high'
446
+ ? 'land now with elevated integration attention: this is the clearest current high-risk merge candidate'
447
+ : 'land now: this is the clearest current merge candidate',
448
+ command: 'switchman queue run',
449
+ };
450
+ }
451
+
452
+ function classifyQueuePlanLane(item) {
453
+ const action = item.recommendation?.action || 'hold';
454
+ const assessment = item.queue_assessment || {};
455
+
456
+ if (action === 'escalate') {
457
+ return {
458
+ lane: 'escalate',
459
+ summary: item.recommendation?.summary || 'needs operator review before it should land',
460
+ command: item.recommendation?.command || `switchman explain queue ${item.id}`,
461
+ };
462
+ }
463
+
464
+ if (action === 'retry') {
465
+ if (assessment.backoff_active) {
466
+ return {
467
+ lane: 'prepare_next',
468
+ summary: item.recommendation?.summary || 'wait for retry backoff, then retry this landing candidate',
469
+ command: item.recommendation?.command || `switchman queue retry ${item.id}`,
470
+ };
471
+ }
472
+ return {
473
+ lane: 'prepare_next',
474
+ summary: item.recommendation?.summary || 'retry this landing candidate once the immediate issue is cleared',
475
+ command: item.recommendation?.command || 'switchman queue run',
476
+ };
477
+ }
478
+
479
+ if (action === 'land_now') {
480
+ return {
481
+ lane: 'land_now',
482
+ summary: item.recommendation?.summary || 'this is ready to land now',
483
+ command: item.recommendation?.command || 'switchman queue run',
484
+ };
485
+ }
486
+
487
+ if (assessment.stale_invalidation_count > 0) {
488
+ return {
489
+ lane: 'unblock_first',
490
+ summary: item.recommendation?.summary || 'revalidate this goal before it can land',
491
+ command: item.recommendation?.command || assessment.next_action || 'switchman queue status',
492
+ };
493
+ }
494
+
495
+ if (assessment.freshness === 'behind' || assessment.freshness === 'unknown') {
496
+ return {
497
+ lane: 'defer',
498
+ summary: item.recommendation?.summary || 'wait until fresher candidates land first',
499
+ command: item.recommendation?.command || 'switchman queue run',
500
+ };
501
+ }
502
+
503
+ return {
504
+ lane: 'prepare_next',
505
+ summary: item.recommendation?.summary || 'keep this candidate close behind the current landing focus',
506
+ command: item.recommendation?.command || 'switchman queue status',
507
+ };
508
+ }
509
+
510
+ function buildQueueGoalPlan(candidates = []) {
511
+ const lanes = {
512
+ land_now: [],
513
+ prepare_next: [],
514
+ unblock_first: [],
515
+ escalate: [],
516
+ defer: [],
517
+ };
518
+
519
+ for (const item of candidates) {
520
+ const plan = classifyQueuePlanLane(item);
521
+ lanes[plan.lane].push({
522
+ item_id: item.id,
523
+ source_ref: item.source_ref,
524
+ source_type: item.source_type,
525
+ pipeline_id: item.source_pipeline_id || null,
526
+ goal_title: item.queue_assessment?.goal_title || null,
527
+ goal_priority: item.queue_assessment?.goal_priority || null,
528
+ action: item.recommendation?.action || 'hold',
529
+ freshness: item.queue_assessment?.freshness || 'unknown',
530
+ stale_invalidation_count: item.queue_assessment?.stale_invalidation_count || 0,
531
+ integration_risk: item.queue_assessment?.integration_risk || 'normal',
532
+ summary: plan.summary,
533
+ command: plan.command,
534
+ });
535
+ }
536
+
537
+ return lanes;
538
+ }
539
+
540
+ function buildQueueRecommendedSequence(candidates = [], limit = 5) {
541
+ const ordered = [];
542
+ const pushLane = (laneName, items, stage) => {
543
+ for (const item of items) {
544
+ if (ordered.length >= limit) return;
545
+ ordered.push({
546
+ stage,
547
+ lane: laneName,
548
+ item_id: item.item_id,
549
+ source_ref: item.source_ref,
550
+ source_type: item.source_type,
551
+ pipeline_id: item.pipeline_id,
552
+ goal_title: item.goal_title,
553
+ goal_priority: item.goal_priority,
554
+ action: item.action,
555
+ summary: item.summary,
556
+ command: item.command,
557
+ });
558
+ }
559
+ };
560
+
561
+ const plan = buildQueueGoalPlan(candidates);
562
+ pushLane('land_now', plan.land_now, '1');
563
+ pushLane('prepare_next', plan.prepare_next, '2');
564
+ pushLane('unblock_first', plan.unblock_first, '3');
565
+ pushLane('escalate', plan.escalate, '4');
566
+ pushLane('defer', plan.defer, '5');
567
+ return ordered;
568
+ }
569
+
570
+ function chooseNextQueueItem(items, { db = null, repoRoot = null } = {}) {
571
+ const candidates = annotateQueueCandidates(items, { db, repoRoot });
572
+ return candidates[0] || null;
573
+ }
574
+
575
+ function isQueueItemRunnable(item) {
576
+ if (!item?.recommendation?.action) return false;
577
+ if (item.recommendation.action === 'retry' && item.queue_assessment?.backoff_active) {
578
+ return false;
579
+ }
580
+ return ['land_now', 'retry'].includes(item.recommendation.action);
581
+ }
582
+
583
+ function chooseRunnableQueueItem(items, { db = null, repoRoot = null, followPlan = false } = {}) {
584
+ const candidates = annotateQueueCandidates(items, { db, repoRoot });
585
+ if (followPlan) {
586
+ return candidates.find((item) => classifyQueuePlanLane(item).lane === 'land_now' && isQueueItemRunnable(item)) || null;
587
+ }
588
+ return candidates.find((item) => isQueueItemRunnable(item))
589
+ || candidates.find((item) =>
590
+ item.recommendation?.action === 'hold'
591
+ && item.queue_assessment?.stale_invalidation_count === 0
592
+ && item.queue_assessment?.integration_risk !== 'high')
593
+ || null;
594
+ }
595
+
596
+ function syncDeferredQueueState(db, item) {
597
+ if (!item?.recommendation?.action || !['hold', 'escalate'].includes(item.recommendation.action)) {
598
+ return item;
599
+ }
600
+
601
+ const desiredStatus = item.recommendation.action === 'hold'
602
+ ? (item.queue_assessment?.stale_wave_size > 1 ? 'wave_blocked' : 'held')
603
+ : 'escalated';
604
+ const desiredNextAction = item.recommendation.action === 'escalate'
605
+ ? `Run \`switchman explain queue ${item.id}\` to review the landing risk, then \`switchman queue retry ${item.id}\` when it is ready again.`
606
+ : item.queue_assessment?.next_action || item.recommendation.command || null;
607
+ const desiredSummary = item.recommendation.summary || item.queue_assessment?.reason || null;
608
+
609
+ if (
610
+ item.status === desiredStatus
611
+ && (item.next_action || null) === desiredNextAction
612
+ && (item.last_error_summary || null) === desiredSummary
613
+ ) {
614
+ return item;
615
+ }
616
+
617
+ return markMergeQueueState(db, item.id, {
618
+ status: desiredStatus,
619
+ lastErrorCode: desiredStatus === 'wave_blocked' ? 'queue_wave_blocked' : desiredStatus === 'held' ? 'queue_hold' : 'queue_escalated',
620
+ lastErrorSummary: desiredSummary,
621
+ nextAction: desiredNextAction,
622
+ });
623
+ }
624
+
625
+ export function buildQueueStatusSummary(items, { db = null, repoRoot = null } = {}) {
626
+ const rankedCandidates = annotateQueueCandidates(items, { db, repoRoot });
627
+ const plan = buildQueueGoalPlan(rankedCandidates.slice(0, 8));
628
+ const next = rankedCandidates[0]
629
+ || items.find((item) => ['validating', 'rebasing', 'merging'].includes(item.status))
630
+ || null;
139
631
  const counts = {
140
632
  queued: items.filter((item) => item.status === 'queued').length,
141
633
  validating: items.filter((item) => item.status === 'validating').length,
142
634
  rebasing: items.filter((item) => item.status === 'rebasing').length,
143
635
  merging: items.filter((item) => item.status === 'merging').length,
144
636
  retrying: items.filter((item) => item.status === 'retrying').length,
637
+ held: items.filter((item) => item.status === 'held').length,
638
+ wave_blocked: items.filter((item) => item.status === 'wave_blocked').length,
639
+ escalated: items.filter((item) => item.status === 'escalated').length,
145
640
  blocked: items.filter((item) => item.status === 'blocked').length,
146
641
  merged: items.filter((item) => item.status === 'merged').length,
147
642
  };
148
643
 
149
644
  return {
150
645
  counts,
151
- next: items.find((item) => ['queued', 'retrying', 'validating', 'rebasing', 'merging'].includes(item.status)) || null,
646
+ next,
152
647
  blocked: items.filter((item) => item.status === 'blocked'),
648
+ held_back: rankedCandidates.slice(1, 4),
649
+ decision_summary: next?.queue_assessment?.reason || null,
650
+ focus_decision: next?.recommendation || null,
651
+ plan,
652
+ recommended_sequence: buildQueueRecommendedSequence(rankedCandidates.slice(0, 8)),
653
+ recommendations: rankedCandidates.slice(0, 5).map((item) => ({
654
+ item_id: item.id,
655
+ source_ref: item.source_ref,
656
+ source_type: item.source_type,
657
+ action: item.recommendation?.action || 'hold',
658
+ summary: item.recommendation?.summary || null,
659
+ command: item.recommendation?.command || null,
660
+ freshness: item.queue_assessment?.freshness || 'unknown',
661
+ stale_invalidation_count: item.queue_assessment?.stale_invalidation_count || 0,
662
+ stale_wave_count: item.queue_assessment?.stale_wave_count || 0,
663
+ stale_wave_size: item.queue_assessment?.stale_wave_size || 0,
664
+ stale_wave_summary: item.queue_assessment?.stale_wave_summary || null,
665
+ goal_priority: item.queue_assessment?.goal_priority || null,
666
+ integration_risk: item.queue_assessment?.integration_risk || 'normal',
667
+ })),
153
668
  };
154
669
  }
155
670
 
156
- export async function runNextQueueItem(db, repoRoot, { targetBranch = 'main' } = {}) {
157
- const nextItem = listMergeQueue(db).find((item) => ['queued', 'retrying'].includes(item.status));
671
+ export async function runNextQueueItem(db, repoRoot, { targetBranch = 'main', followPlan = false } = {}) {
672
+ const currentItems = listMergeQueue(db);
673
+ const nextItem = chooseRunnableQueueItem(currentItems, { db, repoRoot, followPlan });
158
674
  if (!nextItem) {
675
+ const deferred = chooseNextQueueItem(currentItems, { db, repoRoot });
676
+ if (deferred) {
677
+ syncDeferredQueueState(db, deferred);
678
+ const refreshedDeferred = chooseNextQueueItem(listMergeQueue(db), { db, repoRoot });
679
+ return { status: 'deferred', item: null, deferred: refreshedDeferred };
680
+ }
159
681
  return { status: 'idle', item: null };
160
682
  }
161
683
 
@@ -168,6 +690,21 @@ export async function runNextQueueItem(db, repoRoot, { targetBranch = 'main' } =
168
690
  const resolved = resolveQueueSource(db, repoRoot, started);
169
691
  const queueTarget = started.target_branch || targetBranch;
170
692
 
693
+ if (resolved.pipeline_id) {
694
+ const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, resolved.pipeline_id);
695
+ if (!policyGate.ok) {
696
+ return {
697
+ status: 'blocked',
698
+ item: markMergeQueueState(db, started.id, {
699
+ status: 'blocked',
700
+ lastErrorCode: policyGate.reason_code,
701
+ lastErrorSummary: policyGate.summary,
702
+ nextAction: policyGate.next_action,
703
+ }),
704
+ };
705
+ }
706
+ }
707
+
171
708
  if (!gitBranchExists(repoRoot, resolved.branch)) {
172
709
  return scheduleRetryOrBlock(db, started, {
173
710
  code: 'source_missing',
@@ -177,8 +714,34 @@ export async function runNextQueueItem(db, repoRoot, { targetBranch = 'main' } =
177
714
  });
178
715
  }
179
716
 
717
+ const rebaseOperation = startOperationJournalEntry(db, {
718
+ scopeType: 'queue_item',
719
+ scopeId: started.id,
720
+ operationType: 'queue_rebase',
721
+ details: JSON.stringify({
722
+ queue_item_id: started.id,
723
+ branch: resolved.branch,
724
+ target_branch: queueTarget,
725
+ }),
726
+ });
180
727
  markMergeQueueState(db, started.id, { status: 'rebasing' });
181
- gitRebaseOnto(resolved.worktree_path || repoRoot, queueTarget, resolved.branch);
728
+ try {
729
+ gitRebaseOnto(resolved.worktree_path || repoRoot, queueTarget, resolved.branch);
730
+ finishOperationJournalEntry(db, rebaseOperation.id, {
731
+ status: 'completed',
732
+ });
733
+ } catch (err) {
734
+ finishOperationJournalEntry(db, rebaseOperation.id, {
735
+ status: 'failed',
736
+ details: JSON.stringify({
737
+ queue_item_id: started.id,
738
+ branch: resolved.branch,
739
+ target_branch: queueTarget,
740
+ error: String(err?.message || err),
741
+ }),
742
+ });
743
+ throw err;
744
+ }
182
745
 
183
746
  const gate = await evaluateQueueRepoGate(db, repoRoot);
184
747
  if (!gate.ok) {
@@ -193,8 +756,41 @@ export async function runNextQueueItem(db, repoRoot, { targetBranch = 'main' } =
193
756
  };
194
757
  }
195
758
 
759
+ const mergeOperation = startOperationJournalEntry(db, {
760
+ scopeType: 'queue_item',
761
+ scopeId: started.id,
762
+ operationType: 'queue_merge',
763
+ details: JSON.stringify({
764
+ queue_item_id: started.id,
765
+ branch: resolved.branch,
766
+ target_branch: queueTarget,
767
+ }),
768
+ });
196
769
  markMergeQueueState(db, started.id, { status: 'merging' });
197
- const mergedCommit = gitMergeBranchInto(repoRoot, queueTarget, resolved.branch);
770
+ let mergedCommit;
771
+ try {
772
+ mergedCommit = gitMergeBranchInto(repoRoot, queueTarget, resolved.branch);
773
+ finishOperationJournalEntry(db, mergeOperation.id, {
774
+ status: 'completed',
775
+ details: JSON.stringify({
776
+ queue_item_id: started.id,
777
+ branch: resolved.branch,
778
+ target_branch: queueTarget,
779
+ merged_commit: mergedCommit,
780
+ }),
781
+ });
782
+ } catch (err) {
783
+ finishOperationJournalEntry(db, mergeOperation.id, {
784
+ status: 'failed',
785
+ details: JSON.stringify({
786
+ queue_item_id: started.id,
787
+ branch: resolved.branch,
788
+ target_branch: queueTarget,
789
+ error: String(err?.message || err),
790
+ }),
791
+ });
792
+ throw err;
793
+ }
198
794
 
199
795
  return {
200
796
  status: 'merged',
@@ -209,17 +805,37 @@ export async function runNextQueueItem(db, repoRoot, { targetBranch = 'main' } =
209
805
  }
210
806
  }
211
807
 
212
- export async function runMergeQueue(db, repoRoot, { maxItems = 1, targetBranch = 'main' } = {}) {
808
+ export async function runMergeQueue(db, repoRoot, {
809
+ maxItems = 1,
810
+ targetBranch = 'main',
811
+ followPlan = false,
812
+ mergeBudget = null,
813
+ } = {}) {
213
814
  const processed = [];
815
+ let deferred = null;
816
+ let mergedCount = 0;
214
817
  for (let count = 0; count < maxItems; count++) {
215
- const result = await runNextQueueItem(db, repoRoot, { targetBranch });
216
- if (!result.item) break;
818
+ if (mergeBudget !== null && mergedCount >= mergeBudget) break;
819
+ const result = await runNextQueueItem(db, repoRoot, { targetBranch, followPlan });
820
+ if (!result.item) {
821
+ deferred = result.deferred || deferred;
822
+ break;
823
+ }
217
824
  processed.push(result);
825
+ if (result.status === 'merged') {
826
+ mergedCount += 1;
827
+ }
218
828
  if (result.status !== 'merged') break;
219
829
  }
220
830
 
221
831
  return {
222
832
  processed,
223
- summary: buildQueueStatusSummary(listMergeQueue(db)),
833
+ deferred,
834
+ execution_policy: {
835
+ follow_plan: followPlan,
836
+ merge_budget: mergeBudget,
837
+ merged_count: mergedCount,
838
+ },
839
+ summary: buildQueueStatusSummary(listMergeQueue(db), { db, repoRoot }),
224
840
  };
225
841
  }