diff --git a/.changeset/fix-github-tracking-close-after-done.md b/.changeset/fix-github-tracking-close-after-done.md new file mode 100644 index 0000000000..a2a0839b85 --- /dev/null +++ b/.changeset/fix-github-tracking-close-after-done.md @@ -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. diff --git a/packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts b/packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts index 084e63510a..b8527236b5 100644 --- a/packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking-reconciler.test.ts @@ -65,6 +65,35 @@ describe("GitHubTrackingReconciler", () => { 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 () => { mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } }); mockGetIssue.mockResolvedValue({ state: "open" }); @@ -107,7 +136,7 @@ describe("GitHubTrackingReconciler", () => { const result = await new GitHubTrackingReconciler().reconcile(store); expect(result.closed).toBe(0); - expect(result.skipped).toBe(3); + expect(result.skipped).toBe(1); expect(mockSetIssueState).not.toHaveBeenCalled(); }); @@ -116,8 +145,8 @@ describe("GitHubTrackingReconciler", () => { mockGetIssue.mockRejectedValueOnce(new Error("boom")); mockGetIssue.mockResolvedValueOnce({ state: "open" }); const store = createStore({ listTasks: [ - { id: "FN-1", column: "done", 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-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", updatedAt: "2026-08-15T04:50:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } }, ] }); const result = await new GitHubTrackingReconciler().reconcile(store); diff --git a/packages/dashboard/src/__tests__/github-tracking.test.ts b/packages/dashboard/src/__tests__/github-tracking.test.ts index 30cbad6bb0..b572888ff3 100644 --- a/packages/dashboard/src/__tests__/github-tracking.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking.test.ts @@ -3,6 +3,8 @@ import type { Task } from "@fusion/core"; const createIssueMock = vi.fn(); const searchIssuesMock = vi.fn(); +const getIssueMock = vi.fn(); +const setIssueStateMock = vi.fn(); const resolveAuthMock = vi.fn(); const summarizeTitleMock = vi.fn(); @@ -19,6 +21,8 @@ vi.mock("../github.js", async (importOriginal) => ({ GitHubClient: vi.fn().mockImplementation(function () { return { createIssue: createIssueMock, searchIssues: searchIssuesMock, + getIssue: getIssueMock, + setIssueState: setIssueStateMock, }; }), })); @@ -163,6 +167,8 @@ describe("maybeCreateTrackingIssue", () => { }); summarizeTitleMock.mockResolvedValue(null); searchIssuesMock.mockResolvedValue([]); + getIssueMock.mockResolvedValue({ state: "open" }); + setIssueStateMock.mockResolvedValue(undefined); }); it("returns tracking_disabled when not enabled", async () => { @@ -229,6 +235,49 @@ describe("maybeCreateTrackingIssue", () => { 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 () => { const linkGithubIssue = vi.fn(); const recordActivity = vi.fn(); diff --git a/packages/dashboard/src/github-tracking-reconciler.ts b/packages/dashboard/src/github-tracking-reconciler.ts index 2b757323ac..2e884cec11 100644 --- a/packages/dashboard/src/github-tracking-reconciler.ts +++ b/packages/dashboard/src/github-tracking-reconciler.ts @@ -78,6 +78,17 @@ function isTerminalTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boole || 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)? */ function isArchivedTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boolean { 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 }> { const listedTasks = await store.listTasks({ slim: true, includeArchived: true }); const allTasks = Array.isArray(listedTasks) ? listedTasks : []; - const lifecycleByTaskId = await resolveLifecycleByTaskId(store, allTasks, new Map(), { - match: (task, lifecycle) => task.column === (lifecycle?.complete ?? "done") - || task.column === (lifecycle?.archived ?? "archived"), - limit: RECONCILE_SCAN_LIMIT, - }); - const tasks = allTasks - .filter((task) => isTerminalTask(task, lifecycleByTaskId)) - .slice(0, RECONCILE_SCAN_LIMIT); + /* + FNXC:GithubTracking 2026-08-15-22:27: + This board lists thousands of terminal rows oldest-first (`createdAt ASC`). The previous + 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 + entered the close pass. Restrict to linked tracking issues, then prefer recently updated + rows so late-created issues are closed on the next sweep. + */ + const tracked = allTasks.filter(hasLinkedTrackingIssue); + const newestTracked = [...tracked].sort(compareUpdatedAtDesc).slice(0, RECONCILE_SCAN_LIMIT); + const lifecycleByTaskId = await resolveLifecycleByTaskId(store, newestTracked, new Map()); + const tasks = newestTracked.filter((task) => isTerminalTask(task, lifecycleByTaskId)); const projectSettings = ((await store.getSettings()) ?? {}) as Pick; const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick; diff --git a/packages/dashboard/src/github-tracking.ts b/packages/dashboard/src/github-tracking.ts index 2774f821df..cded26ba82 100644 --- a/packages/dashboard/src/github-tracking.ts +++ b/packages/dashboard/src/github-tracking.ts @@ -1,8 +1,11 @@ import { AiServiceError, MIN_DESCRIPTION_LENGTH, + columnsWithFlag, + declaresAnyLifecycleTrait, parseRepoSlug, resolveTaskGithubTracking, + resolveWorkflowIrForTask, summarizeTitle, type GlobalSettings, type ProjectSettings, @@ -257,6 +260,64 @@ export type MaybeCreateTrackingIssueReason = | "auth_gh_not_authenticated" | "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 { + 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( projectSettings: ProjectSettings, 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" }; } } @@ -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 }; } catch (error) { const message = error instanceof Error ? error.message : String(error);