fix: close Fusion tracking issues created after a task is already done

Late-created GitHub tracking issues never got a task:moved close, and the
reconcile sweep only scanned the oldest 200 terminal rows.
This commit is contained in:
gsxdsm
2026-08-15 15:33:40 -07:00
parent 374ae08d56
commit 987878bd17
5 changed files with 182 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Close Fusion-created GitHub tracking issues when the task is already done.
category: fix
dev: Late-created tracking issues (opened after the task reached Done) now close immediately, and the reconcile sweep prefers recently updated tracked terminals instead of the oldest 200 board rows.

View File

@@ -65,6 +65,35 @@ describe("GitHubTrackingReconciler", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("closes a recently updated tracked done task even when older untracked terminal rows fill the scan window", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const olderUntracked = Array.from({ length: RECONCILE_SCAN_LIMIT }, (_, i) => ({
id: `FN-old-${String(i + 1).padStart(4, "0")}`,
column: "archived",
createdAt: `2026-01-01T00:00:00.${String(i).padStart(3, "0")}Z`,
updatedAt: `2026-01-01T00:00:00.${String(i).padStart(3, "0")}Z`,
}));
const store = createStore({
listTasks: [
...olderUntracked,
{
id: "FN-9061",
column: "done",
createdAt: "2026-08-15T04:05:43.525Z",
updatedAt: "2026-08-15T04:51:10.315Z",
executionCompletedAt: "2026-08-15T04:46:44.428Z",
githubTracking: { enabled: true, issue: { owner: "Runfusion", repo: "Fusion", number: 3458 } },
},
],
});
const result = await new GitHubTrackingReconciler().reconcile(store);
expect(mockSetIssueState).toHaveBeenCalledWith("Runfusion", "Fusion", 3458, "closed", "completed");
expect(result.closed).toBe(1);
});
it("closes open issues for done-column tracked tasks", async () => { it("closes open issues for done-column tracked tasks", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } }); mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" }); mockGetIssue.mockResolvedValue({ state: "open" });
@@ -107,7 +136,7 @@ describe("GitHubTrackingReconciler", () => {
const result = await new GitHubTrackingReconciler().reconcile(store); const result = await new GitHubTrackingReconciler().reconcile(store);
expect(result.closed).toBe(0); expect(result.closed).toBe(0);
expect(result.skipped).toBe(3); expect(result.skipped).toBe(1);
expect(mockSetIssueState).not.toHaveBeenCalled(); expect(mockSetIssueState).not.toHaveBeenCalled();
}); });
@@ -116,8 +145,8 @@ describe("GitHubTrackingReconciler", () => {
mockGetIssue.mockRejectedValueOnce(new Error("boom")); mockGetIssue.mockRejectedValueOnce(new Error("boom"));
mockGetIssue.mockResolvedValueOnce({ state: "open" }); mockGetIssue.mockResolvedValueOnce({ state: "open" });
const store = createStore({ listTasks: [ const store = createStore({ listTasks: [
{ id: "FN-1", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }, { id: "FN-1", column: "done", updatedAt: "2026-08-15T04:51:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
{ id: "FN-2", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } }, { id: "FN-2", column: "done", updatedAt: "2026-08-15T04:50:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
] }); ] });
const result = await new GitHubTrackingReconciler().reconcile(store); const result = await new GitHubTrackingReconciler().reconcile(store);

View File

@@ -3,6 +3,8 @@ import type { Task } from "@fusion/core";
const createIssueMock = vi.fn(); const createIssueMock = vi.fn();
const searchIssuesMock = vi.fn(); const searchIssuesMock = vi.fn();
const getIssueMock = vi.fn();
const setIssueStateMock = vi.fn();
const resolveAuthMock = vi.fn(); const resolveAuthMock = vi.fn();
const summarizeTitleMock = vi.fn(); const summarizeTitleMock = vi.fn();
@@ -19,6 +21,8 @@ vi.mock("../github.js", async (importOriginal) => ({
GitHubClient: vi.fn().mockImplementation(function () { return { GitHubClient: vi.fn().mockImplementation(function () { return {
createIssue: createIssueMock, createIssue: createIssueMock,
searchIssues: searchIssuesMock, searchIssues: searchIssuesMock,
getIssue: getIssueMock,
setIssueState: setIssueStateMock,
}; }), }; }),
})); }));
@@ -163,6 +167,8 @@ describe("maybeCreateTrackingIssue", () => {
}); });
summarizeTitleMock.mockResolvedValue(null); summarizeTitleMock.mockResolvedValue(null);
searchIssuesMock.mockResolvedValue([]); searchIssuesMock.mockResolvedValue([]);
getIssueMock.mockResolvedValue({ state: "open" });
setIssueStateMock.mockResolvedValue(undefined);
}); });
it("returns tracking_disabled when not enabled", async () => { it("returns tracking_disabled when not enabled", async () => {
@@ -229,6 +235,49 @@ describe("maybeCreateTrackingIssue", () => {
expect(createIssueMock).not.toHaveBeenCalled(); expect(createIssueMock).not.toHaveBeenCalled();
}); });
it("closes a newly created tracking issue when the task is already done", async () => {
const linkGithubIssue = vi.fn();
const recordActivity = vi.fn();
const logEntry = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({
title: "Already finished",
column: "done",
executionCompletedAt: "2026-08-15T04:34:26.712Z",
githubTracking: { enabled: true },
}), {
taskStore: { linkGithubIssue, recordActivity, logEntry } as any,
projectSettings: {},
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(result).toMatchObject({ created: true });
expect(createIssueMock).toHaveBeenCalledTimes(1);
expect(setIssueStateMock).toHaveBeenCalledWith("o", "r", 12, "closed", "completed");
expect(logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub tracking issue", "o/r#12");
});
it("does not close a tracking issue created for an in-progress task", async () => {
const linkGithubIssue = vi.fn();
const recordActivity = vi.fn();
await maybeCreateTrackingIssue(buildTask({
title: "Still running",
column: "in-progress",
githubTracking: { enabled: true },
}), {
taskStore: { linkGithubIssue, recordActivity } as any,
projectSettings: {},
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(setIssueStateMock).not.toHaveBeenCalled();
});
it("creates issue, links metadata, and records activity", async () => { it("creates issue, links metadata, and records activity", async () => {
const linkGithubIssue = vi.fn(); const linkGithubIssue = vi.fn();
const recordActivity = vi.fn(); const recordActivity = vi.fn();

View File

@@ -78,6 +78,17 @@ function isTerminalTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boole
|| task.column === (lifecycle?.archived ?? "archived"); || task.column === (lifecycle?.archived ?? "archived");
} }
function hasLinkedTrackingIssue(task: Task): boolean {
const issue = task.githubTracking?.issue;
return task.githubTracking?.enabled === true
&& Boolean(issue?.owner && issue.repo && issue.number);
}
function compareUpdatedAtDesc(a: Task, b: Task): number {
const delta = (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
return delta !== 0 ? delta : b.id.localeCompare(a.id);
}
/** Is this task in the ARCHIVED lane specifically (used for the FN-5577 done-heuristic)? */ /** Is this task in the ARCHIVED lane specifically (used for the FN-5577 done-heuristic)? */
function isArchivedTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boolean { function isArchivedTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boolean {
return task.column === (lifecycleByTaskId.get(task.id)?.archived ?? "archived"); return task.column === (lifecycleByTaskId.get(task.id)?.archived ?? "archived");
@@ -125,14 +136,18 @@ export class GitHubTrackingReconciler {
async reconcile(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> { async reconcile(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
const listedTasks = await store.listTasks({ slim: true, includeArchived: true }); const listedTasks = await store.listTasks({ slim: true, includeArchived: true });
const allTasks = Array.isArray(listedTasks) ? listedTasks : []; const allTasks = Array.isArray(listedTasks) ? listedTasks : [];
const lifecycleByTaskId = await resolveLifecycleByTaskId(store, allTasks, new Map<string, WorkflowIr>(), { /*
match: (task, lifecycle) => task.column === (lifecycle?.complete ?? "done") FNXC:GithubTracking 2026-08-15-22:27:
|| task.column === (lifecycle?.archived ?? "archived"), This board lists thousands of terminal rows oldest-first (`createdAt ASC`). The previous
limit: RECONCILE_SCAN_LIMIT, scan took the first 200 terminal cards — almost all untracked archived history — so a
}); recently completed tracked task (FN-9046 / FN-9054 / FN-9061, positions ~7880+) never
const tasks = allTasks entered the close pass. Restrict to linked tracking issues, then prefer recently updated
.filter((task) => isTerminalTask(task, lifecycleByTaskId)) rows so late-created issues are closed on the next sweep.
.slice(0, RECONCILE_SCAN_LIMIT); */
const tracked = allTasks.filter(hasLinkedTrackingIssue);
const newestTracked = [...tracked].sort(compareUpdatedAtDesc).slice(0, RECONCILE_SCAN_LIMIT);
const lifecycleByTaskId = await resolveLifecycleByTaskId(store, newestTracked, new Map<string, WorkflowIr>());
const tasks = newestTracked.filter((task) => isTerminalTask(task, lifecycleByTaskId));
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">; const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>; const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;

View File

@@ -1,8 +1,11 @@
import { import {
AiServiceError, AiServiceError,
MIN_DESCRIPTION_LENGTH, MIN_DESCRIPTION_LENGTH,
columnsWithFlag,
declaresAnyLifecycleTrait,
parseRepoSlug, parseRepoSlug,
resolveTaskGithubTracking, resolveTaskGithubTracking,
resolveWorkflowIrForTask,
summarizeTitle, summarizeTitle,
type GlobalSettings, type GlobalSettings,
type ProjectSettings, type ProjectSettings,
@@ -257,6 +260,64 @@ export type MaybeCreateTrackingIssueReason =
| "auth_gh_not_authenticated" | "auth_gh_not_authenticated"
| "auth_invalid_mode"; | "auth_invalid_mode";
/*
FNXC:GithubTracking 2026-08-15-22:27:
A tracking issue created AFTER the Fusion task is already done/archived has no later
task:moved event, so GitHubTrackingStateService never closes it. Observed on FN-9046 /
FN-9054 / FN-9061: executionCompletedAt preceded issue.createdAt by 4–15 minutes, and
the issues stayed OPEN. After create or dedup-link, close immediately when the task is
already in a complete or archived lane. Failures are logged and never undo the link.
*/
async function closeTrackingIssueIfTaskAlreadyTerminal(
task: Task,
store: TaskStore,
client: GitHubClient,
issue: { owner: string; repo: string; number: number },
): Promise<void> {
const latest = typeof store.getTask === "function"
? ((await store.getTask(task.id).catch(() => task)) ?? task)
: task;
const ir = await resolveWorkflowIrForTask(store, latest.id).catch(() => undefined);
const traitsExpressed = ir !== undefined && declaresAnyLifecycleTrait(ir);
const completeLanes = ir === undefined || !traitsExpressed ? ["done"] : columnsWithFlag(ir, "complete");
const archivedLanes = ir === undefined || !traitsExpressed ? ["archived"] : columnsWithFlag(ir, "archived");
const isComplete = completeLanes.includes(latest.column);
const isArchived = archivedLanes.includes(latest.column);
if (!isComplete && !isArchived) {
return;
}
try {
const existing = await client.getIssue(issue.owner, issue.repo, issue.number);
if (!existing || existing.state === "closed") {
return;
}
const stateReason = isArchived && !latest.executionCompletedAt ? "not_planned" : "completed";
await client.setIssueState(issue.owner, issue.repo, issue.number, "closed", stateReason);
if (typeof store.logEntry === "function") {
await store.logEntry(latest.id, "Closed linked GitHub tracking issue", `${issue.owner}/${issue.repo}#${issue.number}`);
}
if (typeof store.recordActivity === "function") {
await store.recordActivity({
type: "task:updated",
taskId: latest.id,
taskTitle: latest.title,
details: `Closed linked GitHub tracking issue ${issue.owner}/${issue.repo}#${issue.number} because the task is already ${latest.column}`,
metadata: {
type: "github-issue-closed-already-terminal",
repo: `${issue.owner}/${issue.repo}`,
number: issue.number,
},
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (typeof store.logEntry === "function") {
await store.logEntry(latest.id, "Failed to close GitHub tracking issue", message);
}
}
}
function resolveTrackingTitleSummarizerModel( function resolveTrackingTitleSummarizerModel(
projectSettings: ProjectSettings, projectSettings: ProjectSettings,
globalSettings: GlobalSettings, globalSettings: GlobalSettings,
@@ -517,6 +578,11 @@ export async function maybeCreateTrackingIssue(
}, },
}); });
await closeTrackingIssueIfTaskAlreadyTerminal(latestTask, deps.taskStore, githubClient, {
owner: repo.owner,
repo: repo.repo,
number: bestMatch.candidate.number,
});
return { created: false, reason: "existing_issue_found" }; return { created: false, reason: "existing_issue_found" };
} }
} }
@@ -551,6 +617,11 @@ export async function maybeCreateTrackingIssue(
}, },
}); });
await closeTrackingIssueIfTaskAlreadyTerminal(latestTask, deps.taskStore, githubClient, {
owner: repo.owner,
repo: repo.repo,
number: issue.number,
});
return { created: true, issue }; return { created: true, issue };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);