create-claude-workspace 2.3.10 → 2.3.12
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/git/manager.mjs +5 -2
- package/dist/scheduler/loop.mjs +382 -262
- package/package.json +1 -1
|
@@ -55,13 +55,16 @@ export function listOrphanedWorktrees(projectDir, knownWorktrees) {
|
|
|
55
55
|
return actual.filter(p => !knownSet.has(normalizePath(resolve(p))));
|
|
56
56
|
}
|
|
57
57
|
// ─── Commit operations ───
|
|
58
|
-
export function commitInWorktree(worktreePath, message) {
|
|
58
|
+
export function commitInWorktree(worktreePath, message, skipHooks = false) {
|
|
59
59
|
git(['add', '-A'], worktreePath);
|
|
60
60
|
// Check if there's anything to commit
|
|
61
61
|
const status = git(['status', '--porcelain'], worktreePath);
|
|
62
62
|
if (!status)
|
|
63
63
|
return '';
|
|
64
|
-
|
|
64
|
+
const args = ['commit', '-m', message];
|
|
65
|
+
if (skipHooks)
|
|
66
|
+
args.push('--no-verify');
|
|
67
|
+
git(args, worktreePath);
|
|
65
68
|
return git(['rev-parse', 'HEAD'], worktreePath);
|
|
66
69
|
}
|
|
67
70
|
export function getChangedFiles(worktreePath) {
|
package/dist/scheduler/loop.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// ─── Main orchestration loop: pipeline state machine ───
|
|
2
2
|
// Picks tasks, assigns to workers, advances through plan→implement→test→review→commit→merge.
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
4
5
|
import { resolve } from 'node:path';
|
|
5
6
|
import { parseTodoMd, updateTaskCheckbox } from './tasks/parser.mjs';
|
|
6
7
|
import { fetchOpenIssues, issueToTask, updateIssueStatus } from './tasks/issue-source.mjs';
|
|
7
8
|
import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
|
|
8
9
|
import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
|
|
9
10
|
import { recordSession, getSession, clearSession } from './state/session.mjs';
|
|
10
|
-
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, 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';
|
|
11
12
|
import { createPR, getPRStatus, getPRComments, mergePR } from './git/pr-manager.mjs';
|
|
12
13
|
import { scanAgents } from './agents/health-checker.mjs';
|
|
13
14
|
import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
|
|
@@ -63,7 +64,7 @@ export async function runIteration(deps) {
|
|
|
63
64
|
if (hasUncommittedChanges(projectDir)) {
|
|
64
65
|
logger.warn('Uncommitted changes on main — auto-committing before starting work');
|
|
65
66
|
try {
|
|
66
|
-
commitInWorktree(projectDir, 'chore: auto-commit uncommitted changes before scheduler start');
|
|
67
|
+
commitInWorktree(projectDir, 'chore: auto-commit uncommitted changes before scheduler start', true);
|
|
67
68
|
logger.info('Uncommitted changes committed on main');
|
|
68
69
|
}
|
|
69
70
|
catch (err) {
|
|
@@ -192,9 +193,72 @@ export async function runIteration(deps) {
|
|
|
192
193
|
if (queued > 0) {
|
|
193
194
|
logger.info(`Starting ${tasksToRun.length} tasks, ${queued} queued (waiting for workers)`);
|
|
194
195
|
}
|
|
195
|
-
// Process
|
|
196
|
+
// Process recovered pipelines first (from recovery phase)
|
|
196
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
|
|
197
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;
|
|
198
262
|
const slot = pool.idleSlot();
|
|
199
263
|
if (!slot)
|
|
200
264
|
break;
|
|
@@ -245,12 +309,17 @@ export async function runIteration(deps) {
|
|
|
245
309
|
async function runTaskPipeline(task, workerId, agents, deps) {
|
|
246
310
|
const { pool, orchestrator, state, opts, logger, onMessage, onSpawnStart, onSpawnEnd } = deps;
|
|
247
311
|
const projectDir = opts.projectDir;
|
|
248
|
-
//
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
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 ?? {
|
|
254
323
|
taskId: task.id,
|
|
255
324
|
workerId,
|
|
256
325
|
worktreePath,
|
|
@@ -265,149 +334,181 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
265
334
|
assignedAgent: null,
|
|
266
335
|
prState: null,
|
|
267
336
|
};
|
|
337
|
+
pipeline.workerId = workerId;
|
|
268
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;
|
|
269
344
|
try {
|
|
270
345
|
// Route task to agent
|
|
271
|
-
const routing = await orchestrator.routeTask(task, 'plan', agents);
|
|
346
|
+
const routing = await orchestrator.routeTask(task, shouldSkip('plan') ? skipTo : 'plan', agents);
|
|
272
347
|
pipeline.assignedAgent = routing.agent;
|
|
273
|
-
if (routing.create) {
|
|
274
|
-
// Auto-create new agent
|
|
348
|
+
if (routing.create && !shouldSkip('plan')) {
|
|
275
349
|
await createAgentFile(projectDir, routing.create);
|
|
276
350
|
pipeline.assignedAgent = routing.create.name;
|
|
277
351
|
appendEvent(projectDir, createEvent('agent_created', { agentType: routing.create.name }));
|
|
278
352
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'plan' }));
|
|
282
|
-
const planResult = await spawnAgent(pool, workerId, {
|
|
283
|
-
agent: pipeline.assignedAgent ?? undefined,
|
|
284
|
-
cwd: worktreePath,
|
|
285
|
-
prompt: buildPlanPrompt({ task, worktreePath, projectDir }),
|
|
286
|
-
model: getAgentModel(pipeline.assignedAgent, agents, task),
|
|
287
|
-
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
288
|
-
if (!planResult.success) {
|
|
289
|
-
logger.error(`[${task.id}] Planning failed: ${planResult.error}`);
|
|
290
|
-
return false;
|
|
353
|
+
if (shouldSkip('plan')) {
|
|
354
|
+
logger.info(`[${task.id}] Resuming from step "${skipTo}" with agent ${pipeline.assignedAgent ?? 'default'}`);
|
|
291
355
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
|
298
375
|
}
|
|
299
376
|
// STEP 2: Implement (includes unit tests + build/lint)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
prompt: buildImplementPrompt({
|
|
310
|
-
task, worktreePath, projectDir,
|
|
311
|
-
architectPlan: pipeline.architectPlan,
|
|
312
|
-
apiContract: pipeline.apiContract ?? undefined,
|
|
313
|
-
}),
|
|
314
|
-
model: getAgentModel(implAgent, agents, task),
|
|
315
|
-
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
316
|
-
if (!implResult.success) {
|
|
317
|
-
logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
|
|
318
|
-
return false;
|
|
319
|
-
}
|
|
320
|
-
// STEP 3: QA (E2E tests, integration tests, acceptance criteria verification)
|
|
321
|
-
// Only for tasks that need it — skip for pure refactoring, config changes, etc.
|
|
322
|
-
const needsQA = task.type !== 'fullstack' || !isRefactoringTask(task);
|
|
323
|
-
if (needsQA) {
|
|
324
|
-
pipeline.step = 'test';
|
|
325
|
-
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'test' }));
|
|
326
|
-
const changedFiles = getChangedFiles(worktreePath);
|
|
327
|
-
const qaRouting = await orchestrator.routeTask(task, 'test', agents);
|
|
328
|
-
const qaResult = await spawnAgent(pool, workerId, {
|
|
329
|
-
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,
|
|
330
386
|
cwd: worktreePath,
|
|
331
|
-
prompt:
|
|
332
|
-
task, worktreePath, projectDir,
|
|
333
|
-
|
|
387
|
+
prompt: buildImplementPrompt({
|
|
388
|
+
task, worktreePath, projectDir,
|
|
389
|
+
architectPlan: pipeline.architectPlan ?? undefined,
|
|
390
|
+
apiContract: pipeline.apiContract ?? undefined,
|
|
334
391
|
}),
|
|
335
|
-
model: getAgentModel(
|
|
392
|
+
model: getAgentModel(implAgent, agents, task),
|
|
336
393
|
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
337
|
-
if (!
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
logger.error(`[${task.id}] QA failed ${MAX_BUILD_FIXES} times — skipping`);
|
|
341
|
-
return false;
|
|
342
|
-
}
|
|
343
|
-
const decision = await orchestrator.handleFailure(task.title, 'test', qaResult.error ?? 'QA failed', pipeline.buildFixes);
|
|
344
|
-
if (decision.action === 'skip')
|
|
345
|
-
return false;
|
|
394
|
+
if (!implResult.success) {
|
|
395
|
+
logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
|
|
396
|
+
return false;
|
|
346
397
|
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
let reviewPassed = false;
|
|
356
|
-
while (pipeline.reviewCycles < MAX_REVIEW_CYCLES && !reviewPassed) {
|
|
357
|
-
pipeline.reviewCycles++;
|
|
358
|
-
const reviewResult = await spawnAgent(pool, workerId, {
|
|
359
|
-
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,
|
|
360
406
|
cwd: worktreePath,
|
|
361
|
-
prompt:
|
|
407
|
+
prompt: buildReworkPrompt({
|
|
362
408
|
task, worktreePath, projectDir,
|
|
363
|
-
|
|
364
|
-
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',
|
|
365
410
|
}),
|
|
366
|
-
model: getAgentModel(
|
|
411
|
+
model: getAgentModel(pipeline.assignedAgent, agents, task),
|
|
367
412
|
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
368
|
-
|
|
369
|
-
|
|
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
|
+
}
|
|
370
442
|
}
|
|
371
443
|
else {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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')) {
|
|
375
466
|
reviewPassed = true;
|
|
376
467
|
}
|
|
377
468
|
else {
|
|
378
|
-
|
|
379
|
-
pipeline.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
+
}
|
|
391
489
|
}
|
|
392
490
|
}
|
|
393
|
-
}
|
|
491
|
+
} // end if !shouldSkip('review')
|
|
394
492
|
// STEP 5: Commit
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
+
}
|
|
404
504
|
}
|
|
405
|
-
|
|
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')
|
|
406
511
|
const commitMsg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
|
|
407
|
-
const sha = commitInWorktree(worktreePath, commitMsg);
|
|
408
|
-
if (!sha) {
|
|
409
|
-
logger.warn(`[${task.id}] Nothing to commit`);
|
|
410
|
-
}
|
|
411
512
|
// STEP 6: Push + PR/MR flow
|
|
412
513
|
const ciPlatform = detectCIPlatform(projectDir);
|
|
413
514
|
if (ciPlatform !== 'none') {
|
|
@@ -590,176 +691,195 @@ function closeRecoveredIssue(projectDir, branch, logger) {
|
|
|
590
691
|
logger.warn(`[recovery] Failed to close issue #${issueNum}: ${err.message}`);
|
|
591
692
|
}
|
|
592
693
|
}
|
|
593
|
-
|
|
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
|
+
async function recoverOrphanedWorktrees(projectDir, state, logger, _deps) {
|
|
594
702
|
const knownPaths = Object.values(state.pipelines).map(p => p.worktreePath);
|
|
595
703
|
const orphans = listOrphanedWorktrees(projectDir, knownPaths);
|
|
596
|
-
|
|
704
|
+
const ciPlatform = detectCIPlatform(projectDir);
|
|
705
|
+
if (orphans.length === 0 && ciPlatform === 'none') {
|
|
706
|
+
logger.info('[recovery] No orphaned worktrees or open MRs');
|
|
597
707
|
return;
|
|
598
|
-
|
|
708
|
+
}
|
|
709
|
+
if (orphans.length > 0) {
|
|
710
|
+
logger.info(`[recovery] Found ${orphans.length} orphaned worktree(s)`);
|
|
711
|
+
}
|
|
712
|
+
// Phase 1: Orphaned worktrees → re-inject into pipelines
|
|
599
713
|
for (const worktreePath of orphans) {
|
|
600
714
|
try {
|
|
601
715
|
const branch = getCurrentBranch(worktreePath);
|
|
602
716
|
const alreadyMerged = isBranchMerged(projectDir, branch);
|
|
603
717
|
if (alreadyMerged) {
|
|
604
|
-
|
|
605
|
-
logger.info(`[recovery] ${branch} already merged — cleaning up worktree`);
|
|
718
|
+
logger.info(`[recovery] ${branch} already merged — cleaning up`);
|
|
606
719
|
cleanupWorktree(projectDir, worktreePath, branch);
|
|
607
720
|
deleteBranchRemote(projectDir, branch);
|
|
608
721
|
closeRecoveredIssue(projectDir, branch, logger);
|
|
609
722
|
continue;
|
|
610
723
|
}
|
|
611
|
-
// Check if worktree has commits ahead of main
|
|
612
724
|
const hasChanges = hasUncommittedChanges(worktreePath);
|
|
613
725
|
const changedFromMain = getChangedFiles(worktreePath);
|
|
614
726
|
const hasCommits = changedFromMain.length > 0;
|
|
615
727
|
if (!hasCommits && !hasChanges) {
|
|
616
|
-
// No work done — clean up empty worktree
|
|
617
728
|
logger.info(`[recovery] ${branch} has no changes — cleaning up`);
|
|
618
729
|
cleanupWorktree(projectDir, worktreePath, branch);
|
|
730
|
+
deleteBranchRemote(projectDir, branch);
|
|
619
731
|
continue;
|
|
620
732
|
}
|
|
733
|
+
// Auto-commit any uncommitted changes so the pipeline can work with them
|
|
621
734
|
if (hasChanges) {
|
|
622
|
-
// Uncommitted changes — commit them first
|
|
623
735
|
logger.info(`[recovery] ${branch} has uncommitted changes — committing`);
|
|
624
|
-
commitInWorktree(worktreePath,
|
|
625
|
-
}
|
|
626
|
-
// Try to merge
|
|
627
|
-
logger.info(`[recovery] ${branch} has unmerged commits — attempting merge`);
|
|
628
|
-
// Clean dirty main before merge
|
|
629
|
-
// Clean main: abort any in-progress merge/rebase, stash uncommitted changes
|
|
630
|
-
const stashed = cleanMainForMerge(projectDir);
|
|
631
|
-
syncMain(projectDir);
|
|
632
|
-
// Try PR flow first if remote exists
|
|
633
|
-
const ciPlatform = detectCIPlatform(projectDir);
|
|
634
|
-
if (ciPlatform !== 'none') {
|
|
635
|
-
const pushed = pushWorktree(worktreePath);
|
|
636
|
-
if (pushed) {
|
|
637
|
-
try {
|
|
638
|
-
const mainBranch = getMainBranch(projectDir);
|
|
639
|
-
const prInfo = createPR({
|
|
640
|
-
cwd: projectDir,
|
|
641
|
-
platform: ciPlatform,
|
|
642
|
-
branch,
|
|
643
|
-
baseBranch: mainBranch,
|
|
644
|
-
title: `feat: ${branch} (recovered)`,
|
|
645
|
-
body: `Auto-recovered from orphaned worktree.\n\nThis PR was created by the scheduler to complete a previously interrupted task.`,
|
|
646
|
-
});
|
|
647
|
-
logger.info(`[recovery] PR created for ${branch}: ${prInfo.url}`);
|
|
648
|
-
// Try to merge immediately if possible
|
|
649
|
-
const status = getPRStatus(projectDir, ciPlatform, branch);
|
|
650
|
-
if (status.mergeable) {
|
|
651
|
-
mergePR(projectDir, ciPlatform, status.number);
|
|
652
|
-
logger.info(`[recovery] ${branch} merged via platform`);
|
|
653
|
-
syncMain(projectDir);
|
|
654
|
-
cleanupWorktree(projectDir, worktreePath, branch);
|
|
655
|
-
deleteBranchRemote(projectDir, branch);
|
|
656
|
-
closeRecoveredIssue(projectDir, branch, logger);
|
|
657
|
-
if (stashed)
|
|
658
|
-
popStash(projectDir);
|
|
659
|
-
continue;
|
|
660
|
-
}
|
|
661
|
-
// PR created but not yet mergeable — leave it for next iteration
|
|
662
|
-
logger.info(`[recovery] PR for ${branch} not yet mergeable — will retry`);
|
|
663
|
-
if (stashed)
|
|
664
|
-
popStash(projectDir);
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
catch (err) {
|
|
668
|
-
logger.warn(`[recovery] PR creation failed for ${branch}: ${err.message} — trying local merge`);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
736
|
+
commitInWorktree(worktreePath, 'wip: auto-commit from recovery', true);
|
|
671
737
|
}
|
|
672
|
-
//
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
+
};
|
|
759
|
+
}
|
|
760
|
+
catch (err) {
|
|
761
|
+
logger.error(`[recovery] Failed to process ${worktreePath}: ${err.message}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// Phase 2: Open MRs/PRs without local worktrees
|
|
765
|
+
if (ciPlatform !== 'none') {
|
|
766
|
+
recoverOpenMRs(projectDir, ciPlatform, state, logger);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** Diagnose which pipeline step a recovered worktree should resume from */
|
|
770
|
+
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
|
|
678
783
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
``,
|
|
700
|
-
`Conflicting files: ${(mergeResult.conflictFiles ?? []).join(', ') || 'unknown'}`,
|
|
701
|
-
``,
|
|
702
|
-
`Important: preserve the intent of both sides. When in doubt, prefer the feature branch changes.`,
|
|
703
|
-
].join('\n');
|
|
704
|
-
const slot = deps.pool.idleSlot();
|
|
705
|
-
if (slot) {
|
|
706
|
-
await deps.pool.spawn(slot.id, {
|
|
707
|
-
cwd: worktreePath,
|
|
708
|
-
prompt: conflictPrompt,
|
|
709
|
-
model: 'claude-sonnet-4-6',
|
|
710
|
-
onMessage: deps.onMessage,
|
|
711
|
-
});
|
|
712
|
-
// Retry merge after agent resolved conflicts
|
|
713
|
-
const retryResult = mergeToMain(projectDir, branch);
|
|
714
|
-
if (retryResult.success) {
|
|
715
|
-
logger.info(`[recovery] ${branch} merged after AI conflict resolution`);
|
|
716
|
-
appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery-ai-resolve: ${branch}` }));
|
|
717
|
-
resolved = true;
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
else if (conflictDecision.action === 'rebase') {
|
|
722
|
-
const rebaseResult = rebaseOnMain(worktreePath, projectDir);
|
|
723
|
-
if (rebaseResult.success) {
|
|
724
|
-
const retryResult = mergeToMain(projectDir, branch);
|
|
725
|
-
if (retryResult.success) {
|
|
726
|
-
logger.info(`[recovery] ${branch} merged after rebase`);
|
|
727
|
-
resolved = true;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
// action === 'skip' → resolved stays false
|
|
732
|
-
}
|
|
733
|
-
catch (err) {
|
|
734
|
-
logger.warn(`[recovery] AI conflict resolution failed for ${branch}: ${err.message}`);
|
|
735
|
-
abortMerge(projectDir);
|
|
736
|
-
}
|
|
737
|
-
if (!resolved) {
|
|
738
|
-
logger.error(`[recovery] ${branch} has unresolvable conflicts — skipping`);
|
|
739
|
-
abortMerge(projectDir);
|
|
740
|
-
if (stashed)
|
|
741
|
-
popStash(projectDir);
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
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
|
+
function recoverOpenMRs(projectDir, platform, state, logger) {
|
|
795
|
+
try {
|
|
796
|
+
const openMRs = getOpenMRs(projectDir, platform);
|
|
797
|
+
if (openMRs.length === 0)
|
|
798
|
+
return;
|
|
799
|
+
// Collect branches that already have worktrees or pipelines
|
|
800
|
+
const handledBranches = new Set();
|
|
801
|
+
for (const wt of listWorktrees(projectDir)) {
|
|
802
|
+
try {
|
|
803
|
+
handledBranches.add(getCurrentBranch(wt));
|
|
744
804
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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);
|
|
811
|
+
}
|
|
812
|
+
catch { /* skip */ }
|
|
813
|
+
}
|
|
814
|
+
for (const mr of openMRs) {
|
|
815
|
+
if (handledBranches.has(mr.branch))
|
|
816
|
+
continue;
|
|
817
|
+
const prStatus = getPRStatus(projectDir, platform, mr.branch);
|
|
818
|
+
if (prStatus.status === 'merged') {
|
|
819
|
+
logger.info(`[recovery] MR !${mr.number} already merged — closing issue`);
|
|
820
|
+
closeRecoveredIssue(projectDir, mr.branch, logger);
|
|
750
821
|
continue;
|
|
751
822
|
}
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if (
|
|
757
|
-
|
|
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
|
+
};
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
logger.warn(`[recovery] Failed to create worktree for MR !${mr.number}: ${err.message?.split('\n')[0]}`);
|
|
860
|
+
}
|
|
758
861
|
}
|
|
759
|
-
|
|
760
|
-
|
|
862
|
+
}
|
|
863
|
+
catch (err) {
|
|
864
|
+
logger.warn(`[recovery] MR scan failed: ${err.message?.split('\n')[0]}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function getOpenMRs(projectDir, platform) {
|
|
868
|
+
try {
|
|
869
|
+
if (platform === 'github') {
|
|
870
|
+
const output = execFileSync('gh', ['pr', 'list', '--json', 'number,headRefName', '--state', 'open'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
|
|
871
|
+
const prs = JSON.parse(output);
|
|
872
|
+
return prs.map(p => ({ number: p.number, branch: p.headRefName }));
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
const output = execFileSync('glab', ['mr', 'list', '--output', 'json'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
|
|
876
|
+
const mrs = JSON.parse(output);
|
|
877
|
+
return mrs.map(m => ({ number: m.iid, branch: m.source_branch }));
|
|
761
878
|
}
|
|
762
879
|
}
|
|
880
|
+
catch {
|
|
881
|
+
return [];
|
|
882
|
+
}
|
|
763
883
|
}
|
|
764
884
|
function loadTasksJson(path) {
|
|
765
885
|
const content = readFileSync(path, 'utf-8');
|