fix(FN-7579): stop tracking-issue dedup from linking new tasks to stale issues

GitHub tracking-issue creation reused old/closed issues via dedup:
- searched state:"all", so a resolved issue from an unrelated task could be reused
- accepted keyword-only matches (generic shared identifiers) with no file overlap

Dedup now only reuses OPEN issues and requires a File-Scope path overlap;
without an open path-overlapping issue a fresh tracking issue is always created.
Adds two regression tests asserting closed and keyword-only matches are not reused.

Fusion-Task-Id: FN-7579

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-05 10:51:44 -07:00
parent 9a0951bf27
commit 45e5a269e8
3 changed files with 91 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Stop GitHub tracking-issue creation from linking new tasks to old/closed issues.
category: fix
dev: github-tracking dedup now only reuses OPEN issues and requires a File-Scope path overlap (keyword-only matches no longer link). Prevents mis-linking a fresh task to a stale/resolved tracking issue (FN-7579). Setting `githubTrackingDedupEnabled` unchanged.

View File

@@ -254,13 +254,14 @@ describe("maybeCreateTrackingIssue", () => {
const linkGithubIssue = vi.fn();
const recordActivity = vi.fn();
// FNXC:GithubTracking Only OPEN issues may be reused (a shared File-Scope path is present here).
searchIssuesMock.mockResolvedValue([
{
number: 400,
title: "Diff route truncation in packages/dashboard/src/routes/register-session-diff-routes.ts",
body: "rebase-merge path drops output",
html_url: "https://github.com/o/r/issues/400",
state: "closed",
state: "open",
updatedAt: "2026-05-01T00:00:00.000Z",
},
]);
@@ -290,6 +291,74 @@ describe("maybeCreateTrackingIssue", () => {
}));
});
// FNXC:GithubTracking 2026-07-05 Regression (FN-7579): dedup mis-linked new tasks to old/stale issues.
// Surfaces: (1) a resolved CLOSED issue that path+keyword-matches must NOT be reused; (2) an OPEN
// issue that matches only on generic keywords (zero File-Scope path overlap) must NOT be reused.
// Invariant: the only reusable candidate is an OPEN issue sharing at least one File-Scope path.
it("does not reuse a CLOSED issue even when file scope and keywords match (FN-7579 stale-issue regression)", async () => {
const linkGithubIssue = vi.fn();
searchIssuesMock.mockResolvedValue([
{
number: 500,
title: "Diff route truncation in packages/dashboard/src/routes/register-session-diff-routes.ts",
body: "rebase-merge truncation resolved long ago",
html_url: "https://github.com/o/r/issues/500",
state: "closed",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
const result = await maybeCreateTrackingIssue(buildTask({
title: "Fix rebase-merge truncation in registerSessionDiffRoutes",
description: "## File Scope\n- packages/dashboard/src/routes/register-session-diff-routes.ts",
githubTracking: { enabled: true },
}), {
taskStore: { linkGithubIssue, recordActivity: vi.fn() } 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(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 12 }));
expect(linkGithubIssue).not.toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 500 }));
});
it("does not reuse an OPEN issue matched on keywords only when no file-scope path overlaps (FN-7579)", async () => {
const linkGithubIssue = vi.fn();
// Shares generic identifiers (truncation / registerSessionDiffRoutes) but references a DIFFERENT file.
searchIssuesMock.mockResolvedValue([
{
number: 501,
title: "truncation bug in registerSessionDiffRoutes helper",
body: "affects packages/dashboard/src/routes/register-other-routes.ts truncation registerSessionDiffRoutes",
html_url: "https://github.com/o/r/issues/501",
state: "open",
updatedAt: "2026-06-01T00:00:00.000Z",
},
]);
const result = await maybeCreateTrackingIssue(buildTask({
title: "Fix rebase-merge truncation in registerSessionDiffRoutes",
description: "## File Scope\n- packages/dashboard/src/routes/register-session-diff-routes.ts",
githubTracking: { enabled: true },
}), {
taskStore: { linkGithubIssue, recordActivity: vi.fn() } 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(linkGithubIssue).not.toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 501 }));
});
it("falls through to create issue when dedup search has no qualifying match", async () => {
searchIssuesMock.mockResolvedValue([
{

View File

@@ -354,11 +354,20 @@ export async function maybeCreateTrackingIssue(
const title = formatTrackingIssueTitle(latestTask);
const body = formatTrackingIssueBody(latestTask);
/*
FNXC:GithubTracking 2026-07-05-00:00:
Tracking-issue dedup was mis-linking new tasks to OLD/STALE issues (operator report: FN-7579 got an old issue id instead of a fresh one).
Two false-positive vectors, both fixed here:
1. Search included CLOSED issues (state: "all"), so a resolved tracking issue from an earlier, unrelated task could be reused. Dedup only exists to avoid opening a *second live* issue for the same active work — a closed/resolved issue must never be reused. We now search and accept OPEN issues only.
2. The accept filter allowed a keyword-only match (matchedKeywords >= 2 with zero file-path overlap). Symptom keywords are generic camelCase identifiers shared across many tasks (e.g. `githubTracking`, `trackingIssue`), so 2-3 shared tokens is a weak signal that routinely mis-matched. We now require at least one File-Scope path overlap before reusing an issue; keyword count only breaks ties / raises confidence.
Net effect: a task with no File-Scope paths (or no OPEN path-overlapping issue) always creates a fresh tracking issue rather than mis-linking. See docs/triage-duplicate-detection-postmortem.md.
*/
if (deps.projectSettings.githubTrackingDedupEnabled !== false) {
try {
const paths = extractFileScopePaths(latestTask as Task & { prompt?: string });
const keywords = extractSymptomKeywords(latestTask, { max: 6 });
if (paths.length > 0 || keywords.length > 0) {
// FNXC:GithubTracking Path overlap is now mandatory for a dedup link — without File-Scope paths there is no strong-enough signal, so skip the search entirely and create fresh.
if (paths.length > 0) {
const queries = buildIssueSearchQueries(paths, keywords);
const byNumber = new Map<number, {
number: number;
@@ -370,8 +379,10 @@ export async function maybeCreateTrackingIssue(
}>();
for (const query of queries) {
const candidates = await githubClient.searchIssues(repo.owner, repo.repo, query, { state: "all", limit: 10 });
const candidates = await githubClient.searchIssues(repo.owner, repo.repo, query, { state: "open", limit: 10 });
for (const candidate of candidates) {
// FNXC:GithubTracking Defensive: never reuse a closed/resolved issue even if the API returns one.
if (candidate.state !== "open") continue;
if (!byNumber.has(candidate.number)) {
byNumber.set(candidate.number, candidate);
}
@@ -380,7 +391,7 @@ export async function maybeCreateTrackingIssue(
const scored = [...byNumber.values()]
.map((candidate) => ({ candidate, ...scoreCandidateIssue(candidate, paths, keywords) }))
.filter((entry) => entry.score >= DEDUP_MATCH_THRESHOLD)
.filter((entry) => entry.matchedPaths.length > 0 || entry.matchedKeywords.length >= 2)
.filter((entry) => entry.matchedPaths.length > 0)
.sort((a, b) => b.score - a.score);
const bestMatch = scored[0];