chore(FN-4350): import dependency content from fusion/fn-4326

Squash-imported the working tree of fusion/fn-4326 as a single commit so this branch carries the dep's content without inheriting its individual commits. If the dep is later squash-merged to main, this commit's patch-id should match the merge and rebase cleanly.

Fusion-Task-Id: FN-4350
Fusion-Task-Lineage: 11fff2c2-1cd1-4562-9b01-032a15217479
This commit is contained in:
gsxdsm
2026-05-13 11:57:14 -07:00
parent bb0d6118e2
commit f378874c14
11 changed files with 423 additions and 24 deletions

View File

@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore moveTask preserveStatus", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
it("clears status/error by default when moving in-progress to todo", async () => {
const task = await harness.store().createTask({ description: "preserveStatus default clear" });
await harness.store().moveTask(task.id, "todo");
await harness.store().moveTask(task.id, "in-progress");
await harness.store().updateTask(task.id, {
status: "failed",
error: "boom",
});
const moved = await harness.store().moveTask(task.id, "todo");
expect(moved.status).toBeUndefined();
expect(moved.error).toBeUndefined();
});
it("preserves status/error when preserveStatus is true on in-progress to todo", async () => {
const task = await harness.store().createTask({ description: "preserveStatus true in-progress" });
await harness.store().moveTask(task.id, "todo");
await harness.store().moveTask(task.id, "in-progress");
await harness.store().updateTask(task.id, {
status: "failed",
error: "branch conflict",
});
const moved = await harness.store().moveTask(task.id, "todo", { preserveStatus: true });
expect(moved.status).toBe("failed");
expect(moved.error).toBe("branch conflict");
});
it("preserves status/error on in-review to todo when preserveStatus is true", async () => {
const task = await harness.store().createTask({ description: "preserveStatus true in-review" });
await harness.store().moveTask(task.id, "todo");
await harness.store().moveTask(task.id, "in-progress");
await harness.store().moveTask(task.id, "in-review");
await harness.store().updateTask(task.id, {
status: "failed",
error: "recovery exhausted",
});
const moved = await harness.store().moveTask(task.id, "todo", { preserveStatus: true });
expect(moved.status).toBe("failed");
expect(moved.error).toBe("recovery exhausted");
});
});

View File

@@ -3461,6 +3461,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* clear the worktree.
*/
preserveWorktree?: boolean;
/**
* When true, do not clear task.status/task.error/task.pausedReason on
* reopen-to-todo/triage transitions. Required so recovery handlers that
* bounce through todo can keep sticky failed state context.
*/
preserveStatus?: boolean;
/**
* When transitioning to in-progress on a task that has no worktree
* assigned, invoke this allocator to pick a path. The store calls
@@ -3541,8 +3547,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
&& (toColumn === "todo" || toColumn === "triage");
if (isReopenToTodoOrTriage) {
task.status = undefined;
task.error = undefined;
if (!options?.preserveStatus) {
task.status = undefined;
task.error = undefined;
task.pausedReason = undefined;
}
task.blockedBy = undefined;
task.paused = undefined;
task.pausedByAgentId = undefined;
@@ -3675,7 +3684,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -3803,6 +3812,21 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.pausedByAgentId !== undefined) {
task.pausedByAgentId = updates.pausedByAgentId;
}
if (updates.pausedReason === null) {
task.pausedReason = undefined;
} else if (updates.pausedReason !== undefined) {
task.pausedReason = updates.pausedReason;
}
if (updates.dispatchStormCount === null) {
task.dispatchStormCount = undefined;
} else if (updates.dispatchStormCount !== undefined) {
task.dispatchStormCount = updates.dispatchStormCount;
}
if (updates.lastDispatchAt === null) {
task.lastDispatchAt = undefined;
} else if (updates.lastDispatchAt !== undefined) {
task.lastDispatchAt = updates.lastDispatchAt;
}
if (updates.assigneeUserId === null) {
task.assigneeUserId = undefined;
} else if (updates.assigneeUserId !== undefined) {

View File

@@ -1162,6 +1162,12 @@ export interface Task {
blockedBy?: string;
/** When true, all automated agent and scheduler interaction is suspended. */
paused?: boolean;
/** Optional machine-readable reason for automated pauses (for example dispatch-storm). */
pausedReason?: string;
/** Dispatch-storm cycle counter tracked by scheduler for todo↔in-progress loop detection. */
dispatchStormCount?: number;
/** ISO timestamp of the most recent dispatch-storm cycle increment. */
lastDispatchAt?: string;
/** When set, this task was paused because the agent with this ID was paused. Cleared when the agent resumes. Distinct from user-initiated pause. */
pausedByAgentId?: string;
/** Configured merge target/base branch for this task (task intent).

View File

@@ -60,6 +60,7 @@ describe("branch-conflicts", () => {
repoDir: "/tmp/repo",
branchName: "fusion/fn-4068",
conflictingWorktreePath: "/tmp/missing-wt",
requestingTaskId: "FN-4068",
startPoint: "main",
});
@@ -70,6 +71,13 @@ describe("branch-conflicts", () => {
it("returns a typed live conflict with stranded commits", async () => {
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command === "git worktree prune") return Buffer.from("");
if (command === "git worktree list --porcelain") {
return Buffer.from(["worktree /tmp/existing-wt", "HEAD 2222222", "branch refs/heads/fusion/fn-4068", ""].join("\n"));
}
if (command.includes("git rev-parse --verify 'refs/heads/fusion/fn-4068^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
return Buffer.from("abc123def456\n");
}
@@ -83,6 +91,7 @@ describe("branch-conflicts", () => {
repoDir: "/tmp/repo",
branchName: "fusion/fn-4068",
conflictingWorktreePath: "/tmp/existing-wt",
requestingTaskId: "FN-4068",
startPoint: "main",
});

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { BranchConflictError } from "../branch-conflicts.js";
import { classifyTaskError } from "../error-classifier.js";
describe("classifyTaskError", () => {
it("classifies branch-conflict-stale", () => {
const error = new BranchConflictError({
branchName: "fusion/fn-1",
conflictingWorktreePath: "/tmp/wt",
existingTipSha: "abc123abc123",
strandedCommits: [],
startPoint: "main",
recommendedAction: "retry",
}) as BranchConflictError & { kind?: string };
error.kind = "stale";
expect(classifyTaskError(error).class).toBe("branch-conflict-stale");
});
it("classifies branch-conflict-live-other", () => {
const error = new BranchConflictError({
branchName: "fusion/fn-1",
conflictingWorktreePath: "/tmp/wt",
existingTipSha: "abc123abc123",
strandedCommits: [],
startPoint: "main",
recommendedAction: "retry",
}) as BranchConflictError & { kind?: string };
error.kind = "live-foreign";
expect(classifyTaskError(error).class).toBe("branch-conflict-live-other");
});
it("classifies branch-conflict-reclaimable", () => {
const error = new BranchConflictError({
branchName: "fusion/fn-1",
conflictingWorktreePath: "/tmp/wt",
existingTipSha: "abc123abc123",
strandedCommits: [],
startPoint: "main",
recommendedAction: "retry",
}) as BranchConflictError & { kind?: string };
error.kind = "reclaimable";
expect(classifyTaskError(error).class).toBe("branch-conflict-reclaimable");
});
it("classifies branch-conflict-unrecoverable", () => {
const error = new BranchConflictError({
branchName: "fusion/fn-1",
conflictingWorktreePath: "/tmp/wt",
existingTipSha: "abc123abc123",
strandedCommits: [],
startPoint: "main",
recommendedAction: "retry",
});
expect(classifyTaskError(error).class).toBe("branch-conflict-unrecoverable");
});
it("classifies worktree-missing", () => {
expect(classifyTaskError(new Error("fatal: '/tmp/wt' is not a working tree")).class).toBe("worktree-missing");
});
it("classifies worktree-locked", () => {
expect(classifyTaskError(new Error("worktree is locked"))).toEqual({
class: "worktree-locked",
recoverable: "auto",
retryAfterMs: 2000,
});
});
it("classifies merge-conflict", () => {
expect(classifyTaskError(new Error("CONFLICT (content): Merge conflict in file.ts")).class).toBe("merge-conflict");
});
it("classifies audit-failure", () => {
const err = new Error("audit failed");
err.name = "SquashAuditError";
expect(classifyTaskError(err).class).toBe("audit-failure");
});
it("classifies unknown", () => {
expect(classifyTaskError("something odd").class).toBe("unknown");
});
});

View File

@@ -811,7 +811,7 @@ describe("TaskExecutor worktree recovery", () => {
worktree: "/tmp/test/.worktrees/green-sage",
}),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "todo", { preserveProgress: true });
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Existing tip: abc123def456"),
@@ -2086,11 +2086,16 @@ describe("TaskExecutor worktree pool integration", () => {
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
);
expect(worktreeAddCalls).toHaveLength(0);
expect(store.moveTask).toHaveBeenCalledWith("FN-020", "todo", { preserveProgress: true });
expect(store.updateTask).toHaveBeenCalledWith(
"FN-020",
expect.objectContaining({ branch: "fusion/fn-020", worktree: "/tmp/test/.worktrees/existing-fn-020" }),
expect.objectContaining({
status: "failed",
branch: "fusion/fn-020",
worktree: "/tmp/test/.worktrees/existing-fn-020",
paused: true,
}),
);
expect(store.moveTask).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
export interface BranchConflictCommit {
sha: string;
@@ -61,11 +62,15 @@ export interface InspectBranchConflictInput {
repoDir: string;
branchName: string;
conflictingWorktreePath: string;
requestingTaskId: string;
startPoint?: string;
}
export type BranchConflictInspectionResult =
| { kind: "stale" }
| { kind: "stale-resolved" }
| { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] }
| { kind: "live-foreign"; livePath: string }
| { kind: "live"; error: BranchConflictError };
export interface ListBranchRecoveryCandidatesInput {
@@ -173,6 +178,30 @@ export async function listBranchRecoveryCandidates(
return candidates;
}
async function countTaskAttributedCommits(repoDir: string, range: string, taskId: string): Promise<number> {
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const subjectPattern = new RegExp(`^(feat|fix|test|chore|docs|refactor|perf|build)\\(${escapedTaskId}\\):`);
const trailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escapedTaskId}(?:\\n|$)`);
let output = "";
try {
output = await runGit(repoDir, `git log --format=%H%x00%s%x00%b ${quoteShellArg(range)}`);
} catch {
return 0;
}
if (!output) return 0;
const tokens = output.split("\u0000");
let count = 0;
for (let i = 0; i + 2 < tokens.length; i += 3) {
const subject = tokens[i + 1] ?? "";
const body = tokens[i + 2] ?? "";
if (subjectPattern.test(subject) || trailerPattern.test(body)) {
count += 1;
}
}
return count;
}
export async function inspectBranchConflict(
input: InspectBranchConflictInput,
): Promise<BranchConflictInspectionResult> {
@@ -181,6 +210,41 @@ export async function inspectBranchConflict(
return { kind: "stale" };
}
try {
await runGit(input.repoDir, "git worktree prune");
} catch {
// best-effort
}
const worktreeMap = await getWorktreeBranchMap(input.repoDir);
const livePath = worktreeMap.get(input.branchName);
try {
await revParse(input.repoDir, `refs/heads/${input.branchName}`);
} catch {
return { kind: "stale-resolved" };
}
if (livePath && livePath !== input.conflictingWorktreePath) {
const tipSha = await revParse(input.repoDir, input.branchName);
const strandedCommits = await listStrandedCommits(input.repoDir, startPoint, input.branchName);
const taskAttributedCommitCount = await countTaskAttributedCommits(
input.repoDir,
`${startPoint}..${input.branchName}`,
input.requestingTaskId,
);
if (taskAttributedCommitCount > 0) {
return {
kind: "reclaimable",
livePath,
tipSha,
taskAttributedCommitCount,
strandedCommits,
};
}
return { kind: "live-foreign", livePath };
}
const existingTipSha = await revParse(input.repoDir, input.branchName);
const strandedCommits = await listStrandedCommits(input.repoDir, startPoint, input.branchName);

View File

@@ -0,0 +1,59 @@
import { BranchConflictError } from "./branch-conflicts.js";
export type ErrorClass =
| "branch-conflict-stale"
| "branch-conflict-live-other"
| "branch-conflict-reclaimable"
| "branch-conflict-unrecoverable"
| "worktree-missing"
| "worktree-locked"
| "merge-conflict"
| "audit-failure"
| "unknown";
export interface TaskErrorClassification {
class: ErrorClass;
recoverable: "auto" | "sticky";
retryAfterMs?: number;
}
function getErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err ?? "");
}
export function classifyTaskError(err: unknown): TaskErrorClassification {
if (err instanceof BranchConflictError) {
const kind = (err as { kind?: string }).kind;
if (kind === "stale" || kind === "stale-resolved") {
return { class: "branch-conflict-stale", recoverable: "auto" };
}
if (kind === "reclaimable") {
return { class: "branch-conflict-reclaimable", recoverable: "auto" };
}
if (kind === "live-foreign") {
return { class: "branch-conflict-live-other", recoverable: "auto" };
}
return { class: "branch-conflict-unrecoverable", recoverable: "sticky" };
}
const message = getErrorMessage(err);
if (/is not a working tree|No such file or directory/i.test(message)) {
return { class: "worktree-missing", recoverable: "auto" };
}
if (/worktree is locked/i.test(message)) {
return { class: "worktree-locked", recoverable: "auto", retryAfterMs: 2000 };
}
if (err instanceof Error && err.name === "SquashAuditError") {
return { class: "audit-failure", recoverable: "sticky" };
}
if (/merge conflict|CONFLICT \(/i.test(message)) {
return { class: "merge-conflict", recoverable: "auto" };
}
return { class: "unknown", recoverable: "sticky" };
}

View File

@@ -3962,7 +3962,21 @@ export class TaskExecutor {
});
// Fall through to terminal failure marking
} else if (isBranchConflictError(err)) {
await this.handleBranchConflict(task, err);
let outcome: "retry" | "reclaimed" | "sticky" = "sticky";
for (let attempt = 1; attempt <= this.MAX_AUTO_RECOVERY_ATTEMPTS; attempt += 1) {
outcome = await this.handleBranchConflict(task, err);
if (outcome !== "retry") break;
await this.store.logEntry(task.id, `[recovery] ${task.id} branch-conflict auto-retry requested (${attempt}/${this.MAX_AUTO_RECOVERY_ATTEMPTS})`, undefined, this.currentRunContext);
}
if (outcome === "retry") {
await this.store.updateTask(task.id, {
status: "failed",
error: err.message,
paused: true,
pausedReason: "branch-conflict-recovery-exhausted",
});
return;
}
return;
} else if (this.options.usageLimitPauser && isUsageLimitError(errorMessage)) {
await this.options.usageLimitPauser.onUsageLimitHit("executor", task.id, errorMessage);
@@ -6324,32 +6338,78 @@ and show an appropriate message to the user.\`
return lines.join("\n");
}
private async handleBranchConflict(task: Task, error: BranchConflictError): Promise<void> {
private readonly MAX_AUTO_RECOVERY_ATTEMPTS = 3;
private async reclaimExistingWorktree(
task: Task,
livePath: string,
branch: string,
tipSha: string,
count: number,
): Promise<void> {
await this.store.updateTask(task.id, { worktree: livePath, branch });
const message = `[recovery] reclaimed existing worktree for ${task.id} at ${livePath} (${count} commits preserved, tip ${tipSha.slice(0, 12)})`;
await this.store.logEntry(task.id, message, undefined, this.currentRunContext);
await this.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "info", message, "executor");
}
private async handleBranchConflict(task: Task, error: BranchConflictError): Promise<"retry" | "reclaimed" | "sticky"> {
const inspection = await inspectBranchConflict({
repoDir: this.rootDir,
branchName: error.branchName,
conflictingWorktreePath: error.conflictingWorktreePath,
requestingTaskId: task.id,
startPoint: error.startPoint,
});
if (inspection.kind === "stale-resolved") {
const message = `[recovery] ${task.id} stage-A: pruned stale admin entry for ${error.branchName}`;
await this.store.logEntry(task.id, message, undefined, this.currentRunContext);
await this.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "info", message, "executor");
return "retry";
}
if (inspection.kind === "reclaimable") {
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, inspection.taskAttributedCommitCount);
return "reclaimed";
}
if (inspection.kind === "live-foreign") {
const cleanupSuccess = await this.cleanupConflictingWorktree(inspection.livePath, error.branchName, task.id);
if (cleanupSuccess) {
try {
await execAsync("git worktree prune", { cwd: this.rootDir });
} catch {
// best-effort
}
try {
const worktreeMap = await this.getWorktreeBranchMap();
if (!worktreeMap.has(error.branchName)) {
await execAsync(`git branch -D "${error.branchName}"`, { cwd: this.rootDir });
}
} catch {
// best-effort
}
return "retry";
}
}
const conflictMessage = `Task branch conflict: ${error.branchName} is already checked out at ${error.conflictingWorktreePath}. ` +
`Run 'fn task branch-recovery ${task.id}' to inspect candidates, then reclaim the existing branch or discard prior work explicitly.`;
await this.store.logEntry(
task.id,
this.formatBranchConflictLifecycleLog(task.id, error),
undefined,
this.currentRunContext,
);
await this.store.appendAgentLog(
task.id,
"Branch conflict recovery required",
"tool_error",
this.formatBranchConflictAgentLog(task.id, error),
"executor",
);
await this.store.logEntry(task.id, this.formatBranchConflictLifecycleLog(task.id, error), undefined, this.currentRunContext);
await this.store.appendAgentLog(task.id, "Branch conflict recovery required", "tool_error", this.formatBranchConflictAgentLog(task.id, error), "executor");
await this.store.updateTask(task.id, {
status: "failed",
error: conflictMessage,
branch: error.branchName,
worktree: error.conflictingWorktreePath,
paused: true,
pausedReason: "branch-conflict-unrecoverable",
});
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "todo", { preserveProgress: true });
executorLog.warn(`${task.id} branch conflict → todo: ${error.branchName} @ ${error.conflictingWorktreePath}`);
executorLog.warn(`${task.id} branch conflict sticky failure: ${error.branchName} @ ${error.conflictingWorktreePath}`);
this.options.onError?.(task, error);
return "sticky";
}
private async createWorktree(
@@ -6982,9 +7042,11 @@ and show an appropriate message to the user.\`
repoDir: this.rootDir,
branchName: branch,
conflictingWorktreePath: conflictPath,
requestingTaskId: taskId,
startPoint,
});
if (inspection.kind === "stale") {
if (inspection.kind === "stale" || inspection.kind === "stale-resolved") {
const cleanupSuccess = await this.cleanupConflictingWorktree(conflictPath, branch, taskId);
if (cleanupSuccess) {
await this.store.logEntry(taskId, `Cleaned up conflicting worktree, retrying`, path);
@@ -6993,6 +7055,24 @@ and show an appropriate message to the user.\`
return null;
}
if (inspection.kind === "reclaimable") {
await this.store.logEntry(
taskId,
`[recovery] reclaimed existing worktree for ${taskId} at ${inspection.livePath} (${inspection.taskAttributedCommitCount} commits preserved)`,
inspection.tipSha,
);
return { path: inspection.livePath, branch };
}
if (inspection.kind === "live-foreign") {
const cleanupSuccess = await this.cleanupConflictingWorktree(inspection.livePath, branch, taskId);
if (cleanupSuccess) {
await this.store.logEntry(taskId, `Removed foreign conflicting worktree and retrying`, inspection.livePath);
return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, 0, allowSiblingBranchRename);
}
return null;
}
if (!allowSiblingBranchRename) {
throw inspection.error;
}
@@ -7069,6 +7149,22 @@ and show an appropriate message to the user.\`
* Determine if we should generate a new worktree name instead of cleaning up.
* Returns true if the conflicting worktree is used by an active task.
*/
private async getWorktreeBranchMap(): Promise<Map<string, string>> {
const { stdout } = await execAsync("git worktree list --porcelain", { cwd: this.rootDir, encoding: "utf-8" });
const map = new Map<string, string>();
let currentWorktree: string | null = null;
for (const line of stdout.split("\n")) {
if (line.startsWith("worktree ")) {
currentWorktree = line.slice("worktree ".length).trim();
} else if (line.startsWith("branch refs/heads/") && currentWorktree) {
map.set(line.slice("branch refs/heads/".length).trim(), currentWorktree);
} else if (!line.trim()) {
currentWorktree = null;
}
}
return map;
}
private async shouldGenerateNewWorktreeName(
conflictPath: string,
currentTaskId: string,

View File

@@ -108,6 +108,7 @@ export {
export { generateReservedWorktreeName, generateWorktreeName, planTaskWorktreePath, slugify } from "./worktree-names.js";
export { createLogger, type Logger } from "./logger.js";
export { fetchWebContent, assertSafeUrl, WebFetchError, type WebFetchOptions, type WebFetchResult, type WebFetchErrorCode } from "./web-fetch.js";
export { classifyTaskError, type ErrorClass, type TaskErrorClassification } from "./error-classifier.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
export { withRateLimitRetry } from "./rate-limit-retry.js";
export { ResearchOrchestrator, type ResearchOrchestratorOptions, type ResearchOrchestratorStatus, type ResearchOrchestratorStartOptions } from "./research-orchestrator.js";

View File

@@ -247,6 +247,7 @@ export class WorktreePool {
repoDir: options?.repoDir ?? worktreePath,
branchName,
conflictingWorktreePath: conflictingPath,
requestingTaskId: branchName,
startPoint: base,
});
if (inspection.kind === "stale") {