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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
82
packages/engine/src/__tests__/error-classifier.test.ts
Normal file
82
packages/engine/src/__tests__/error-classifier.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
59
packages/engine/src/error-classifier.ts
Normal file
59
packages/engine/src/error-classifier.ts
Normal 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" };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -247,6 +247,7 @@ export class WorktreePool {
|
||||
repoDir: options?.repoDir ?? worktreePath,
|
||||
branchName,
|
||||
conflictingWorktreePath: conflictingPath,
|
||||
requestingTaskId: branchName,
|
||||
startPoint: base,
|
||||
});
|
||||
if (inspection.kind === "stale") {
|
||||
|
||||
Reference in New Issue
Block a user