fix(FN-4625): address step 2 typing and exports
Fusion-Task-Id: FN-4625 Fusion-Task-Lineage: c1d9b414-b5dd-4786-ba51-0b8adec4acf5
This commit is contained in:
committed by
gsxdsm
parent
0da21bb880
commit
743b7a480d
@@ -4017,7 +4017,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; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | 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; worktreeSessionRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; 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; firstExecutionAt?: string | null; cumulativeActiveMs?: number | 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; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | 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; worktreeSessionRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; 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; firstExecutionAt?: string | null; cumulativeActiveMs?: number | 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 () => {
|
||||
@@ -4160,6 +4160,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} else if (updates.tokenBudgetSoftAlertedAt !== undefined) {
|
||||
task.tokenBudgetSoftAlertedAt = updates.tokenBudgetSoftAlertedAt;
|
||||
}
|
||||
if (updates.worktrunkFallbackAlertedAt === null) {
|
||||
task.worktrunkFallbackAlertedAt = undefined;
|
||||
} else if (updates.worktrunkFallbackAlertedAt !== undefined) {
|
||||
task.worktrunkFallbackAlertedAt = updates.worktrunkFallbackAlertedAt;
|
||||
}
|
||||
if (updates.worktrunkFailure === null) {
|
||||
task.worktrunkFailure = undefined;
|
||||
} else if (updates.worktrunkFailure !== undefined) {
|
||||
task.worktrunkFailure = updates.worktrunkFailure;
|
||||
}
|
||||
if (updates.tokenBudgetHardAlertedAt === null) {
|
||||
task.tokenBudgetHardAlertedAt = undefined;
|
||||
} else if (updates.tokenBudgetHardAlertedAt !== undefined) {
|
||||
|
||||
166
packages/engine/src/__tests__/worktrunk-failure-handler.test.ts
Normal file
166
packages/engine/src/__tests__/worktrunk-failure-handler.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import {
|
||||
handleWorktrunkOperationFailure,
|
||||
type WorktrunkOperationFailure,
|
||||
type WorktreeOperationResult,
|
||||
} from "../worktrunk-failure-handler.js";
|
||||
import {
|
||||
WorktrunkBinaryUnavailableError,
|
||||
WorktrunkInstallDeniedError,
|
||||
WorktrunkInstallFailedError,
|
||||
} from "../worktrunk-installer.js";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-4625",
|
||||
description: "task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function makeFailure(cause: Error, stderr = "boom", exitCode: number | null = 1): WorktrunkOperationFailure {
|
||||
return { op: "create", cause, stderr, exitCode };
|
||||
}
|
||||
|
||||
describe("handleWorktrunkOperationFailure", () => {
|
||||
it("fail mode pauses, persists details, audits, and rethrows", async () => {
|
||||
const pauseTask = vi.fn().mockResolvedValue(undefined);
|
||||
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
const git = vi.fn().mockResolvedValue(undefined);
|
||||
const notify = vi.fn();
|
||||
const cause = new Error("create failed");
|
||||
|
||||
await expect(handleWorktrunkOperationFailure({
|
||||
failure: makeFailure(cause),
|
||||
task: makeTask(),
|
||||
settings: { enabled: true, onFailure: "fail" },
|
||||
store: { pauseTask, updateTask } as any,
|
||||
runContext: { runId: "run-1", agentId: "agent-1" },
|
||||
runAudit: { git },
|
||||
notify,
|
||||
})).rejects.toThrow("create failed");
|
||||
|
||||
expect(pauseTask).toHaveBeenCalledWith("FN-4625", true, expect.any(Object));
|
||||
expect(updateTask).toHaveBeenCalledWith(
|
||||
"FN-4625",
|
||||
expect.objectContaining({
|
||||
pausedReason: "worktrunk_operation_failed",
|
||||
worktrunkFailure: expect.objectContaining({ op: "create", stderr: "boom", exitCode: 1 }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(git).toHaveBeenCalledWith(expect.objectContaining({ type: "worktree:worktrunk-failure" }));
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fallback-native calls nativeFallback, notifies once, audits, and returns result", async () => {
|
||||
const pauseTask = vi.fn();
|
||||
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
const git = vi.fn().mockResolvedValue(undefined);
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const result: WorktreeOperationResult = { path: "/tmp/wt", branch: "fusion/fn-4625" };
|
||||
const nativeFallback = vi.fn().mockResolvedValue(result);
|
||||
|
||||
const disposition = await handleWorktrunkOperationFailure({
|
||||
failure: makeFailure(new Error("create failed")),
|
||||
task: makeTask(),
|
||||
settings: { enabled: true, onFailure: "fallback-native" },
|
||||
store: { pauseTask, updateTask } as any,
|
||||
runAudit: { git },
|
||||
notify,
|
||||
nativeFallback,
|
||||
});
|
||||
|
||||
expect(disposition).toEqual({ kind: "fallback-native", result, alerted: true });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-4625", expect.objectContaining({ worktrunkFallbackAlertedAt: expect.any(String) }), undefined);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(git).toHaveBeenCalledWith(expect.objectContaining({ type: "worktree:worktrunk-fallback-native" }));
|
||||
expect(pauseTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fallback-native does not re-alert when already alerted", async () => {
|
||||
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
const notify = vi.fn();
|
||||
const nativeFallback = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const disposition = await handleWorktrunkOperationFailure({
|
||||
failure: makeFailure(new Error("create failed")),
|
||||
task: makeTask({ worktrunkFallbackAlertedAt: "2026-05-15T00:00:00.000Z" }),
|
||||
settings: { enabled: true, onFailure: "fallback-native" },
|
||||
store: { pauseTask: vi.fn(), updateTask } as any,
|
||||
notify,
|
||||
nativeFallback,
|
||||
});
|
||||
|
||||
expect(disposition).toEqual({ kind: "fallback-native", result: undefined, alerted: false });
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(updateTask).not.toHaveBeenCalledWith("FN-4625", expect.objectContaining({ worktrunkFallbackAlertedAt: expect.any(String) }), undefined);
|
||||
});
|
||||
|
||||
it("fallback-native without nativeFallback degrades to fail-hard", async () => {
|
||||
const pauseTask = vi.fn().mockResolvedValue(undefined);
|
||||
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
const cause = new Error("create failed");
|
||||
|
||||
await expect(handleWorktrunkOperationFailure({
|
||||
failure: makeFailure(cause),
|
||||
task: makeTask(),
|
||||
settings: { enabled: true, onFailure: "fallback-native" },
|
||||
store: { pauseTask, updateTask } as any,
|
||||
notify: vi.fn(),
|
||||
})).rejects.toThrow("create failed");
|
||||
|
||||
expect(pauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-4625", expect.objectContaining({ pausedReason: "worktrunk_operation_failed" }), undefined);
|
||||
});
|
||||
|
||||
it("native fallback errors are rethrown", async () => {
|
||||
const fallbackError = new Error("native failed");
|
||||
await expect(handleWorktrunkOperationFailure({
|
||||
failure: makeFailure(new Error("create failed")),
|
||||
task: makeTask(),
|
||||
settings: { enabled: true, onFailure: "fallback-native" },
|
||||
store: { pauseTask: vi.fn(), updateTask: vi.fn().mockResolvedValue(undefined) } as any,
|
||||
notify: vi.fn(),
|
||||
nativeFallback: vi.fn().mockRejectedValue(fallbackError),
|
||||
})).rejects.toThrow("native failed");
|
||||
});
|
||||
|
||||
it("truncates stderr preview to 4KB in run-audit metadata", async () => {
|
||||
const git = vi.fn().mockResolvedValue(undefined);
|
||||
const longStderr = "x".repeat(5000);
|
||||
await expect(handleWorktrunkOperationFailure({
|
||||
failure: makeFailure(new Error("create failed"), longStderr),
|
||||
task: makeTask(),
|
||||
settings: { enabled: true, onFailure: "fail" },
|
||||
store: { pauseTask: vi.fn().mockResolvedValue(undefined), updateTask: vi.fn().mockResolvedValue(undefined) } as any,
|
||||
runAudit: { git },
|
||||
notify: vi.fn(),
|
||||
})).rejects.toThrow();
|
||||
|
||||
const call = git.mock.calls.find((entry) => entry[0]?.type === "worktree:worktrunk-failure");
|
||||
expect(call?.[0]?.metadata?.stderrPreview.length).toBe(4097);
|
||||
expect(call?.[0]?.metadata?.stderrPreview.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
new WorktrunkBinaryUnavailableError("missing"),
|
||||
new WorktrunkInstallFailedError("install failed", { stage: "release" }),
|
||||
new WorktrunkInstallDeniedError("denied"),
|
||||
])("handles installer error class %s in fail-hard mode", async (cause) => {
|
||||
await expect(handleWorktrunkOperationFailure({
|
||||
failure: makeFailure(cause, cause.message),
|
||||
task: makeTask(),
|
||||
settings: { enabled: true, onFailure: "fail" },
|
||||
store: { pauseTask: vi.fn().mockResolvedValue(undefined), updateTask: vi.fn().mockResolvedValue(undefined) } as any,
|
||||
notify: vi.fn(),
|
||||
})).rejects.toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,15 @@ export {
|
||||
WORKTRUNK_DOWNLOAD_MAX_BYTES,
|
||||
WORKTRUNK_CARGO_TIMEOUT_MS,
|
||||
} from "./worktrunk-installer.js";
|
||||
export {
|
||||
handleWorktrunkOperationFailure,
|
||||
truncateWorktrunkStderr,
|
||||
type WorktreeOperationResult,
|
||||
type WorktrunkDisposition,
|
||||
type WorktrunkFailureNotification,
|
||||
type WorktrunkOpName,
|
||||
type WorktrunkOperationFailure,
|
||||
} from "./worktrunk-failure-handler.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";
|
||||
|
||||
@@ -71,6 +71,8 @@ export type GitMutationType =
|
||||
| "worktree:worktrunk-sync"
|
||||
| "worktree:worktrunk-prune"
|
||||
| "worktree:worktrunk-fallback"
|
||||
| "worktree:worktrunk-failure"
|
||||
| "worktree:worktrunk-fallback-native"
|
||||
| "branch:create"
|
||||
| "branch:delete"
|
||||
| "branch:checkout"
|
||||
|
||||
Reference in New Issue
Block a user