create-claude-workspace 2.3.10 → 2.3.11
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 +150 -117
- 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, abortMerge, deleteBranchRemote, } from './git/manager.mjs';
|
|
11
|
+
import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listWorktrees, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, abortMerge, 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) {
|
|
@@ -593,166 +594,135 @@ function closeRecoveredIssue(projectDir, branch, logger) {
|
|
|
593
594
|
async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
|
|
594
595
|
const knownPaths = Object.values(state.pipelines).map(p => p.worktreePath);
|
|
595
596
|
const orphans = listOrphanedWorktrees(projectDir, knownPaths);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
597
|
+
const ciPlatform = detectCIPlatform(projectDir);
|
|
598
|
+
if (orphans.length === 0) {
|
|
599
|
+
logger.info('[recovery] No orphaned worktrees found');
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
logger.info(`[recovery] Found ${orphans.length} orphaned worktree(s)`);
|
|
603
|
+
}
|
|
604
|
+
// Phase 1: Handle orphaned worktrees
|
|
599
605
|
for (const worktreePath of orphans) {
|
|
600
606
|
try {
|
|
601
607
|
const branch = getCurrentBranch(worktreePath);
|
|
608
|
+
logger.info(`[recovery] Processing worktree: ${branch}`);
|
|
602
609
|
const alreadyMerged = isBranchMerged(projectDir, branch);
|
|
603
610
|
if (alreadyMerged) {
|
|
604
|
-
|
|
605
|
-
logger.info(`[recovery] ${branch} already merged — cleaning up worktree`);
|
|
611
|
+
logger.info(`[recovery] ${branch} already merged — cleaning up`);
|
|
606
612
|
cleanupWorktree(projectDir, worktreePath, branch);
|
|
607
613
|
deleteBranchRemote(projectDir, branch);
|
|
608
614
|
closeRecoveredIssue(projectDir, branch, logger);
|
|
609
615
|
continue;
|
|
610
616
|
}
|
|
611
|
-
// Check if worktree has commits ahead of main
|
|
612
617
|
const hasChanges = hasUncommittedChanges(worktreePath);
|
|
613
618
|
const changedFromMain = getChangedFiles(worktreePath);
|
|
614
619
|
const hasCommits = changedFromMain.length > 0;
|
|
620
|
+
logger.info(`[recovery] ${branch}: uncommitted=${hasChanges}, commits ahead=${hasCommits} (${changedFromMain.length} files)`);
|
|
615
621
|
if (!hasCommits && !hasChanges) {
|
|
616
|
-
// No work done — clean up empty worktree
|
|
617
622
|
logger.info(`[recovery] ${branch} has no changes — cleaning up`);
|
|
618
623
|
cleanupWorktree(projectDir, worktreePath, branch);
|
|
624
|
+
deleteBranchRemote(projectDir, branch);
|
|
619
625
|
continue;
|
|
620
626
|
}
|
|
621
627
|
if (hasChanges) {
|
|
622
|
-
// Uncommitted changes — commit them first
|
|
623
628
|
logger.info(`[recovery] ${branch} has uncommitted changes — committing`);
|
|
624
|
-
commitInWorktree(worktreePath,
|
|
629
|
+
commitInWorktree(worktreePath, 'wip: auto-commit from recovery', true);
|
|
630
|
+
}
|
|
631
|
+
// Delegate to AI agent: rebase onto main, fix any issues, then commit
|
|
632
|
+
logger.info(`[recovery] Spawning AI agent to finalize ${branch}`);
|
|
633
|
+
const slot = deps.pool.idleSlot();
|
|
634
|
+
if (!slot) {
|
|
635
|
+
logger.warn(`[recovery] No idle worker — skipping ${branch} for now`);
|
|
636
|
+
continue;
|
|
625
637
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
638
|
+
deps.onSpawnStart?.(`recovery-${branch}`);
|
|
639
|
+
try {
|
|
640
|
+
await deps.pool.spawn(slot.id, {
|
|
641
|
+
cwd: worktreePath,
|
|
642
|
+
prompt: [
|
|
643
|
+
`You are in a git worktree for branch "${branch}".`,
|
|
644
|
+
`This branch was interrupted and needs to be finalized for merge into main.`,
|
|
645
|
+
``,
|
|
646
|
+
`Do the following:`,
|
|
647
|
+
`1. Run: git fetch origin main && git rebase origin/main`,
|
|
648
|
+
`2. If rebase has conflicts, resolve them sensibly (prefer feature branch intent)`,
|
|
649
|
+
`3. After rebase, run the project's build/lint to check for errors:`,
|
|
650
|
+
` - Look at package.json for available scripts (build, lint, test)`,
|
|
651
|
+
` - Fix any build or lint errors in the code`,
|
|
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}`);
|
|
630
666
|
const stashed = cleanMainForMerge(projectDir);
|
|
631
667
|
syncMain(projectDir);
|
|
632
|
-
// Try PR
|
|
633
|
-
|
|
668
|
+
// Try platform merge (PR/MR) first
|
|
669
|
+
let merged = false;
|
|
634
670
|
if (ciPlatform !== 'none') {
|
|
635
|
-
const pushed = pushWorktree(worktreePath);
|
|
671
|
+
const pushed = pushWorktree(worktreePath) || forcePushWorktree(worktreePath);
|
|
636
672
|
if (pushed) {
|
|
637
673
|
try {
|
|
638
674
|
const mainBranch = getMainBranch(projectDir);
|
|
675
|
+
const issueNum = extractIssueFromBranch(branch);
|
|
639
676
|
const prInfo = createPR({
|
|
640
677
|
cwd: projectDir,
|
|
641
678
|
platform: ciPlatform,
|
|
642
679
|
branch,
|
|
643
680
|
baseBranch: mainBranch,
|
|
644
|
-
title: `feat: ${branch}
|
|
645
|
-
body:
|
|
681
|
+
title: `feat: ${branch}`,
|
|
682
|
+
body: 'Recovered and finalized by scheduler.',
|
|
683
|
+
issueNumber: issueNum ?? undefined,
|
|
646
684
|
});
|
|
647
|
-
logger.info(`[recovery] PR
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
+
}
|
|
660
700
|
}
|
|
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
701
|
}
|
|
667
702
|
catch (err) {
|
|
668
|
-
logger.warn(`[recovery] PR
|
|
703
|
+
logger.warn(`[recovery] PR flow failed for ${branch}: ${err.message?.split('\n')[0]}`);
|
|
669
704
|
}
|
|
670
705
|
}
|
|
671
706
|
}
|
|
672
707
|
// Local merge fallback
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
else if (mergeResult.conflict) {
|
|
680
|
-
// mergeToMain already aborted the merge internally
|
|
681
|
-
// Delegate conflict resolution to orchestrator AI
|
|
682
|
-
logger.info(`[recovery] ${branch} has merge conflicts — delegating to AI`);
|
|
683
|
-
let resolved = false;
|
|
684
|
-
try {
|
|
685
|
-
const conflictDecision = await deps.orchestrator.handleMergeConflict(branch, mergeResult.conflictFiles ?? []);
|
|
686
|
-
if (conflictDecision.action === 'resolve') {
|
|
687
|
-
// Spawn agent to resolve conflicts in the worktree via rebase
|
|
688
|
-
logger.info(`[recovery] Spawning agent to resolve conflicts in ${branch}`);
|
|
689
|
-
const conflictPrompt = [
|
|
690
|
-
`You are in a worktree for branch "${branch}".`,
|
|
691
|
-
`Rebase this branch onto main and resolve all merge conflicts.`,
|
|
692
|
-
``,
|
|
693
|
-
`Steps:`,
|
|
694
|
-
`1. Run: git rebase main`,
|
|
695
|
-
`2. For each conflict, read the conflicting files and resolve them sensibly`,
|
|
696
|
-
`3. After resolving each file: git add <file>`,
|
|
697
|
-
`4. Continue rebase: git rebase --continue`,
|
|
698
|
-
`5. Repeat until rebase is complete`,
|
|
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);
|
|
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;
|
|
736
713
|
}
|
|
737
|
-
|
|
738
|
-
logger.error(`[recovery] ${branch}
|
|
714
|
+
else {
|
|
715
|
+
logger.error(`[recovery] ${branch} merge failed: ${mergeResult.error ?? 'unknown'}`);
|
|
739
716
|
abortMerge(projectDir);
|
|
740
|
-
if (stashed)
|
|
741
|
-
popStash(projectDir);
|
|
742
|
-
continue;
|
|
743
717
|
}
|
|
744
718
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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}` }));
|
|
751
725
|
}
|
|
752
|
-
// Post-merge cleanup: worktree + local branch + remote branch + close issue
|
|
753
|
-
cleanupWorktree(projectDir, worktreePath, branch);
|
|
754
|
-
deleteBranchRemote(projectDir, branch);
|
|
755
|
-
closeRecoveredIssue(projectDir, branch, logger);
|
|
756
726
|
if (stashed)
|
|
757
727
|
popStash(projectDir);
|
|
758
728
|
}
|
|
@@ -760,6 +730,69 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
|
|
|
760
730
|
logger.error(`[recovery] Failed to recover ${worktreePath}: ${err.message}`);
|
|
761
731
|
}
|
|
762
732
|
}
|
|
733
|
+
// Phase 2: Check for open MRs/PRs without local worktrees
|
|
734
|
+
if (ciPlatform !== 'none') {
|
|
735
|
+
await recoverOpenMRs(projectDir, ciPlatform, state, logger);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/** Find open MRs/PRs that don't have local worktrees and try to merge them */
|
|
739
|
+
async function recoverOpenMRs(projectDir, platform, state, logger) {
|
|
740
|
+
try {
|
|
741
|
+
// Get all open MRs
|
|
742
|
+
const openMRs = getOpenMRs(projectDir, platform);
|
|
743
|
+
if (openMRs.length === 0)
|
|
744
|
+
return;
|
|
745
|
+
const existingBranches = new Set(listWorktrees(projectDir).map(wt => {
|
|
746
|
+
try {
|
|
747
|
+
return getCurrentBranch(wt);
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
return '';
|
|
751
|
+
}
|
|
752
|
+
}));
|
|
753
|
+
for (const mr of openMRs) {
|
|
754
|
+
if (existingBranches.has(mr.branch))
|
|
755
|
+
continue; // Handled in phase 1
|
|
756
|
+
logger.info(`[recovery] Found open MR !${mr.number} for ${mr.branch} (no local worktree)`);
|
|
757
|
+
const prStatus = getPRStatus(projectDir, platform, mr.branch);
|
|
758
|
+
if (prStatus.status === 'merged') {
|
|
759
|
+
logger.info(`[recovery] MR !${mr.number} already merged`);
|
|
760
|
+
closeRecoveredIssue(projectDir, mr.branch, logger);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (prStatus.mergeable) {
|
|
764
|
+
const merged = mergePR(projectDir, platform, prStatus.number);
|
|
765
|
+
if (merged) {
|
|
766
|
+
logger.info(`[recovery] MR !${mr.number} merged via platform`);
|
|
767
|
+
syncMain(projectDir);
|
|
768
|
+
closeRecoveredIssue(projectDir, mr.branch, logger);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
logger.info(`[recovery] MR !${mr.number} not mergeable (CI: ${prStatus.ciStatus}) — leaving open`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
logger.warn(`[recovery] MR scan failed: ${err.message?.split('\n')[0]}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
function getOpenMRs(projectDir, platform) {
|
|
781
|
+
try {
|
|
782
|
+
if (platform === 'github') {
|
|
783
|
+
const output = execFileSync('gh', ['pr', 'list', '--json', 'number,headRefName', '--state', 'open'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
|
|
784
|
+
const prs = JSON.parse(output);
|
|
785
|
+
return prs.map(p => ({ number: p.number, branch: p.headRefName }));
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
const output = execFileSync('glab', ['mr', 'list', '--output', 'json'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
|
|
789
|
+
const mrs = JSON.parse(output);
|
|
790
|
+
return mrs.map(m => ({ number: m.iid, branch: m.source_branch }));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
return [];
|
|
795
|
+
}
|
|
763
796
|
}
|
|
764
797
|
function loadTasksJson(path) {
|
|
765
798
|
const content = readFileSync(path, 'utf-8');
|