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:
@@ -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([
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user