create-claude-workspace 2.3.11 → 2.3.13
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/dist/scheduler/loop.mjs +340 -253
- package/dist/scheduler/loop.spec.js +153 -0
- package/dist/scheduler/recovery.spec.js +121 -0
- package/package.json +1 -1
package/dist/scheduler/loop.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fetchOpenIssues, issueToTask, updateIssueStatus } from './tasks/issue-s
|
|
|
8
8
|
import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
|
|
9
9
|
import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
|
|
10
10
|
import { recordSession, getSession, clearSession } from './state/session.mjs';
|
|
11
|
-
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listWorktrees, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges,
|
|
11
|
+
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listWorktrees, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, deleteBranchRemote, } from './git/manager.mjs';
|
|
12
12
|
import { createPR, getPRStatus, getPRComments, mergePR } from './git/pr-manager.mjs';
|
|
13
13
|
import { scanAgents } from './agents/health-checker.mjs';
|
|
14
14
|
import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
|
|
@@ -193,9 +193,72 @@ export async function runIteration(deps) {
|
|
|
193
193
|
if (queued > 0) {
|
|
194
194
|
logger.info(`Starting ${tasksToRun.length} tasks, ${queued} queued (waiting for workers)`);
|
|
195
195
|
}
|
|
196
|
-
// Process
|
|
196
|
+
// Process recovered pipelines first (from recovery phase)
|
|
197
197
|
let workDone = false;
|
|
198
|
+
const recoveredIds = Object.keys(state.pipelines).filter(id => {
|
|
199
|
+
const p = state.pipelines[id];
|
|
200
|
+
return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
|
|
201
|
+
});
|
|
202
|
+
for (const taskId of recoveredIds) {
|
|
203
|
+
const slot = pool.idleSlot();
|
|
204
|
+
if (!slot)
|
|
205
|
+
break;
|
|
206
|
+
const pipeline = state.pipelines[taskId];
|
|
207
|
+
pipeline.workerId = slot.id;
|
|
208
|
+
// Find matching task from loaded tasks, or create a minimal one
|
|
209
|
+
let task = tasks.find(t => t.id === taskId);
|
|
210
|
+
if (!task) {
|
|
211
|
+
const issueNum = extractIssueNumber(taskId);
|
|
212
|
+
task = {
|
|
213
|
+
id: taskId,
|
|
214
|
+
title: `Recovered: ${taskId}`,
|
|
215
|
+
phase: state.currentPhase,
|
|
216
|
+
type: 'fullstack',
|
|
217
|
+
complexity: 'M',
|
|
218
|
+
dependsOn: [],
|
|
219
|
+
issueMarker: taskId,
|
|
220
|
+
kitUpgrade: false,
|
|
221
|
+
lineNumber: 0,
|
|
222
|
+
status: 'in-progress',
|
|
223
|
+
changelog: 'changed',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
logger.info(`[${taskId}] Resuming recovered pipeline at step "${pipeline.step}"`);
|
|
227
|
+
try {
|
|
228
|
+
const success = await runTaskPipeline(task, slot.id, agents, deps);
|
|
229
|
+
workDone = true;
|
|
230
|
+
if (success) {
|
|
231
|
+
task.status = 'done';
|
|
232
|
+
state.completedTasks.push(task.id);
|
|
233
|
+
if (state.taskMode === 'platform') {
|
|
234
|
+
const platform = detectCIPlatform(projectDir);
|
|
235
|
+
if (platform !== 'none') {
|
|
236
|
+
const issueNum = extractIssueNumber(task.issueMarker);
|
|
237
|
+
if (issueNum)
|
|
238
|
+
updateIssueStatus(projectDir, platform, issueNum, 'done');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
appendEvent(projectDir, createEvent('task_completed', { taskId: task.id }));
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
task.status = 'skipped';
|
|
245
|
+
state.skippedTasks.push(task.id);
|
|
246
|
+
appendEvent(projectDir, createEvent('task_skipped', { taskId: task.id }));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
logger.error(`Recovered task ${taskId} failed: ${err.message}`);
|
|
251
|
+
state.skippedTasks.push(taskId);
|
|
252
|
+
}
|
|
253
|
+
writeState(projectDir, state);
|
|
254
|
+
}
|
|
255
|
+
// Process new tasks through the pipeline
|
|
198
256
|
for (const task of tasksToRun) {
|
|
257
|
+
// Skip if already handled as recovered pipeline
|
|
258
|
+
if (state.pipelines[task.id])
|
|
259
|
+
continue;
|
|
260
|
+
if (state.completedTasks.includes(task.id) || state.skippedTasks.includes(task.id))
|
|
261
|
+
continue;
|
|
199
262
|
const slot = pool.idleSlot();
|
|
200
263
|
if (!slot)
|
|
201
264
|
break;
|
|
@@ -246,12 +309,17 @@ export async function runIteration(deps) {
|
|
|
246
309
|
async function runTaskPipeline(task, workerId, agents, deps) {
|
|
247
310
|
const { pool, orchestrator, state, opts, logger, onMessage, onSpawnStart, onSpawnEnd } = deps;
|
|
248
311
|
const projectDir = opts.projectDir;
|
|
249
|
-
//
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
312
|
+
// Check for existing pipeline (recovered from previous run)
|
|
313
|
+
const existing = state.pipelines[task.id];
|
|
314
|
+
const resumeStep = existing?.step;
|
|
315
|
+
// Create worktree (returns existing path if already exists)
|
|
316
|
+
const slug = existing ? getCurrentBranch(existing.worktreePath) : taskToSlug(task);
|
|
317
|
+
const worktreePath = existing?.worktreePath ?? createWorktree(projectDir, taskToSlug(task));
|
|
318
|
+
if (!existing) {
|
|
319
|
+
logger.info(`[${task.id}] Worktree created: ${taskToSlug(task)}`);
|
|
320
|
+
}
|
|
321
|
+
// Initialize or reuse pipeline state
|
|
322
|
+
const pipeline = existing ?? {
|
|
255
323
|
taskId: task.id,
|
|
256
324
|
workerId,
|
|
257
325
|
worktreePath,
|
|
@@ -266,149 +334,181 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
266
334
|
assignedAgent: null,
|
|
267
335
|
prState: null,
|
|
268
336
|
};
|
|
337
|
+
pipeline.workerId = workerId;
|
|
269
338
|
state.pipelines[task.id] = pipeline;
|
|
339
|
+
// Determine which steps to skip (for resumed pipelines)
|
|
340
|
+
const skipTo = resumeStep ?? 'plan';
|
|
341
|
+
const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
|
|
342
|
+
const skipToIndex = stepOrder.indexOf(skipTo);
|
|
343
|
+
const shouldSkip = (step) => stepOrder.indexOf(step) < skipToIndex;
|
|
270
344
|
try {
|
|
271
345
|
// Route task to agent
|
|
272
|
-
const routing = await orchestrator.routeTask(task, 'plan', agents);
|
|
346
|
+
const routing = await orchestrator.routeTask(task, shouldSkip('plan') ? skipTo : 'plan', agents);
|
|
273
347
|
pipeline.assignedAgent = routing.agent;
|
|
274
|
-
if (routing.create) {
|
|
275
|
-
// Auto-create new agent
|
|
348
|
+
if (routing.create && !shouldSkip('plan')) {
|
|
276
349
|
await createAgentFile(projectDir, routing.create);
|
|
277
350
|
pipeline.assignedAgent = routing.create.name;
|
|
278
351
|
appendEvent(projectDir, createEvent('agent_created', { agentType: routing.create.name }));
|
|
279
352
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'plan' }));
|
|
283
|
-
const planResult = await spawnAgent(pool, workerId, {
|
|
284
|
-
agent: pipeline.assignedAgent ?? undefined,
|
|
285
|
-
cwd: worktreePath,
|
|
286
|
-
prompt: buildPlanPrompt({ task, worktreePath, projectDir }),
|
|
287
|
-
model: getAgentModel(pipeline.assignedAgent, agents, task),
|
|
288
|
-
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
289
|
-
if (!planResult.success) {
|
|
290
|
-
logger.error(`[${task.id}] Planning failed: ${planResult.error}`);
|
|
291
|
-
return false;
|
|
353
|
+
if (shouldSkip('plan')) {
|
|
354
|
+
logger.info(`[${task.id}] Resuming from step "${skipTo}" with agent ${pipeline.assignedAgent ?? 'default'}`);
|
|
292
355
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
356
|
+
// STEP 1: Plan
|
|
357
|
+
if (!shouldSkip('plan')) {
|
|
358
|
+
pipeline.step = 'plan';
|
|
359
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'plan' }));
|
|
360
|
+
const planResult = await spawnAgent(pool, workerId, {
|
|
361
|
+
agent: pipeline.assignedAgent ?? undefined,
|
|
362
|
+
cwd: worktreePath,
|
|
363
|
+
prompt: buildPlanPrompt({ task, worktreePath, projectDir }),
|
|
364
|
+
model: getAgentModel(pipeline.assignedAgent, agents, task),
|
|
365
|
+
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
366
|
+
if (!planResult.success) {
|
|
367
|
+
logger.error(`[${task.id}] Planning failed: ${planResult.error}`);
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
pipeline.architectPlan = planResult.output;
|
|
371
|
+
pipeline.testingSection = extractSection(planResult.output, 'TESTING');
|
|
372
|
+
if (task.complexity === 'L' && planResult.output.includes('SPLIT RECOMMENDATION') && !planResult.output.includes('No split needed')) {
|
|
373
|
+
logger.info(`[${task.id}] L-task split recommended — deferring to decomposition`);
|
|
374
|
+
}
|
|
299
375
|
}
|
|
300
376
|
// STEP 2: Implement (includes unit tests + build/lint)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
prompt: buildImplementPrompt({
|
|
311
|
-
task, worktreePath, projectDir,
|
|
312
|
-
architectPlan: pipeline.architectPlan,
|
|
313
|
-
apiContract: pipeline.apiContract ?? undefined,
|
|
314
|
-
}),
|
|
315
|
-
model: getAgentModel(implAgent, agents, task),
|
|
316
|
-
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
317
|
-
if (!implResult.success) {
|
|
318
|
-
logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
|
|
319
|
-
return false;
|
|
320
|
-
}
|
|
321
|
-
// STEP 3: QA (E2E tests, integration tests, acceptance criteria verification)
|
|
322
|
-
// Only for tasks that need it — skip for pure refactoring, config changes, etc.
|
|
323
|
-
const needsQA = task.type !== 'fullstack' || !isRefactoringTask(task);
|
|
324
|
-
if (needsQA) {
|
|
325
|
-
pipeline.step = 'test';
|
|
326
|
-
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'test' }));
|
|
327
|
-
const changedFiles = getChangedFiles(worktreePath);
|
|
328
|
-
const qaRouting = await orchestrator.routeTask(task, 'test', agents);
|
|
329
|
-
const qaResult = await spawnAgent(pool, workerId, {
|
|
330
|
-
agent: qaRouting.agent ?? undefined,
|
|
377
|
+
if (!shouldSkip('implement')) {
|
|
378
|
+
pipeline.step = 'implement';
|
|
379
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'implement' }));
|
|
380
|
+
// Re-route to implementation agent (may differ from planning agent)
|
|
381
|
+
// e.g., ui-engineer plans → angular-engineer implements
|
|
382
|
+
const implRouting = await orchestrator.routeTask(task, 'implement', agents);
|
|
383
|
+
const implAgent = implRouting.agent ?? pipeline.assignedAgent;
|
|
384
|
+
const implResult = await spawnAgent(pool, workerId, {
|
|
385
|
+
agent: implAgent ?? undefined,
|
|
331
386
|
cwd: worktreePath,
|
|
332
|
-
prompt:
|
|
333
|
-
task, worktreePath, projectDir,
|
|
334
|
-
|
|
387
|
+
prompt: buildImplementPrompt({
|
|
388
|
+
task, worktreePath, projectDir,
|
|
389
|
+
architectPlan: pipeline.architectPlan ?? undefined,
|
|
390
|
+
apiContract: pipeline.apiContract ?? undefined,
|
|
335
391
|
}),
|
|
336
|
-
model: getAgentModel(
|
|
392
|
+
model: getAgentModel(implAgent, agents, task),
|
|
337
393
|
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
338
|
-
if (!
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
logger.error(`[${task.id}] QA failed ${MAX_BUILD_FIXES} times — skipping`);
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
const decision = await orchestrator.handleFailure(task.title, 'test', qaResult.error ?? 'QA failed', pipeline.buildFixes);
|
|
345
|
-
if (decision.action === 'skip')
|
|
346
|
-
return false;
|
|
394
|
+
if (!implResult.success) {
|
|
395
|
+
logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
|
|
396
|
+
return false;
|
|
347
397
|
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
let reviewPassed = false;
|
|
357
|
-
while (pipeline.reviewCycles < MAX_REVIEW_CYCLES && !reviewPassed) {
|
|
358
|
-
pipeline.reviewCycles++;
|
|
359
|
-
const reviewResult = await spawnAgent(pool, workerId, {
|
|
360
|
-
agent: reviewRouting.agent ?? undefined,
|
|
398
|
+
} // end if !shouldSkip('implement')
|
|
399
|
+
// For recovered pipelines at 'rework' step: run rework with context about what failed
|
|
400
|
+
if (resumeStep === 'rework') {
|
|
401
|
+
pipeline.step = 'rework';
|
|
402
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'rework' }));
|
|
403
|
+
logger.info(`[${task.id}] Running rework step (recovered pipeline)`);
|
|
404
|
+
await spawnAgent(pool, workerId, {
|
|
405
|
+
agent: pipeline.assignedAgent ?? undefined,
|
|
361
406
|
cwd: worktreePath,
|
|
362
|
-
prompt:
|
|
407
|
+
prompt: buildReworkPrompt({
|
|
363
408
|
task, worktreePath, projectDir,
|
|
364
|
-
|
|
365
|
-
testingSection: pipeline.testingSection ?? undefined,
|
|
409
|
+
reviewFindings: 'This task was interrupted. The branch has existing work but may have build/lint failures, merge conflicts, or failing CI. Please:\n1. Run git fetch origin main && git rebase origin/main (resolve any conflicts)\n2. Run the build and lint scripts from package.json\n3. Fix any errors found\n4. Ensure all tests pass',
|
|
366
410
|
}),
|
|
367
|
-
model: getAgentModel(
|
|
411
|
+
model: getAgentModel(pipeline.assignedAgent, agents, task),
|
|
368
412
|
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
369
|
-
|
|
370
|
-
|
|
413
|
+
}
|
|
414
|
+
// STEP 3: QA (E2E tests, integration tests, acceptance criteria verification)
|
|
415
|
+
// Only for tasks that need it — skip for pure refactoring, config changes, etc.
|
|
416
|
+
if (!shouldSkip('test')) {
|
|
417
|
+
const needsQA = task.type !== 'fullstack' || !isRefactoringTask(task);
|
|
418
|
+
if (needsQA) {
|
|
419
|
+
pipeline.step = 'test';
|
|
420
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'test' }));
|
|
421
|
+
const changedFiles = getChangedFiles(worktreePath);
|
|
422
|
+
const qaRouting = await orchestrator.routeTask(task, 'test', agents);
|
|
423
|
+
const qaResult = await spawnAgent(pool, workerId, {
|
|
424
|
+
agent: qaRouting.agent ?? undefined,
|
|
425
|
+
cwd: worktreePath,
|
|
426
|
+
prompt: buildQAPrompt({
|
|
427
|
+
task, worktreePath, projectDir, changedFiles,
|
|
428
|
+
testingSection: pipeline.testingSection ?? undefined,
|
|
429
|
+
}),
|
|
430
|
+
model: getAgentModel(qaRouting.agent, agents, task),
|
|
431
|
+
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
432
|
+
if (!qaResult.success) {
|
|
433
|
+
pipeline.buildFixes++;
|
|
434
|
+
if (pipeline.buildFixes >= MAX_BUILD_FIXES) {
|
|
435
|
+
logger.error(`[${task.id}] QA failed ${MAX_BUILD_FIXES} times — skipping`);
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
const decision = await orchestrator.handleFailure(task.title, 'test', qaResult.error ?? 'QA failed', pipeline.buildFixes);
|
|
439
|
+
if (decision.action === 'skip')
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
371
442
|
}
|
|
372
443
|
else {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
444
|
+
logger.info(`[${task.id}] Skipping QA step (refactoring/config task)`);
|
|
445
|
+
}
|
|
446
|
+
} // end if !shouldSkip('test')
|
|
447
|
+
// STEP 4: Review
|
|
448
|
+
if (!shouldSkip('review')) {
|
|
449
|
+
pipeline.step = 'review';
|
|
450
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'review' }));
|
|
451
|
+
const reviewRouting = await orchestrator.routeTask(task, 'review', agents);
|
|
452
|
+
let reviewPassed = false;
|
|
453
|
+
while (pipeline.reviewCycles < MAX_REVIEW_CYCLES && !reviewPassed) {
|
|
454
|
+
pipeline.reviewCycles++;
|
|
455
|
+
const reviewResult = await spawnAgent(pool, workerId, {
|
|
456
|
+
agent: reviewRouting.agent ?? undefined,
|
|
457
|
+
cwd: worktreePath,
|
|
458
|
+
prompt: buildReviewPrompt({
|
|
459
|
+
task, worktreePath, projectDir,
|
|
460
|
+
changedFiles: getChangedFiles(worktreePath),
|
|
461
|
+
testingSection: pipeline.testingSection ?? undefined,
|
|
462
|
+
}),
|
|
463
|
+
model: getAgentModel(reviewRouting.agent, agents, task),
|
|
464
|
+
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
465
|
+
if (reviewResult.output.includes('**PASS**') || reviewResult.output.includes('PASS')) {
|
|
376
466
|
reviewPassed = true;
|
|
377
467
|
}
|
|
378
468
|
else {
|
|
379
|
-
|
|
380
|
-
pipeline.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
469
|
+
pipeline.reviewFindings = reviewResult.output;
|
|
470
|
+
if (pipeline.reviewCycles >= MAX_REVIEW_CYCLES) {
|
|
471
|
+
logger.warn(`[${task.id}] Review limit (${MAX_REVIEW_CYCLES}) reached — proceeding anyway`);
|
|
472
|
+
reviewPassed = true;
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
// Rework
|
|
476
|
+
pipeline.step = 'rework';
|
|
477
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'rework' }));
|
|
478
|
+
await spawnAgent(pool, workerId, {
|
|
479
|
+
agent: pipeline.assignedAgent ?? undefined,
|
|
480
|
+
cwd: worktreePath,
|
|
481
|
+
prompt: buildReworkPrompt({
|
|
482
|
+
task, worktreePath, projectDir,
|
|
483
|
+
reviewFindings: pipeline.reviewFindings,
|
|
484
|
+
}),
|
|
485
|
+
model: getAgentModel(pipeline.assignedAgent, agents, task),
|
|
486
|
+
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
487
|
+
pipeline.step = 're-review';
|
|
488
|
+
}
|
|
392
489
|
}
|
|
393
490
|
}
|
|
394
|
-
}
|
|
491
|
+
} // end if !shouldSkip('review')
|
|
395
492
|
// STEP 5: Commit
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
493
|
+
if (!shouldSkip('commit')) {
|
|
494
|
+
pipeline.step = 'commit';
|
|
495
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'commit' }));
|
|
496
|
+
// In local mode: update TODO.md checkbox inside worktree before commit
|
|
497
|
+
if (state.taskMode === 'local') {
|
|
498
|
+
const worktreeTodoPath = resolve(worktreePath, 'TODO.md');
|
|
499
|
+
if (task.lineNumber > 0 && existsSync(worktreeTodoPath)) {
|
|
500
|
+
const todoContent = readFileSync(worktreeTodoPath, 'utf-8');
|
|
501
|
+
const updated = updateTaskCheckbox(todoContent, task.lineNumber, 'done');
|
|
502
|
+
writeFileSync(worktreeTodoPath, updated, 'utf-8');
|
|
503
|
+
}
|
|
405
504
|
}
|
|
406
|
-
|
|
505
|
+
const msg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
|
|
506
|
+
const sha = commitInWorktree(worktreePath, msg);
|
|
507
|
+
if (!sha) {
|
|
508
|
+
logger.warn(`[${task.id}] Nothing to commit`);
|
|
509
|
+
}
|
|
510
|
+
} // end if !shouldSkip('commit')
|
|
407
511
|
const commitMsg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
|
|
408
|
-
const sha = commitInWorktree(worktreePath, commitMsg);
|
|
409
|
-
if (!sha) {
|
|
410
|
-
logger.warn(`[${task.id}] Nothing to commit`);
|
|
411
|
-
}
|
|
412
512
|
// STEP 6: Push + PR/MR flow
|
|
413
513
|
const ciPlatform = detectCIPlatform(projectDir);
|
|
414
514
|
if (ciPlatform !== 'none') {
|
|
@@ -576,7 +676,7 @@ function isRefactoringTask(task) {
|
|
|
576
676
|
}
|
|
577
677
|
// ─── Orphaned worktree recovery ───
|
|
578
678
|
/** Mark issue as done on the platform after a successful recovery merge */
|
|
579
|
-
function closeRecoveredIssue(projectDir, branch, logger) {
|
|
679
|
+
export function closeRecoveredIssue(projectDir, branch, logger) {
|
|
580
680
|
const platform = detectCIPlatform(projectDir);
|
|
581
681
|
if (platform === 'none')
|
|
582
682
|
return;
|
|
@@ -591,21 +691,28 @@ function closeRecoveredIssue(projectDir, branch, logger) {
|
|
|
591
691
|
logger.warn(`[recovery] Failed to close issue #${issueNum}: ${err.message}`);
|
|
592
692
|
}
|
|
593
693
|
}
|
|
594
|
-
|
|
694
|
+
/**
|
|
695
|
+
* Recovery: diagnose orphaned worktrees + open MRs, then re-inject them
|
|
696
|
+
* into the normal pipeline so standard agents handle them via contracts.
|
|
697
|
+
*
|
|
698
|
+
* This does NOT spawn custom recovery agents — it just figures out the
|
|
699
|
+
* right pipeline step and lets runTaskPipeline do the real work.
|
|
700
|
+
*/
|
|
701
|
+
export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps) {
|
|
595
702
|
const knownPaths = Object.values(state.pipelines).map(p => p.worktreePath);
|
|
596
703
|
const orphans = listOrphanedWorktrees(projectDir, knownPaths);
|
|
597
704
|
const ciPlatform = detectCIPlatform(projectDir);
|
|
598
|
-
if (orphans.length === 0) {
|
|
599
|
-
logger.info('[recovery] No orphaned worktrees
|
|
705
|
+
if (orphans.length === 0 && ciPlatform === 'none') {
|
|
706
|
+
logger.info('[recovery] No orphaned worktrees or open MRs');
|
|
707
|
+
return;
|
|
600
708
|
}
|
|
601
|
-
|
|
709
|
+
if (orphans.length > 0) {
|
|
602
710
|
logger.info(`[recovery] Found ${orphans.length} orphaned worktree(s)`);
|
|
603
711
|
}
|
|
604
|
-
// Phase 1:
|
|
712
|
+
// Phase 1: Orphaned worktrees → re-inject into pipelines
|
|
605
713
|
for (const worktreePath of orphans) {
|
|
606
714
|
try {
|
|
607
715
|
const branch = getCurrentBranch(worktreePath);
|
|
608
|
-
logger.info(`[recovery] Processing worktree: ${branch}`);
|
|
609
716
|
const alreadyMerged = isBranchMerged(projectDir, branch);
|
|
610
717
|
if (alreadyMerged) {
|
|
611
718
|
logger.info(`[recovery] ${branch} already merged — cleaning up`);
|
|
@@ -617,159 +724,139 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
|
|
|
617
724
|
const hasChanges = hasUncommittedChanges(worktreePath);
|
|
618
725
|
const changedFromMain = getChangedFiles(worktreePath);
|
|
619
726
|
const hasCommits = changedFromMain.length > 0;
|
|
620
|
-
logger.info(`[recovery] ${branch}: uncommitted=${hasChanges}, commits ahead=${hasCommits} (${changedFromMain.length} files)`);
|
|
621
727
|
if (!hasCommits && !hasChanges) {
|
|
622
728
|
logger.info(`[recovery] ${branch} has no changes — cleaning up`);
|
|
623
729
|
cleanupWorktree(projectDir, worktreePath, branch);
|
|
624
730
|
deleteBranchRemote(projectDir, branch);
|
|
625
731
|
continue;
|
|
626
732
|
}
|
|
733
|
+
// Auto-commit any uncommitted changes so the pipeline can work with them
|
|
627
734
|
if (hasChanges) {
|
|
628
735
|
logger.info(`[recovery] ${branch} has uncommitted changes — committing`);
|
|
629
736
|
commitInWorktree(worktreePath, 'wip: auto-commit from recovery', true);
|
|
630
737
|
}
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
`4. Commit fixes: git add -A && git commit --no-verify -m "fix: resolve issues for merge"`,
|
|
653
|
-
`5. Do NOT merge into main — just make the branch clean and ready`,
|
|
654
|
-
``,
|
|
655
|
-
`Changed files in this branch: ${changedFromMain.join(', ')}`,
|
|
656
|
-
].join('\n'),
|
|
657
|
-
model: 'claude-sonnet-4-6',
|
|
658
|
-
onMessage: deps.onMessage,
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
finally {
|
|
662
|
-
deps.onSpawnEnd?.();
|
|
663
|
-
}
|
|
664
|
-
// Now try to merge
|
|
665
|
-
logger.info(`[recovery] Agent done — attempting merge of ${branch}`);
|
|
666
|
-
const stashed = cleanMainForMerge(projectDir);
|
|
667
|
-
syncMain(projectDir);
|
|
668
|
-
// Try platform merge (PR/MR) first
|
|
669
|
-
let merged = false;
|
|
670
|
-
if (ciPlatform !== 'none') {
|
|
671
|
-
const pushed = pushWorktree(worktreePath) || forcePushWorktree(worktreePath);
|
|
672
|
-
if (pushed) {
|
|
673
|
-
try {
|
|
674
|
-
const mainBranch = getMainBranch(projectDir);
|
|
675
|
-
const issueNum = extractIssueFromBranch(branch);
|
|
676
|
-
const prInfo = createPR({
|
|
677
|
-
cwd: projectDir,
|
|
678
|
-
platform: ciPlatform,
|
|
679
|
-
branch,
|
|
680
|
-
baseBranch: mainBranch,
|
|
681
|
-
title: `feat: ${branch}`,
|
|
682
|
-
body: 'Recovered and finalized by scheduler.',
|
|
683
|
-
issueNumber: issueNum ?? undefined,
|
|
684
|
-
});
|
|
685
|
-
logger.info(`[recovery] PR: ${prInfo.url}`);
|
|
686
|
-
if (prInfo.status === 'merged') {
|
|
687
|
-
logger.info(`[recovery] ${branch} already merged via platform`);
|
|
688
|
-
merged = true;
|
|
689
|
-
}
|
|
690
|
-
else {
|
|
691
|
-
const prStatus = getPRStatus(projectDir, ciPlatform, branch);
|
|
692
|
-
if (prStatus.mergeable) {
|
|
693
|
-
merged = mergePR(projectDir, ciPlatform, prStatus.number);
|
|
694
|
-
if (merged)
|
|
695
|
-
logger.info(`[recovery] ${branch} merged via platform`);
|
|
696
|
-
}
|
|
697
|
-
else {
|
|
698
|
-
logger.info(`[recovery] PR for ${branch} not yet mergeable (CI: ${prStatus.ciStatus}) — leaving open`);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
catch (err) {
|
|
703
|
-
logger.warn(`[recovery] PR flow failed for ${branch}: ${err.message?.split('\n')[0]}`);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
// Local merge fallback
|
|
708
|
-
if (!merged) {
|
|
709
|
-
const mergeResult = mergeToMain(projectDir, branch);
|
|
710
|
-
if (mergeResult.success) {
|
|
711
|
-
logger.info(`[recovery] ${branch} merged locally (${mergeResult.sha?.slice(0, 7)})`);
|
|
712
|
-
merged = true;
|
|
713
|
-
}
|
|
714
|
-
else {
|
|
715
|
-
logger.error(`[recovery] ${branch} merge failed: ${mergeResult.error ?? 'unknown'}`);
|
|
716
|
-
abortMerge(projectDir);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
if (merged) {
|
|
720
|
-
syncMain(projectDir);
|
|
721
|
-
cleanupWorktree(projectDir, worktreePath, branch);
|
|
722
|
-
deleteBranchRemote(projectDir, branch);
|
|
723
|
-
closeRecoveredIssue(projectDir, branch, logger);
|
|
724
|
-
appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery: ${branch}` }));
|
|
725
|
-
}
|
|
726
|
-
if (stashed)
|
|
727
|
-
popStash(projectDir);
|
|
738
|
+
// Determine the right pipeline step to resume from
|
|
739
|
+
const issueNum = extractIssueFromBranch(branch);
|
|
740
|
+
const taskId = issueNum ? `#${issueNum}` : branch;
|
|
741
|
+
const step = diagnoseStep(projectDir, branch, ciPlatform);
|
|
742
|
+
logger.info(`[recovery] ${branch} → re-injecting into pipeline at step "${step}"`);
|
|
743
|
+
// Register in pipelines so the normal task loop picks it up
|
|
744
|
+
state.pipelines[taskId] = {
|
|
745
|
+
taskId,
|
|
746
|
+
workerId: -1, // Will be assigned when picked up
|
|
747
|
+
worktreePath,
|
|
748
|
+
step,
|
|
749
|
+
architectPlan: null,
|
|
750
|
+
apiContract: null,
|
|
751
|
+
reviewFindings: null,
|
|
752
|
+
testingSection: null,
|
|
753
|
+
reviewCycles: 0,
|
|
754
|
+
ciFixes: 0,
|
|
755
|
+
buildFixes: 0,
|
|
756
|
+
assignedAgent: null,
|
|
757
|
+
prState: null,
|
|
758
|
+
};
|
|
728
759
|
}
|
|
729
760
|
catch (err) {
|
|
730
|
-
logger.error(`[recovery] Failed to
|
|
761
|
+
logger.error(`[recovery] Failed to process ${worktreePath}: ${err.message}`);
|
|
731
762
|
}
|
|
732
763
|
}
|
|
733
|
-
// Phase 2:
|
|
764
|
+
// Phase 2: Open MRs/PRs without local worktrees
|
|
734
765
|
if (ciPlatform !== 'none') {
|
|
735
|
-
|
|
766
|
+
recoverOpenMRs(projectDir, ciPlatform, state, logger);
|
|
736
767
|
}
|
|
737
768
|
}
|
|
738
|
-
/**
|
|
739
|
-
|
|
769
|
+
/** Diagnose which pipeline step a recovered worktree should resume from */
|
|
770
|
+
export function diagnoseStep(projectDir, branch, ciPlatform) {
|
|
771
|
+
// Check if there's an open MR/PR for this branch
|
|
772
|
+
if (ciPlatform !== 'none') {
|
|
773
|
+
try {
|
|
774
|
+
const prStatus = getPRStatus(projectDir, ciPlatform, branch);
|
|
775
|
+
if (prStatus.status === 'open') {
|
|
776
|
+
if (prStatus.ciStatus === 'failed')
|
|
777
|
+
return 'rework'; // CI failed → rework
|
|
778
|
+
if (prStatus.mergeable)
|
|
779
|
+
return 'merge'; // Ready to merge
|
|
780
|
+
if (prStatus.ciStatus === 'pending')
|
|
781
|
+
return 'pr-watch'; // CI still running
|
|
782
|
+
return 'rework'; // Not mergeable for other reasons
|
|
783
|
+
}
|
|
784
|
+
if (prStatus.status === 'merged')
|
|
785
|
+
return 'done';
|
|
786
|
+
}
|
|
787
|
+
catch { /* no PR exists */ }
|
|
788
|
+
}
|
|
789
|
+
// No PR — branch has commits but hasn't been reviewed/pushed yet
|
|
790
|
+
// Start from review step (implementation is done since there are commits)
|
|
791
|
+
return 'review';
|
|
792
|
+
}
|
|
793
|
+
/** Re-inject open MRs that have no local worktree into the pipeline */
|
|
794
|
+
export function recoverOpenMRs(projectDir, platform, state, logger) {
|
|
740
795
|
try {
|
|
741
|
-
// Get all open MRs
|
|
742
796
|
const openMRs = getOpenMRs(projectDir, platform);
|
|
743
797
|
if (openMRs.length === 0)
|
|
744
798
|
return;
|
|
745
|
-
|
|
799
|
+
// Collect branches that already have worktrees or pipelines
|
|
800
|
+
const handledBranches = new Set();
|
|
801
|
+
for (const wt of listWorktrees(projectDir)) {
|
|
746
802
|
try {
|
|
747
|
-
|
|
803
|
+
handledBranches.add(getCurrentBranch(wt));
|
|
748
804
|
}
|
|
749
|
-
catch {
|
|
750
|
-
|
|
805
|
+
catch { /* skip */ }
|
|
806
|
+
}
|
|
807
|
+
for (const p of Object.values(state.pipelines)) {
|
|
808
|
+
try {
|
|
809
|
+
const branch = getCurrentBranch(p.worktreePath);
|
|
810
|
+
handledBranches.add(branch);
|
|
751
811
|
}
|
|
752
|
-
|
|
812
|
+
catch { /* skip */ }
|
|
813
|
+
}
|
|
753
814
|
for (const mr of openMRs) {
|
|
754
|
-
if (
|
|
755
|
-
continue;
|
|
756
|
-
logger.info(`[recovery] Found open MR !${mr.number} for ${mr.branch} (no local worktree)`);
|
|
815
|
+
if (handledBranches.has(mr.branch))
|
|
816
|
+
continue;
|
|
757
817
|
const prStatus = getPRStatus(projectDir, platform, mr.branch);
|
|
758
818
|
if (prStatus.status === 'merged') {
|
|
759
|
-
logger.info(`[recovery] MR !${mr.number} already merged`);
|
|
819
|
+
logger.info(`[recovery] MR !${mr.number} already merged — closing issue`);
|
|
760
820
|
closeRecoveredIssue(projectDir, mr.branch, logger);
|
|
761
821
|
continue;
|
|
762
822
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
823
|
+
// Create a worktree for the MR branch so the pipeline can work on it
|
|
824
|
+
const issueNum = extractIssueFromBranch(mr.branch);
|
|
825
|
+
const taskId = issueNum ? `#${issueNum}` : mr.branch;
|
|
826
|
+
// Skip if already completed/skipped
|
|
827
|
+
if (state.completedTasks.includes(taskId) || state.skippedTasks.includes(taskId))
|
|
828
|
+
continue;
|
|
829
|
+
let step;
|
|
830
|
+
if (prStatus.ciStatus === 'failed')
|
|
831
|
+
step = 'rework';
|
|
832
|
+
else if (prStatus.mergeable)
|
|
833
|
+
step = 'merge';
|
|
834
|
+
else if (prStatus.ciStatus === 'pending')
|
|
835
|
+
step = 'pr-watch';
|
|
836
|
+
else
|
|
837
|
+
step = 'rework';
|
|
838
|
+
logger.info(`[recovery] MR !${mr.number} (${mr.branch}) → creating worktree, pipeline at "${step}"`);
|
|
839
|
+
try {
|
|
840
|
+
const slug = mr.branch.replace(/^feat\//, '');
|
|
841
|
+
const worktreePath = createWorktree(projectDir, mr.branch);
|
|
842
|
+
state.pipelines[taskId] = {
|
|
843
|
+
taskId,
|
|
844
|
+
workerId: -1,
|
|
845
|
+
worktreePath,
|
|
846
|
+
step,
|
|
847
|
+
architectPlan: null,
|
|
848
|
+
apiContract: null,
|
|
849
|
+
reviewFindings: null,
|
|
850
|
+
testingSection: null,
|
|
851
|
+
reviewCycles: 0,
|
|
852
|
+
ciFixes: 0,
|
|
853
|
+
buildFixes: 0,
|
|
854
|
+
assignedAgent: null,
|
|
855
|
+
prState: { prNumber: mr.number, url: '', issueNumber: issueNum },
|
|
856
|
+
};
|
|
770
857
|
}
|
|
771
|
-
|
|
772
|
-
logger.
|
|
858
|
+
catch (err) {
|
|
859
|
+
logger.warn(`[recovery] Failed to create worktree for MR !${mr.number}: ${err.message?.split('\n')[0]}`);
|
|
773
860
|
}
|
|
774
861
|
}
|
|
775
862
|
}
|
|
@@ -777,7 +864,7 @@ async function recoverOpenMRs(projectDir, platform, state, logger) {
|
|
|
777
864
|
logger.warn(`[recovery] MR scan failed: ${err.message?.split('\n')[0]}`);
|
|
778
865
|
}
|
|
779
866
|
}
|
|
780
|
-
function getOpenMRs(projectDir, platform) {
|
|
867
|
+
export function getOpenMRs(projectDir, platform) {
|
|
781
868
|
try {
|
|
782
869
|
if (platform === 'github') {
|
|
783
870
|
const output = execFileSync('gh', ['pr', 'list', '--json', 'number,headRefName', '--state', 'open'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
|
|
@@ -910,7 +997,7 @@ function extractIssueNumber(marker) {
|
|
|
910
997
|
return match ? parseInt(match[1], 10) : null;
|
|
911
998
|
}
|
|
912
999
|
/** Extract issue number from branch name like "feat/#94-description" or "feat/94-description" */
|
|
913
|
-
function extractIssueFromBranch(branch) {
|
|
1000
|
+
export function extractIssueFromBranch(branch) {
|
|
914
1001
|
const match = branch.match(/#?(\d+)/);
|
|
915
1002
|
return match ? parseInt(match[1], 10) : null;
|
|
916
1003
|
}
|
|
@@ -139,3 +139,156 @@ describe('pipeline state tracking', () => {
|
|
|
139
139
|
expect(pipeline.reviewCycles < 5).toBe(true);
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
|
+
// ─── Test exports of recovery functions ───
|
|
143
|
+
describe('recovery function exports', () => {
|
|
144
|
+
it('exports diagnoseStep', async () => {
|
|
145
|
+
const mod = await import('./loop.mjs');
|
|
146
|
+
expect(typeof mod.diagnoseStep).toBe('function');
|
|
147
|
+
});
|
|
148
|
+
it('exports recoverOrphanedWorktrees', async () => {
|
|
149
|
+
const mod = await import('./loop.mjs');
|
|
150
|
+
expect(typeof mod.recoverOrphanedWorktrees).toBe('function');
|
|
151
|
+
});
|
|
152
|
+
it('exports recoverOpenMRs', async () => {
|
|
153
|
+
const mod = await import('./loop.mjs');
|
|
154
|
+
expect(typeof mod.recoverOpenMRs).toBe('function');
|
|
155
|
+
});
|
|
156
|
+
it('exports getOpenMRs', async () => {
|
|
157
|
+
const mod = await import('./loop.mjs');
|
|
158
|
+
expect(typeof mod.getOpenMRs).toBe('function');
|
|
159
|
+
});
|
|
160
|
+
it('exports extractIssueFromBranch', async () => {
|
|
161
|
+
const mod = await import('./loop.mjs');
|
|
162
|
+
expect(typeof mod.extractIssueFromBranch).toBe('function');
|
|
163
|
+
});
|
|
164
|
+
it('exports closeRecoveredIssue', async () => {
|
|
165
|
+
const mod = await import('./loop.mjs');
|
|
166
|
+
expect(typeof mod.closeRecoveredIssue).toBe('function');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
// ─── Test shouldSkip pattern used in runTaskPipeline ───
|
|
170
|
+
describe('shouldSkip pattern (pipeline resume logic)', () => {
|
|
171
|
+
const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
|
|
172
|
+
function shouldSkip(step, resumeFrom) {
|
|
173
|
+
const skipToIndex = stepOrder.indexOf(resumeFrom);
|
|
174
|
+
return stepOrder.indexOf(step) < skipToIndex;
|
|
175
|
+
}
|
|
176
|
+
it('fresh pipeline (plan) skips nothing', () => {
|
|
177
|
+
for (const step of stepOrder) {
|
|
178
|
+
expect(shouldSkip(step, 'plan')).toBe(false);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
it('resuming at "implement" skips only plan', () => {
|
|
182
|
+
expect(shouldSkip('plan', 'implement')).toBe(true);
|
|
183
|
+
expect(shouldSkip('implement', 'implement')).toBe(false);
|
|
184
|
+
expect(shouldSkip('test', 'implement')).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
it('resuming at "review" skips plan, implement, test', () => {
|
|
187
|
+
expect(shouldSkip('plan', 'review')).toBe(true);
|
|
188
|
+
expect(shouldSkip('implement', 'review')).toBe(true);
|
|
189
|
+
expect(shouldSkip('test', 'review')).toBe(true);
|
|
190
|
+
expect(shouldSkip('review', 'review')).toBe(false);
|
|
191
|
+
expect(shouldSkip('commit', 'review')).toBe(false);
|
|
192
|
+
expect(shouldSkip('merge', 'review')).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
it('resuming at "merge" skips everything before merge', () => {
|
|
195
|
+
for (const step of stepOrder.slice(0, -1)) {
|
|
196
|
+
expect(shouldSkip(step, 'merge')).toBe(true);
|
|
197
|
+
}
|
|
198
|
+
expect(shouldSkip('merge', 'merge')).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
it('steps not in stepOrder return -1 index (treated as "skip nothing")', () => {
|
|
201
|
+
// 'done' and 'failed' are not in stepOrder, so indexOf returns -1
|
|
202
|
+
// This means skipToIndex = -1, and all steps have index >= 0, so nothing is skipped
|
|
203
|
+
// unless the step itself is also not in the list
|
|
204
|
+
const skipToIndex = stepOrder.indexOf('done');
|
|
205
|
+
expect(skipToIndex).toBe(-1);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
// ─── Test extractIssueFromBranch pattern ───
|
|
209
|
+
describe('extractIssueFromBranch pattern', () => {
|
|
210
|
+
// Mirrors the actual implementation: branch.match(/#?(\d+)/)
|
|
211
|
+
function extractIssueFromBranch(branch) {
|
|
212
|
+
const match = branch.match(/#?(\d+)/);
|
|
213
|
+
return match ? parseInt(match[1], 10) : null;
|
|
214
|
+
}
|
|
215
|
+
it('extracts from "feat/#42-auth"', () => {
|
|
216
|
+
expect(extractIssueFromBranch('feat/#42-auth')).toBe(42);
|
|
217
|
+
});
|
|
218
|
+
it('extracts from "feat/42-auth"', () => {
|
|
219
|
+
expect(extractIssueFromBranch('feat/42-auth')).toBe(42);
|
|
220
|
+
});
|
|
221
|
+
it('returns null for "feat/add-login"', () => {
|
|
222
|
+
expect(extractIssueFromBranch('feat/add-login')).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
it('extracts first number from "feat/#42-fix-issue-99"', () => {
|
|
225
|
+
expect(extractIssueFromBranch('feat/#42-fix-issue-99')).toBe(42);
|
|
226
|
+
});
|
|
227
|
+
it('handles plain number branches like "42-auth"', () => {
|
|
228
|
+
expect(extractIssueFromBranch('42-auth')).toBe(42);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
// ─── Test recovered pipeline identification pattern ───
|
|
232
|
+
describe('recovered pipeline identification', () => {
|
|
233
|
+
it('identifies recovered pipelines by workerId === -1', () => {
|
|
234
|
+
const state = emptyState(1);
|
|
235
|
+
state.pipelines['#42'] = {
|
|
236
|
+
taskId: '#42',
|
|
237
|
+
workerId: -1,
|
|
238
|
+
worktreePath: '/wt/feat-42',
|
|
239
|
+
step: 'review',
|
|
240
|
+
architectPlan: null,
|
|
241
|
+
apiContract: null,
|
|
242
|
+
reviewFindings: null,
|
|
243
|
+
testingSection: null,
|
|
244
|
+
reviewCycles: 0,
|
|
245
|
+
ciFixes: 0,
|
|
246
|
+
buildFixes: 0,
|
|
247
|
+
assignedAgent: null,
|
|
248
|
+
prState: null,
|
|
249
|
+
};
|
|
250
|
+
state.pipelines['p1-1'] = {
|
|
251
|
+
taskId: 'p1-1',
|
|
252
|
+
workerId: 0,
|
|
253
|
+
worktreePath: '/wt/feat-p1-1',
|
|
254
|
+
step: 'implement',
|
|
255
|
+
architectPlan: null,
|
|
256
|
+
apiContract: null,
|
|
257
|
+
reviewFindings: null,
|
|
258
|
+
testingSection: null,
|
|
259
|
+
reviewCycles: 0,
|
|
260
|
+
ciFixes: 0,
|
|
261
|
+
buildFixes: 0,
|
|
262
|
+
assignedAgent: null,
|
|
263
|
+
prState: null,
|
|
264
|
+
};
|
|
265
|
+
const recoveredIds = Object.keys(state.pipelines).filter(id => {
|
|
266
|
+
const p = state.pipelines[id];
|
|
267
|
+
return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
|
|
268
|
+
});
|
|
269
|
+
expect(recoveredIds).toEqual(['#42']);
|
|
270
|
+
});
|
|
271
|
+
it('excludes done and failed pipelines from recovery', () => {
|
|
272
|
+
const state = emptyState(1);
|
|
273
|
+
state.pipelines['#50'] = {
|
|
274
|
+
taskId: '#50',
|
|
275
|
+
workerId: -1,
|
|
276
|
+
worktreePath: '/wt/feat-50',
|
|
277
|
+
step: 'done',
|
|
278
|
+
architectPlan: null,
|
|
279
|
+
apiContract: null,
|
|
280
|
+
reviewFindings: null,
|
|
281
|
+
testingSection: null,
|
|
282
|
+
reviewCycles: 0,
|
|
283
|
+
ciFixes: 0,
|
|
284
|
+
buildFixes: 0,
|
|
285
|
+
assignedAgent: null,
|
|
286
|
+
prState: null,
|
|
287
|
+
};
|
|
288
|
+
const recoveredIds = Object.keys(state.pipelines).filter(id => {
|
|
289
|
+
const p = state.pipelines[id];
|
|
290
|
+
return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
|
|
291
|
+
});
|
|
292
|
+
expect(recoveredIds).toHaveLength(0);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ─── Recovery & git workflow tests ───
|
|
2
|
+
// Tests for diagnoseStep, extractIssueFromBranch, getOpenMRs,
|
|
3
|
+
// closeRecoveredIssue, and shouldSkip pipeline resume logic.
|
|
4
|
+
//
|
|
5
|
+
// Pure function tests — no mocks needed.
|
|
6
|
+
// @ts-ignore bun:test
|
|
7
|
+
import { describe, it, expect } from 'bun:test';
|
|
8
|
+
import { diagnoseStep, extractIssueFromBranch, getOpenMRs, } from './loop.mjs';
|
|
9
|
+
// ─── diagnoseStep ───
|
|
10
|
+
// Note: diagnoseStep calls getPRStatus which calls execFileSync.
|
|
11
|
+
// For unit tests we test the logic indirectly — the function falls back
|
|
12
|
+
// to 'review' when getPRStatus throws (which happens when not in a real repo).
|
|
13
|
+
describe('diagnoseStep', () => {
|
|
14
|
+
it('returns "review" when CI platform is "none"', () => {
|
|
15
|
+
expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'none')).toBe('review');
|
|
16
|
+
});
|
|
17
|
+
it('returns "review" when getPRStatus throws (no PR/no repo)', () => {
|
|
18
|
+
// /nonexistent is not a repo, so getPRStatus will throw
|
|
19
|
+
expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'github')).toBe('review');
|
|
20
|
+
});
|
|
21
|
+
it('returns "review" for gitlab when no PR exists', () => {
|
|
22
|
+
expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'gitlab')).toBe('review');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
// ─── extractIssueFromBranch ───
|
|
26
|
+
describe('extractIssueFromBranch', () => {
|
|
27
|
+
it('extracts number from "feat/#94-description"', () => {
|
|
28
|
+
expect(extractIssueFromBranch('feat/#94-user-auth')).toBe(94);
|
|
29
|
+
});
|
|
30
|
+
it('extracts number from "feat/94-description"', () => {
|
|
31
|
+
expect(extractIssueFromBranch('feat/94-add-login')).toBe(94);
|
|
32
|
+
});
|
|
33
|
+
it('returns null for branches without numbers', () => {
|
|
34
|
+
expect(extractIssueFromBranch('feat/add-login-page')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
it('extracts first number from branch with multiple numbers', () => {
|
|
37
|
+
expect(extractIssueFromBranch('feat/#42-fix-issue-99')).toBe(42);
|
|
38
|
+
});
|
|
39
|
+
it('extracts number from simple format', () => {
|
|
40
|
+
expect(extractIssueFromBranch('#7')).toBe(7);
|
|
41
|
+
});
|
|
42
|
+
it('returns null for empty string', () => {
|
|
43
|
+
expect(extractIssueFromBranch('')).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
// ─── getOpenMRs ───
|
|
47
|
+
describe('getOpenMRs', () => {
|
|
48
|
+
it('returns empty array when CLI not available', () => {
|
|
49
|
+
// /nonexistent is not a repo, so CLI will fail
|
|
50
|
+
expect(getOpenMRs('/nonexistent', 'github')).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
it('returns empty array for gitlab when CLI not available', () => {
|
|
53
|
+
expect(getOpenMRs('/nonexistent', 'gitlab')).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
// ─── shouldSkip pipeline resume (unit test of the pattern) ───
|
|
57
|
+
describe('shouldSkip pipeline resume', () => {
|
|
58
|
+
// Replicate the exact shouldSkip logic from runTaskPipeline
|
|
59
|
+
const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
|
|
60
|
+
function shouldSkip(step, resumeStep) {
|
|
61
|
+
const skipToIndex = stepOrder.indexOf(resumeStep);
|
|
62
|
+
return stepOrder.indexOf(step) < skipToIndex;
|
|
63
|
+
}
|
|
64
|
+
it('pipeline at "review" skips plan, implement, test', () => {
|
|
65
|
+
expect(shouldSkip('plan', 'review')).toBe(true);
|
|
66
|
+
expect(shouldSkip('implement', 'review')).toBe(true);
|
|
67
|
+
expect(shouldSkip('test', 'review')).toBe(true);
|
|
68
|
+
expect(shouldSkip('review', 'review')).toBe(false);
|
|
69
|
+
expect(shouldSkip('commit', 'review')).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
it('pipeline at "rework" skips plan through review', () => {
|
|
72
|
+
expect(shouldSkip('plan', 'rework')).toBe(true);
|
|
73
|
+
expect(shouldSkip('implement', 'rework')).toBe(true);
|
|
74
|
+
expect(shouldSkip('test', 'rework')).toBe(true);
|
|
75
|
+
expect(shouldSkip('review', 'rework')).toBe(true);
|
|
76
|
+
expect(shouldSkip('rework', 'rework')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it('pipeline at "merge" skips everything before merge', () => {
|
|
79
|
+
expect(shouldSkip('plan', 'merge')).toBe(true);
|
|
80
|
+
expect(shouldSkip('commit', 'merge')).toBe(true);
|
|
81
|
+
expect(shouldSkip('pr-watch', 'merge')).toBe(true);
|
|
82
|
+
expect(shouldSkip('merge', 'merge')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
it('pipeline at "plan" skips nothing (fresh)', () => {
|
|
85
|
+
expect(shouldSkip('plan', 'plan')).toBe(false);
|
|
86
|
+
expect(shouldSkip('implement', 'plan')).toBe(false);
|
|
87
|
+
expect(shouldSkip('review', 'plan')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it('pipeline at "pr-watch" skips through pr-create', () => {
|
|
90
|
+
expect(shouldSkip('pr-create', 'pr-watch')).toBe(true);
|
|
91
|
+
expect(shouldSkip('pr-watch', 'pr-watch')).toBe(false);
|
|
92
|
+
expect(shouldSkip('merge', 'pr-watch')).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
it('pipeline at "commit" skips through rework', () => {
|
|
95
|
+
expect(shouldSkip('rework', 'commit')).toBe(true);
|
|
96
|
+
expect(shouldSkip('commit', 'commit')).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it('step not in stepOrder is treated as before all steps (indexOf=-1)', () => {
|
|
99
|
+
// 'done' is not in stepOrder, indexOf returns -1, which is < any skipToIndex
|
|
100
|
+
expect(shouldSkip('done', 'review')).toBe(true);
|
|
101
|
+
// But if resumeStep is 'plan' (index 0), -1 < 0 is true
|
|
102
|
+
expect(shouldSkip('done', 'plan')).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
// ─── Recovered pipeline identification ───
|
|
106
|
+
describe('recovered pipeline identification', () => {
|
|
107
|
+
it('workerId=-1 identifies recovered pipelines', () => {
|
|
108
|
+
const pipelines = {
|
|
109
|
+
'#1': { workerId: -1, step: 'review' },
|
|
110
|
+
'#2': { workerId: 0, step: 'implement' },
|
|
111
|
+
'#3': { workerId: -1, step: 'done' },
|
|
112
|
+
'#4': { workerId: -1, step: 'failed' },
|
|
113
|
+
'#5': { workerId: -1, step: 'rework' },
|
|
114
|
+
};
|
|
115
|
+
const recoveredIds = Object.keys(pipelines).filter(id => {
|
|
116
|
+
const p = pipelines[id];
|
|
117
|
+
return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
|
|
118
|
+
});
|
|
119
|
+
expect(recoveredIds).toEqual(['#1', '#5']);
|
|
120
|
+
});
|
|
121
|
+
});
|