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:
Fusion
2026-05-04 01:14:59 -07:00
committed by gsxdsm
parent eb21d84fa3
commit 37954f3569
9 changed files with 154 additions and 12 deletions

View File

@@ -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 |
|-----------|------|----------|-------------|

View File

@@ -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. |

View File

@@ -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

View File

@@ -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 }))}

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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");

View File

@@ -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,