feat(FN-3349): add in-review retry mechanism and delivery docs
This merge adds a changeset for feature FN-3349 (in-review retry behavior) and delivery documentation to the `@runfusion/fusion` package. Fusion-Task-Id: FN-3349
This commit is contained in:
@@ -75,7 +75,7 @@ Unpause a task — resumes automated agent and scheduler interaction.
|
||||
|
||||
### fn_task_retry
|
||||
|
||||
Retry a failed task — clears the error state and moves it back to the todo column for re-execution.
|
||||
Retry a failed task — clears the error state. Tasks in other columns move to todo; tasks in in-review stay in-place for auto-merge retry.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
|
||||
@@ -19,7 +19,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
|
||||
| `fn_task_attach` | Attach a file to a task. Supports images (png, jpg, gif, webp) and text files (txt, log, json, yaml, yml, toml, csv, xml). |
|
||||
| `fn_task_pause` | Pause a task — stops all automated agent and scheduler interaction for this task. |
|
||||
| `fn_task_unpause` | Unpause a task — resumes automated agent and scheduler interaction. |
|
||||
| `fn_task_retry` | Retry a failed task — clears the error state and moves it back to the todo column for re-execution. |
|
||||
| `fn_task_retry` | Retry a failed task — clears the error state. Tasks in other columns move to todo; tasks in in-review stay in-place for auto-merge retry. |
|
||||
| `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task. |
|
||||
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
|
||||
| `fn_task_archive` | Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. |
|
||||
|
||||
@@ -666,12 +666,13 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
name: "fn_task_retry",
|
||||
label: "fn: Retry Task",
|
||||
description:
|
||||
"Retry a failed task — clears the error state and moves it back to the todo column for re-execution.",
|
||||
promptSnippet: "Retry a failed Fusion task (clears error, moves to todo)",
|
||||
"Retry a failed task — clears the error state. Tasks in other columns move to todo; tasks in in-review stay in-place for auto-merge retry.",
|
||||
promptSnippet: "Retry a failed Fusion task (clears error, moves to todo or stays in in-review)",
|
||||
promptGuidelines: [
|
||||
"Use when a task has failed and needs to be retried from the beginning",
|
||||
"Only tasks in 'failed' state can be retried",
|
||||
"The task will be moved to the todo column with error state cleared",
|
||||
"Use when a task has failed and needs to be retried",
|
||||
"Only tasks in 'failed' or 'stuck-killed' state can be retried",
|
||||
"Tasks in 'in-review' stay in in-review — only the error/retry state is cleared, and the auto-merge system re-attempts",
|
||||
"Tasks in other columns are moved to the todo column with error state cleared",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Task ID to retry (e.g. FN-001). Must be in 'failed' state." }),
|
||||
@@ -701,7 +702,17 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
};
|
||||
}
|
||||
|
||||
// Clear failure state
|
||||
// In-review retry: keep the task in in-review, clear only error/retry state
|
||||
if (task.column === 'in-review') {
|
||||
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0, mergeRetries: 0 });
|
||||
await store.logEntry(params.id, "Retry requested via Fusion extension (in-review retry, mergeRetries reset)");
|
||||
return {
|
||||
content: [{ type: "text", text: `Retried ${params.id} → in-review (merge retry state cleared, task stays in in-review)` }],
|
||||
details: { taskId: params.id, newColumn: 'in-review' },
|
||||
};
|
||||
}
|
||||
|
||||
// Clear failure state and move to todo for other columns
|
||||
await store.updateTask(params.id, { status: null, error: null });
|
||||
|
||||
// Move to todo column
|
||||
|
||||
@@ -1108,6 +1108,9 @@ export function ModelOnboardingModal({
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
setAuthActionInProgress(null);
|
||||
setAuthProviders((prev) => prev.map((provider) =>
|
||||
provider.id === providerId ? { ...provider, loginInProgress: false } : provider,
|
||||
));
|
||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
|
||||
clearAuthLoginUiState();
|
||||
addToast("Login timed out. Please try again.", "warning");
|
||||
@@ -1749,6 +1752,11 @@ export function ModelOnboardingModal({
|
||||
);
|
||||
|
||||
const renderAiProviderCard = (provider: AuthProvider) => {
|
||||
const hasTerminalLoginOutcome = loginOutcomes[provider.id] === "timeout" ||
|
||||
loginOutcomes[provider.id] === "failed" ||
|
||||
loginOutcomes[provider.id] === "cancelled";
|
||||
const showRemoteLoginInProgress = provider.loginInProgress && !hasTerminalLoginOutcome;
|
||||
|
||||
if (provider.id === "droid-cli" && provider.type === "cli") {
|
||||
return (
|
||||
<DroidCliProviderCard
|
||||
@@ -1860,7 +1868,7 @@ export function ModelOnboardingModal({
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : provider.loginInProgress ? (
|
||||
) : showRemoteLoginInProgress ? (
|
||||
<>
|
||||
<button className="btn btn-sm" disabled>
|
||||
Waiting for login…
|
||||
@@ -1888,13 +1896,13 @@ export function ModelOnboardingModal({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(authActionInProgress === provider.id || provider.loginInProgress) && loginInstructions[provider.id] && (
|
||||
{(authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && (
|
||||
<LoginInstructions
|
||||
instructions={loginInstructions[provider.id]}
|
||||
data-testid={`onboarding-login-instructions-${provider.id}`}
|
||||
/>
|
||||
)}
|
||||
{(authActionInProgress === provider.id || provider.loginInProgress) && manualCodeConfigs[provider.id] && (
|
||||
{(authActionInProgress === provider.id || showRemoteLoginInProgress) && manualCodeConfigs[provider.id] && (
|
||||
<OAuthManualCodeForm
|
||||
value={manualCodeInputs[provider.id] ?? ""}
|
||||
onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))}
|
||||
|
||||
@@ -2726,7 +2726,18 @@ describe("ChatView mobile behavior", () => {
|
||||
value: 900,
|
||||
configurable: true,
|
||||
});
|
||||
messagesContainer.scrollTop = 0;
|
||||
// In jsdom, scrollTop on a non-scrollable div may not reflect writes.
|
||||
// Intercept the setter so the assertion can read back the value the effect wrote.
|
||||
let capturedScrollTop = 0;
|
||||
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||
get() { return capturedScrollTop; },
|
||||
set(v: number) { capturedScrollTop = v; },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Focus the chat input so the hook treats the viewport shrink as a keyboard-open event.
|
||||
const chatInput = screen.getByTestId("chat-input");
|
||||
chatInput.focus();
|
||||
|
||||
// Focus the chat textarea so the hook treats the active element as a
|
||||
// keyboard-focusable target.
|
||||
|
||||
@@ -3401,6 +3401,22 @@ describe("ModelOnboardingModal", () => {
|
||||
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
|
||||
|
||||
const addToast = vi.fn();
|
||||
let pollCallCount = 0;
|
||||
|
||||
mockFetchAuthStatus.mockImplementation(() => {
|
||||
pollCallCount++;
|
||||
return Promise.resolve({
|
||||
providers: [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
authenticated: false,
|
||||
loginInProgress: pollCallCount > 1,
|
||||
type: "oauth",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Use act to render with fake timers
|
||||
await act(async () => {
|
||||
|
||||
@@ -408,6 +408,78 @@ describe("POST /tasks/:id/retry", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (stuck kill budget reset)");
|
||||
});
|
||||
|
||||
it("retries a failed in-review task without moving columns", async () => {
|
||||
const reviewTask = { ...FAKE_TASK_DETAIL, column: "in-review", status: "failed" };
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(reviewTask)
|
||||
.mockResolvedValueOnce(reviewTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(reviewTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
status: null,
|
||||
error: null,
|
||||
stuckKillCount: 0,
|
||||
mergeRetries: 0,
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (in-review retry, mergeRetries reset)");
|
||||
});
|
||||
|
||||
it("retries a stuck-killed in-review task without moving columns", async () => {
|
||||
const reviewTask = { ...FAKE_TASK_DETAIL, column: "in-review", status: "stuck-killed" };
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(reviewTask)
|
||||
.mockResolvedValueOnce(reviewTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(reviewTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
status: null,
|
||||
error: null,
|
||||
stuckKillCount: 0,
|
||||
mergeRetries: 0,
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (in-review retry, mergeRetries reset)");
|
||||
});
|
||||
|
||||
it("preserves worktree/branch when retrying in-review task", async () => {
|
||||
const reviewTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "in-review",
|
||||
status: "failed",
|
||||
worktree: "/path/to/worktree",
|
||||
branch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: "abc123",
|
||||
};
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(reviewTask)
|
||||
.mockResolvedValueOnce(reviewTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(reviewTask);
|
||||
|
||||
await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(updateCall).not.toHaveProperty("worktree");
|
||||
expect(updateCall).not.toHaveProperty("branch");
|
||||
expect(updateCall).not.toHaveProperty("baseBranch");
|
||||
expect(updateCall).not.toHaveProperty("baseCommitSha");
|
||||
expect(updateCall).not.toHaveProperty("recoveryRetryCount");
|
||||
expect(updateCall).not.toHaveProperty("nextRecoveryAt");
|
||||
});
|
||||
|
||||
it("retries a stranded planning triage task in triage and removes stale prompt", async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "kb-task-retry-spec-"));
|
||||
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
|
||||
|
||||
@@ -273,9 +273,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
task.status === "planning" ||
|
||||
task.status === "needs-replan" ||
|
||||
(task.stuckKillCount ?? 0) > 0);
|
||||
const isInReviewRetry =
|
||||
task.column === "in-review" &&
|
||||
(task.status === "failed" || task.status === "stuck-killed");
|
||||
if (task.status !== "failed" && task.status !== "stuck-killed" && !retrySpecification) {
|
||||
throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`);
|
||||
}
|
||||
|
||||
// In-review retry: keep the task in in-review, clear only error/retry state
|
||||
// so the auto-merge system re-attempts on its next sweep.
|
||||
if (isInReviewRetry) {
|
||||
await scopedStore.updateTask(req.params.id, {
|
||||
status: null,
|
||||
error: null,
|
||||
stuckKillCount: 0,
|
||||
mergeRetries: 0,
|
||||
});
|
||||
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review retry, mergeRetries reset)");
|
||||
const updated = await scopedStore.getTask(req.params.id);
|
||||
res.json(updated);
|
||||
return;
|
||||
}
|
||||
|
||||
await scopedStore.updateTask(req.params.id, {
|
||||
status: retrySpecification ? "needs-replan" : null,
|
||||
error: null,
|
||||
|
||||
Reference in New Issue
Block a user