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/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/README.md +173 -4
- package/examples/README.md +28 -0
- package/package.json +1 -1
- package/src/cli/index.js +2941 -314
- package/src/core/ci.js +204 -0
- package/src/core/db.js +822 -26
- package/src/core/enforcement.js +18 -5
- package/src/core/git.js +286 -1
- package/src/core/merge-gate.js +17 -2
- package/src/core/outcome.js +1 -1
- package/src/core/pipeline.js +2399 -59
- package/src/core/planner.js +25 -5
- package/src/core/policy.js +105 -0
- package/src/core/queue.js +643 -27
- package/src/core/semantic.js +71 -5
- package/src/core/telemetry.js +210 -0
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}
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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:
|
|
122
|
-
worktree: worktree
|
|
123
|
-
worktree_path: worktree
|
|
124
|
-
pipeline_id:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
}
|