feat(FN-4161): preserve github tracking detail after restart
Fixed GitHub issue tracking details (URL, repo, issue number) being lost when an executor restarts mid-task, with tests and docs to guard against regression. Fusion-Task-Id: FN-4161
This commit is contained in:
5
.changeset/fn-4161-github-tracking-restart.md
Normal file
5
.changeset/fn-4161-github-tracking-restart.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix GitHub tracking state appearing stale/inaccurate on tasks after engine restart.
|
||||||
@@ -428,6 +428,7 @@ Tracking behavior is controlled per task:
|
|||||||
- `task.githubTracking.enabled` turns tracking on for that task.
|
- `task.githubTracking.enabled` turns tracking on for that task.
|
||||||
- `task.githubTracking.repoOverride` optionally forces a specific target repo (`owner/repo`).
|
- `task.githubTracking.repoOverride` optionally forces a specific target repo (`owner/repo`).
|
||||||
- In the dashboard **Task Detail** modal, eligible existing tasks (`triage`, `todo`, `in-progress`, `in-review`) always show a compact GitHub tracking summary row. When tracking is currently disabled and editable, the header exposes a one-click **Enable GitHub tracking** button; linked-issue details and the rest of the tracking controls remain behind the disclosure arrow for disable/retarget flows.
|
- In the dashboard **Task Detail** modal, eligible existing tasks (`triage`, `todo`, `in-progress`, `in-review`) always show a compact GitHub tracking summary row. When tracking is currently disabled and editable, the header exposes a one-click **Enable GitHub tracking** button; linked-issue details and the rest of the tracking controls remain behind the disclosure arrow for disable/retarget flows.
|
||||||
|
- After an engine/dashboard restart, Task Detail preserves the fetched full `githubTracking` payload even when the board opened the modal from a slim task row that intentionally omitted tracking metadata.
|
||||||
- When a task is already tracking-enabled but still unlinked, Task Detail exposes a **Create tracking issue** action in the disclosure content (including non-editable columns like `done`) so "Issue not yet created" is not a dead-end state.
|
- When a task is already tracking-enabled but still unlinked, Task Detail exposes a **Create tracking issue** action in the disclosure content (including non-editable columns like `done`) so "Issue not yet created" is not a dead-end state.
|
||||||
- Clearing the Task Detail repo override stores `null`, which reverts repo resolution to project/global defaults.
|
- Clearing the Task Detail repo override stores `null`, which reverts repo resolution to project/global defaults.
|
||||||
- Explicit task-level enablement is honored even when project/global GitHub tracking defaults are unset. If `enabled: true` and the repo resolves at task scope (for example via `repoOverride`), Fusion attempts tracking-issue creation on both create-time and eligible edit-time flows.
|
- Explicit task-level enablement is honored even when project/global GitHub tracking defaults are unset. If `enabled: true` and the repo resolves at task scope (for example via `repoOverride`), Fusion attempts tracking-issue creation on both create-time and eligible edit-time flows.
|
||||||
|
|||||||
@@ -25,10 +25,36 @@ describe("TaskStore github tracking", () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
store.close();
|
store.close();
|
||||||
await rm(rootDir, { recursive: true, force: true });
|
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
await rm(globalDir, { recursive: true, force: true });
|
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function reopenDiskBackedStore(
|
||||||
|
setup: (diskStore: TaskStore) => Promise<void>,
|
||||||
|
assertions: (reloadedStore: TaskStore) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const diskRoot = makeTmpDir();
|
||||||
|
const diskGlobal = makeTmpDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstStore = new TaskStore(diskRoot, diskGlobal);
|
||||||
|
await firstStore.init();
|
||||||
|
await setup(firstStore);
|
||||||
|
firstStore.close();
|
||||||
|
|
||||||
|
const reloadedStore = new TaskStore(diskRoot, diskGlobal);
|
||||||
|
await reloadedStore.init();
|
||||||
|
try {
|
||||||
|
await assertions(reloadedStore);
|
||||||
|
} finally {
|
||||||
|
reloadedStore.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(diskRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
await rm(diskGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const issue: TaskGithubTrackedIssue = {
|
const issue: TaskGithubTrackedIssue = {
|
||||||
owner: "octocat",
|
owner: "octocat",
|
||||||
repo: "hello-world",
|
repo: "hello-world",
|
||||||
@@ -138,4 +164,103 @@ describe("TaskStore github tracking", () => {
|
|||||||
issue,
|
issue,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists githubTracking across store restart for detail and non-slim listings", async () => {
|
||||||
|
await reopenDiskBackedStore(
|
||||||
|
async (diskStore) => {
|
||||||
|
const created = await diskStore.createTask({ description: "Restart tracking" });
|
||||||
|
await diskStore.updateGithubTracking(created.id, {
|
||||||
|
enabled: true,
|
||||||
|
repoOverride: "octocat/hello-world",
|
||||||
|
issue,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async (reloadedStore) => {
|
||||||
|
const reloadedTask = (await reloadedStore.listTasks()).find((task) => task.description === "Restart tracking");
|
||||||
|
expect(reloadedTask?.githubTracking).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
repoOverride: "octocat/hello-world",
|
||||||
|
issue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetched = await reloadedStore.getTask(reloadedTask!.id);
|
||||||
|
expect(fetched.githubTracking).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
repoOverride: "octocat/hello-world",
|
||||||
|
issue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const slim = await reloadedStore.listTasks({ slim: true });
|
||||||
|
expect(slim.find((task) => task.id === reloadedTask!.id)?.githubTracking).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists disabled state, repo override, and issue mutations across repeated restarts", async () => {
|
||||||
|
const diskRoot = makeTmpDir();
|
||||||
|
const diskGlobal = makeTmpDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let firstStore = new TaskStore(diskRoot, diskGlobal);
|
||||||
|
await firstStore.init();
|
||||||
|
const created = await firstStore.createTask({ description: "Restart tracking mutations" });
|
||||||
|
await firstStore.updateGithubTracking(created.id, {
|
||||||
|
enabled: false,
|
||||||
|
repoOverride: "octocat/hello-world",
|
||||||
|
issue,
|
||||||
|
});
|
||||||
|
firstStore.close();
|
||||||
|
|
||||||
|
let secondStore = new TaskStore(diskRoot, diskGlobal);
|
||||||
|
await secondStore.init();
|
||||||
|
// FN-4161 repro: SQLite restart hydration is intact; downstream dashboard layers receive the correct value from core.
|
||||||
|
expect((await secondStore.getTask(created.id)).githubTracking).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
repoOverride: "octocat/hello-world",
|
||||||
|
issue,
|
||||||
|
});
|
||||||
|
expect((await secondStore.listTasks()).find((task) => task.id === created.id)?.githubTracking).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
repoOverride: "octocat/hello-world",
|
||||||
|
issue,
|
||||||
|
});
|
||||||
|
|
||||||
|
await secondStore.unlinkGithubIssue(created.id);
|
||||||
|
expect((await secondStore.getTask(created.id)).githubTracking?.issue).toBeUndefined();
|
||||||
|
secondStore.close();
|
||||||
|
|
||||||
|
let thirdStore = new TaskStore(diskRoot, diskGlobal);
|
||||||
|
await thirdStore.init();
|
||||||
|
const afterUnlink = await thirdStore.getTask(created.id);
|
||||||
|
expect(afterUnlink.githubTracking?.enabled).toBe(false);
|
||||||
|
expect(afterUnlink.githubTracking?.repoOverride).toBe("octocat/hello-world");
|
||||||
|
expect(afterUnlink.githubTracking?.issue).toBeUndefined();
|
||||||
|
expect(afterUnlink.githubTracking?.unlinkedAt).toBeTruthy();
|
||||||
|
|
||||||
|
await thirdStore.linkGithubIssue(created.id, issue);
|
||||||
|
await thirdStore.updateGithubTracking(created.id, {
|
||||||
|
enabled: false,
|
||||||
|
repoOverride: "octocat/renamed-repo",
|
||||||
|
issue,
|
||||||
|
});
|
||||||
|
thirdStore.close();
|
||||||
|
|
||||||
|
const fourthStore = new TaskStore(diskRoot, diskGlobal);
|
||||||
|
await fourthStore.init();
|
||||||
|
try {
|
||||||
|
const fetched = await fourthStore.getTask(created.id);
|
||||||
|
expect(fetched.githubTracking).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
repoOverride: "octocat/renamed-repo",
|
||||||
|
issue,
|
||||||
|
});
|
||||||
|
expect((await fourthStore.listTasks({ slim: true })).find((task) => task.id === created.id)?.githubTracking).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
fourthStore.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(diskRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
await rm(diskGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -453,8 +453,17 @@ export function TaskDetailContent({
|
|||||||
// keeps populating while a task runs after the modal was opened. `log` is
|
// keeps populating while a task runs after the modal was opened. `log` is
|
||||||
// stripped to [] in SSE payloads (stripTaskListHeavyFields), so we preserve
|
// stripped to [] in SSE payloads (stripTaskListHeavyFields), so we preserve
|
||||||
// fullDetail.log to keep the Activity timeline populated.
|
// fullDetail.log to keep the Activity timeline populated.
|
||||||
|
// FN-4161: board/restart flows open the modal from slim task rows where
|
||||||
|
// `githubTracking` is intentionally omitted; preserve the fetched full-detail
|
||||||
|
// tracking blob instead of letting the sparse parent prop overwrite it.
|
||||||
const workingTask: TaskDetail = fullDetail
|
const workingTask: TaskDetail = fullDetail
|
||||||
? ({ ...fullDetail, ...task, prompt: fullDetail.prompt, log: fullDetail.log } as TaskDetail)
|
? ({
|
||||||
|
...fullDetail,
|
||||||
|
...task,
|
||||||
|
prompt: fullDetail.prompt,
|
||||||
|
log: fullDetail.log,
|
||||||
|
githubTracking: task.githubTracking ?? fullDetail.githubTracking,
|
||||||
|
} as TaskDetail)
|
||||||
: ({ ...task, prompt: "" } as TaskDetail);
|
: ({ ...task, prompt: "" } as TaskDetail);
|
||||||
const canRetryTask =
|
const canRetryTask =
|
||||||
task.status === "failed" ||
|
task.status === "failed" ||
|
||||||
@@ -586,11 +595,11 @@ export function TaskDetailContent({
|
|||||||
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
||||||
setSourceIssueExpanded(false);
|
setSourceIssueExpanded(false);
|
||||||
setGithubTrackingExpanded(false);
|
setGithubTrackingExpanded(false);
|
||||||
setGithubRepoOverrideDraft(task.githubTracking?.repoOverride ?? "");
|
setGithubRepoOverrideDraft(workingTask.githubTracking?.repoOverride ?? "");
|
||||||
setGithubTrackingEnabledDraft(null);
|
setGithubTrackingEnabledDraft(null);
|
||||||
setGithubRepoOverrideError(null);
|
setGithubRepoOverrideError(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode, task.githubTracking]);
|
}, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode, workingTask.githubTracking]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
|
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
|
||||||
@@ -606,10 +615,10 @@ export function TaskDetailContent({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (githubTrackingEnabledDraft === null) return;
|
if (githubTrackingEnabledDraft === null) return;
|
||||||
if ((task.githubTracking?.enabled === true) === githubTrackingEnabledDraft) {
|
if ((workingTask.githubTracking?.enabled === true) === githubTrackingEnabledDraft) {
|
||||||
setGithubTrackingEnabledDraft(null);
|
setGithubTrackingEnabledDraft(null);
|
||||||
}
|
}
|
||||||
}, [githubTrackingEnabledDraft, task.githubTracking?.enabled]);
|
}, [githubTrackingEnabledDraft, workingTask.githubTracking?.enabled]);
|
||||||
|
|
||||||
// Load merged settings for effective model resolution
|
// Load merged settings for effective model resolution
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -788,10 +797,10 @@ export function TaskDetailContent({
|
|||||||
// Check if task can be edited
|
// Check if task can be edited
|
||||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isSaving;
|
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isSaving;
|
||||||
const canEditGithubTracking = GITHUB_TRACKING_EDITABLE_COLUMNS.has(task.column) && !isSaving;
|
const canEditGithubTracking = GITHUB_TRACKING_EDITABLE_COLUMNS.has(task.column) && !isSaving;
|
||||||
const githubTrackingEnabled = githubTrackingEnabledDraft ?? (task.githubTracking?.enabled === true);
|
const githubTrackingEnabled = githubTrackingEnabledDraft ?? (workingTask.githubTracking?.enabled === true);
|
||||||
const githubTrackedIssue = task.githubTracking?.issue;
|
const githubTrackedIssue = workingTask.githubTracking?.issue;
|
||||||
const showInlineGithubTrackingEnableButton =
|
const showInlineGithubTrackingEnableButton =
|
||||||
canEditGithubTracking && !githubTrackedIssue && (!githubTrackingEnabled || (isSavingGithubTracking && task.githubTracking?.enabled !== true));
|
canEditGithubTracking && !githubTrackedIssue && (!githubTrackingEnabled || (isSavingGithubTracking && workingTask.githubTracking?.enabled !== true));
|
||||||
const showGithubTrackingSection = canEditGithubTracking || githubTrackingEnabled || Boolean(githubTrackedIssue);
|
const showGithubTrackingSection = canEditGithubTracking || githubTrackingEnabled || Boolean(githubTrackedIssue);
|
||||||
const githubTrackingStatus = githubTrackedIssue ? "Linked" : githubTrackingEnabled ? "Enabled" : "Disabled";
|
const githubTrackingStatus = githubTrackedIssue ? "Linked" : githubTrackingEnabled ? "Enabled" : "Disabled";
|
||||||
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings ?? null, globalSettings);
|
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings ?? null, globalSettings);
|
||||||
@@ -810,12 +819,12 @@ export function TaskDetailContent({
|
|||||||
}, projectId);
|
}, projectId);
|
||||||
onTaskUpdated?.(updatedTask);
|
onTaskUpdated?.(updatedTask);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setGithubTrackingEnabledDraft(task.githubTracking?.enabled === true);
|
setGithubTrackingEnabledDraft(workingTask.githubTracking?.enabled === true);
|
||||||
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
|
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current) setIsSavingGithubTracking(false);
|
if (mountedRef.current) setIsSavingGithubTracking(false);
|
||||||
}
|
}
|
||||||
}, [addToast, canEditGithubTracking, githubTrackingEnabled, isSavingGithubTracking, onTaskUpdated, projectId, task.githubTracking?.enabled, task.id]);
|
}, [addToast, canEditGithubTracking, githubTrackingEnabled, isSavingGithubTracking, onTaskUpdated, projectId, workingTask.githubTracking?.enabled, task.id]);
|
||||||
|
|
||||||
const handleSaveGithubRepoOverride = useCallback(async () => {
|
const handleSaveGithubRepoOverride = useCallback(async () => {
|
||||||
if (!canEditGithubTracking || isSavingGithubTracking) return;
|
if (!canEditGithubTracking || isSavingGithubTracking) return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
makeTask,
|
makeTask,
|
||||||
noop,
|
noop,
|
||||||
@@ -2188,6 +2189,54 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(screen.getByRole("link", { name: "runfusion/fusion#123" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/123");
|
expect(screen.getByRole("link", { name: "runfusion/fusion#123" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves fetched githubTracking detail when the optimistic task prop came from a slim restart listing", async () => {
|
||||||
|
const { fetchTaskDetail } = await import("../../api");
|
||||||
|
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(
|
||||||
|
makeTask({
|
||||||
|
id: "FN-301",
|
||||||
|
column: "todo",
|
||||||
|
prompt: "# Spec",
|
||||||
|
githubTracking: {
|
||||||
|
enabled: true,
|
||||||
|
repoOverride: "runfusion/fusion",
|
||||||
|
issue: {
|
||||||
|
owner: "runfusion",
|
||||||
|
repo: "fusion",
|
||||||
|
number: 301,
|
||||||
|
url: "https://github.com/runfusion/fusion/issues/301",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const optimisticTask = makeTask({ id: "FN-301", column: "todo" }) as Task;
|
||||||
|
delete (optimisticTask as Partial<Task>).prompt;
|
||||||
|
delete (optimisticTask as Partial<Task>).githubTracking;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={optimisticTask}
|
||||||
|
onClose={noop}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// FN-4161 repro: the optimistic task prop came from a slim restart listing,
|
||||||
|
// so fetched full detail must win when the prop omits githubTracking.
|
||||||
|
expect(screen.getByLabelText("GitHub tracking status")).toHaveTextContent("Linked");
|
||||||
|
});
|
||||||
|
|
||||||
|
expandGithubTracking();
|
||||||
|
expect(screen.getByDisplayValue("runfusion/fusion")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: "runfusion/fusion#301" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/301");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows section when tracking is disabled and task is in an eligible column", () => {
|
it("shows section when tracking is disabled and task is in an eligible column", () => {
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
|
|||||||
Reference in New Issue
Block a user