feat(FN-5443): merge fusion/fn-5443

This commit is contained in:
gsxdsm
2026-05-21 13:36:39 -07:00
parent 252ae24434
commit 9bc5b5f4e9
9 changed files with 89 additions and 13 deletions

View File

@@ -2349,7 +2349,13 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
}); });
await store.moveTask(task.id, "in-progress"); await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review"); await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: "failed", error: "429 rate limited" }); await store.updateTask(task.id, {
status: "failed",
error: "429 rate limited",
taskDoneRetryCount: 2,
workflowStepRetries: 3,
stuckKillCount: 4,
});
const retryTool = api.tools.get("fn_task_retry")!; const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir)); const result = await retryTool.execute("retry-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
@@ -2362,6 +2368,9 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.status).toBeFalsy(); expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy(); expect(updated?.error).toBeFalsy();
expect(updated?.steps[1].status).toBe("in-progress"); expect(updated?.steps[1].status).toBe("in-progress");
expect(updated?.taskDoneRetryCount).toBe(0);
expect(updated?.workflowStepRetries).toBe(0);
expect(updated?.stuckKillCount).toBe(0);
}); });
it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => { it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => {
@@ -2410,7 +2419,14 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
}); });
await store.moveTask(task.id, "in-progress"); await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review"); await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: "failed", error: "merge conflict", mergeRetries: 3 }); await store.updateTask(task.id, {
status: "failed",
error: "merge conflict",
mergeRetries: 3,
taskDoneRetryCount: 5,
workflowStepRetries: 4,
stuckKillCount: 7,
});
const retryTool = api.tools.get("fn_task_retry")!; const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir)); const result = await retryTool.execute("retry-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
@@ -2423,6 +2439,9 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.status).toBeFalsy(); expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy(); expect(updated?.error).toBeFalsy();
expect(updated?.mergeRetries).toBe(0); expect(updated?.mergeRetries).toBe(0);
expect(updated?.taskDoneRetryCount).toBe(0);
expect(updated?.workflowStepRetries).toBe(0);
expect(updated?.stuckKillCount).toBe(0);
}); });
it("keeps zero-step merge-failed in-review task with prior merge attempts in-review and resets merge state", async () => { it("keeps zero-step merge-failed in-review task with prior merge attempts in-review and resets merge state", async () => {

View File

@@ -2415,6 +2415,9 @@ describe("runTaskRetry", () => {
baseCommitSha: null, baseCommitSha: null,
recoveryRetryCount: null, recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 0,
}); });
expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry"); expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry");
@@ -2475,6 +2478,9 @@ describe("runTaskRetry", () => {
baseCommitSha: null, baseCommitSha: null,
recoveryRetryCount: null, recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 0,
}); });
expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry"); expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry");

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch } from "@fusion/core"; import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine"; import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -1011,6 +1011,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
baseCommitSha: null, baseCommitSha: null,
recoveryRetryCount: null, recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
...buildManualRetryResetPatch(),
}); });
// Move to todo column // Move to todo column

View File

@@ -5,6 +5,7 @@ import {
TaskStore, TaskStore,
COLUMNS, COLUMNS,
COLUMN_LABELS, COLUMN_LABELS,
buildManualRetryResetPatch,
validateNodeOverrideChange, validateNodeOverrideChange,
type Task, type Task,
type InsightCategory, type InsightCategory,
@@ -999,7 +1000,11 @@ export default function kbExtension(pi: ExtensionAPI) {
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0); hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
if (isExecutionFailureInReview) { if (isExecutionFailureInReview) {
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0 }); await store.updateTask(params.id, {
status: null,
error: null,
...buildManualRetryResetPatch(),
});
await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)"); await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)");
await store.moveTask(params.id, "todo", { preserveProgress: true }); await store.moveTask(params.id, "todo", { preserveProgress: true });
return { return {
@@ -1008,7 +1013,12 @@ export default function kbExtension(pi: ExtensionAPI) {
}; };
} }
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0, mergeRetries: 0 }); await store.updateTask(params.id, {
status: null,
error: null,
...buildManualRetryResetPatch(),
mergeRetries: 0,
});
await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)"); await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)");
return { return {
content: [{ type: "text", text: `Retried ${params.id} → in-review (merge retry state cleared)` }], content: [{ type: "text", text: `Retried ${params.id} → in-review (merge retry state cleared)` }],
@@ -1017,7 +1027,11 @@ export default function kbExtension(pi: ExtensionAPI) {
} }
// Clear failure state and move to todo for other columns // Clear failure state and move to todo for other columns
await store.updateTask(params.id, { status: null, error: null }); await store.updateTask(params.id, {
status: null,
error: null,
...buildManualRetryResetPatch(),
});
// Move to todo column // Move to todo column
await store.moveTask(params.id, 'todo'); await store.moveTask(params.id, 'todo');

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getPrimaryPrInfo } from "../task-helpers.js"; import { buildManualRetryResetPatch, getPrimaryPrInfo } from "../task-helpers.js";
describe("getPrimaryPrInfo", () => { describe("getPrimaryPrInfo", () => {
it("returns prInfo when only legacy field is set", () => { it("returns prInfo when only legacy field is set", () => {
const prInfo = { number: 1 } as any; const prInfo = { number: 1 } as any;
@@ -23,3 +22,13 @@ describe("getPrimaryPrInfo", () => {
expect(getPrimaryPrInfo({})).toBeUndefined(); expect(getPrimaryPrInfo({})).toBeUndefined();
}); });
}); });
describe("buildManualRetryResetPatch", () => {
it("resets only manual retry counters", () => {
expect(buildManualRetryResetPatch()).toEqual({
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 0,
});
});
});

View File

@@ -200,7 +200,7 @@ export {
hasTitleIdDrift, hasTitleIdDrift,
normalizeTitleForTaskId, normalizeTitleForTaskId,
} from "./task-title-id-drift.js"; } from "./task-title-id-drift.js";
export { getPrimaryPrInfo } from "./task-helpers.js"; export { getPrimaryPrInfo, buildManualRetryResetPatch } from "./task-helpers.js";
export type { export type {
TaskIdIntegrityAnomaly, TaskIdIntegrityAnomaly,
TaskIdIntegrityAnomalyKind, TaskIdIntegrityAnomalyKind,

View File

@@ -3,3 +3,11 @@ import type { PrInfo, Task } from "./types.js";
export function getPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined { export function getPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
return task.prInfos?.[0] ?? task.prInfo; return task.prInfos?.[0] ?? task.prInfo;
} }
export function buildManualRetryResetPatch(): Pick<Task, "taskDoneRetryCount" | "workflowStepRetries" | "stuckKillCount"> {
return {
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 0,
};
}

View File

@@ -342,6 +342,8 @@ describe("POST /tasks/:id/retry", () => {
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null, recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
}); });
@@ -380,6 +382,8 @@ describe("POST /tasks/:id/retry", () => {
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null, recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
}); });
@@ -406,6 +410,8 @@ describe("POST /tasks/:id/retry", () => {
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null, recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
}); });
@@ -435,6 +441,8 @@ describe("POST /tasks/:id/retry", () => {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true }); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
@@ -466,6 +474,8 @@ describe("POST /tasks/:id/retry", () => {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true }); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
@@ -529,6 +539,8 @@ describe("POST /tasks/:id/retry", () => {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true }); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
@@ -562,6 +574,8 @@ describe("POST /tasks/:id/retry", () => {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
mergeRetries: 0, mergeRetries: 0,
}); });
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
@@ -593,6 +607,8 @@ describe("POST /tasks/:id/retry", () => {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
mergeRetries: 0, mergeRetries: 0,
}); });
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
@@ -673,6 +689,8 @@ describe("POST /tasks/:id/retry", () => {
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0, stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null, recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
}); });
@@ -1088,7 +1106,7 @@ describe("POST /tasks/:id/reset", () => {
checkoutLeaseRenewedAt: null, checkoutLeaseRenewedAt: null,
checkoutLeaseEpoch: null, checkoutLeaseEpoch: null,
executionStartedAt: null, executionStartedAt: null,
taskDoneRetryCount: null, taskDoneRetryCount: 0,
worktreeSessionRetryCount: null, worktreeSessionRetryCount: null,
sessionFile: null, sessionFile: null,
}; };

View File

@@ -27,6 +27,7 @@ import {
findDuplicateMatches, findDuplicateMatches,
deterministicGuardLocks, deterministicGuardLocks,
runDeterministicDuplicateGuard, runDeterministicDuplicateGuard,
buildManualRetryResetPatch,
reconcileDeterministicDuplicate, reconcileDeterministicDuplicate,
extractIntentSignature, extractIntentSignature,
findNearDuplicates, findNearDuplicates,
@@ -900,7 +901,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
await scopedStore.updateTask(req.params.id, { await scopedStore.updateTask(req.params.id, {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, ...buildManualRetryResetPatch(),
}); });
await scopedStore.logEntry( await scopedStore.logEntry(
req.params.id, req.params.id,
@@ -914,7 +915,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
await scopedStore.updateTask(req.params.id, { await scopedStore.updateTask(req.params.id, {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, ...buildManualRetryResetPatch(),
mergeRetries: 0, mergeRetries: 0,
}); });
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review merge retry, mergeRetries reset)"); await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
@@ -930,9 +931,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
branch: null, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0,
recoveryRetryCount: null, recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
...buildManualRetryResetPatch(),
}); });
if (retrySpecification) { if (retrySpecification) {