fix(KB-647): add conflict recovery to worktree creation fallback

- Add conflict recovery to createFromExistingBranch fallback in executor\n- Add tests for worktree conflict recovery scenarios\n- Add changeset documenting the worktree recovery fix\n- Clean up removed central-core modules and AGENTS.md content\n- Update CLI command descriptions and extension tools
This commit is contained in:
gsxdsm
2026-03-31 20:01:35 -07:00
parent 80ab88db3d
commit 9fd7b667d5
3 changed files with 176 additions and 23 deletions

View File

@@ -709,6 +709,106 @@ describe("TaskExecutor worktree recovery", () => {
expect(onError).toHaveBeenCalled();
});
it("recovers from 'already used by worktree' error in createFromExistingBranch fallback", async () => {
const store = createMockStore();
let callCount = 0;
// First createWithBranch fails with "branch already exists" (not "already used")
// Then createFromExistingBranch fails with "already used by worktree"
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git worktree add")) {
callCount++;
if (command.includes("-b")) {
// First attempt: createWithBranch fails with branch already exists
const error: any = new Error(
"fatal: A branch named 'kb/fn-050' already exists.",
);
error.stderr = Buffer.from(
"fatal: A branch named 'kb/fn-050' already exists.",
);
throw error;
} else {
// Fallback createFromExistingBranch fails with already used
const error: any = new Error(
"fatal: 'kb/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
);
error.stderr = Buffer.from(
"fatal: 'kb/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
);
throw error;
}
}
if (command.includes("git worktree remove")) {
return Buffer.from("");
}
if (command.includes("git branch -D")) {
return Buffer.from("");
}
return Buffer.from("");
});
const executor = new TaskExecutor(store, "/tmp/test");
// Mock the second call to tryCreateWorktree to succeed
// by making subsequent calls succeed after cleanup
let secondAttempt = false;
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git worktree add")) {
if (secondAttempt) {
return Buffer.from(""); // Second attempt succeeds
}
if (command.includes("-b")) {
const error: any = new Error(
"fatal: A branch named 'kb/fn-050' already exists.",
);
error.stderr = Buffer.from(
"fatal: A branch named 'kb/fn-050' already exists.",
);
throw error;
} else {
const error: any = new Error(
"fatal: 'kb/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
);
error.stderr = Buffer.from(
"fatal: 'kb/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'",
);
throw error;
}
}
if (command.includes("git worktree remove")) {
secondAttempt = true; // After cleanup, next add will succeed
return Buffer.from("");
}
if (command.includes("git branch -D")) {
return Buffer.from("");
}
return Buffer.from("");
});
await executor.execute(makeTask());
// Should have cleaned up the conflicting worktree
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining('git worktree remove "/tmp/test/.worktrees/green-sage" --force'),
expect.any(Object),
);
// Should have logged the cleanup
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
expect.any(String),
);
// Task should eventually succeed
expect(store.updateTask).toHaveBeenCalledWith(
"FN-050",
expect.objectContaining({ worktree: expect.any(String) }),
);
});
it("generates new worktree name when conflicting worktree belongs to active task", async () => {
const store = createMockStore();
store.listTasks.mockResolvedValue([

View File

@@ -1363,31 +1363,16 @@ If issues are found that need attention, describe them clearly.`;
// Handle "already used by worktree" conflict
if (conflictInfo.type === "already-used" && conflictInfo.path) {
const shouldGenerateNewName = await this.shouldGenerateNewWorktreeName(
conflictInfo.path,
taskId,
);
if (shouldGenerateNewName) {
// Conflicting worktree belongs to an active task - generate new name
const newPath = join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
await this.store.logEntry(
taskId,
`Conflicting worktree in use by active task, trying new path`,
newPath,
);
return this.tryCreateWorktree(branch, newPath, taskId, startPoint, attemptNumber);
}
// Safe to clean up - conflicting worktree is not in use
const cleanupSuccess = await this.cleanupConflictingWorktree(
const result = await this.handleWorktreeConflict(
conflictInfo.path,
branch,
path,
taskId,
startPoint,
attemptNumber,
);
if (cleanupSuccess) {
await this.store.logEntry(taskId, `Cleaned up conflicting worktree, retrying`, path);
return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber);
if (result) {
return result;
}
throw new Error(
`Worktree conflict at ${conflictInfo.path}: automatic cleanup failed`,
@@ -1419,12 +1404,71 @@ If issues are found that need attention, describe them clearly.`;
createFromExistingBranch();
executorLog.log(`Worktree created from existing branch: ${path}`);
return path;
} catch (e: any) {
throw new Error(`Failed to create worktree: ${e.message}`);
} catch (fallbackError: any) {
// Check if the fallback also hit an "already used" conflict
const fallbackConflictInfo = this.extractWorktreeConflictInfo(fallbackError);
if (fallbackConflictInfo.type === "already-used" && fallbackConflictInfo.path) {
const result = await this.handleWorktreeConflict(
fallbackConflictInfo.path,
branch,
path,
taskId,
startPoint,
attemptNumber,
);
if (result) {
return result;
}
throw new Error(
`Worktree conflict at ${fallbackConflictInfo.path}: automatic cleanup failed`,
);
}
throw new Error(`Failed to create worktree: ${fallbackError.message}`);
}
}
}
/**
* Handle "already used by worktree" conflict.
* Either generates a new worktree name (if conflicting worktree is in use by active task)
* or cleans up the conflicting worktree and retries.
*
* @returns The worktree path if recovery succeeded, null if recovery failed
*/
private async handleWorktreeConflict(
conflictPath: string,
branch: string,
path: string,
taskId: string,
startPoint?: string,
attemptNumber?: number,
): Promise<string | null> {
const shouldGenerateNewName = await this.shouldGenerateNewWorktreeName(
conflictPath,
taskId,
);
if (shouldGenerateNewName) {
// Conflicting worktree belongs to an active task - generate new name
const newPath = join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
await this.store.logEntry(
taskId,
`Conflicting worktree in use by active task, trying new path`,
newPath,
);
return this.tryCreateWorktree(branch, newPath, taskId, startPoint, attemptNumber);
}
// Safe to clean up - conflicting worktree is not in use
const cleanupSuccess = await this.cleanupConflictingWorktree(conflictPath, branch, taskId);
if (cleanupSuccess) {
await this.store.logEntry(taskId, `Cleaned up conflicting worktree, retrying`, path);
return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber);
}
return null;
}
/**
* Check if a path is registered as a git worktree.
*/