fix(core): converge cross-parent duplicate follow-ups naming the same failing file; surface task provenance in Stats tab
Widen computeCrossParentDiagnosticClaim so repair tasks phrased as 'exceeds limit / oversized / blocking X / so X passes' converge on a file-path or distinctive-slug anchor at creation time (FN-8510/8511/ 8513/8514 incident: four executors on unrelated parents filed the same oversized-changeset follow-up and none deduped before triage). Add a Provenance section to the Task Detail Stats tab showing source type, parent task, creating agent, imported-issue link, and the triage near-duplicate marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/cross-parent-diagnostic-file-path-claims.md
Normal file
7
.changeset/cross-parent-diagnostic-file-path-claims.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Duplicate follow-up tasks naming the same failing file now converge at creation across parent tasks.
|
||||
category: fix
|
||||
dev: `computeCrossParentDiagnosticClaim` gains file-path/slug fallback objects and wider action/failure gates (exceeds, oversized, blocks, "so X passes"); FN-8510/8511/8513/8514 incident.
|
||||
7
.changeset/task-stats-provenance-section.md
Normal file
7
.changeset/task-stats-provenance-section.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Task Stats tab now shows creation provenance — source type, parent task, creating agent, and duplicate flags.
|
||||
category: feature
|
||||
dev: New Provenance section in `TaskTokenStatsPanel` reading the task's flat source fields and `sourceMetadata.nearDuplicateOf`/`issueUrl`.
|
||||
@@ -138,6 +138,57 @@ describe("findSameAgentDuplicates", () => {
|
||||
expect(claims[0]).toMatch(/^agent-diagnostic-intent:/);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TaskCreationDeduplication 2026-07-22-14:30:
|
||||
FN-8510/8511/8513/8514 regression: four executors on unrelated parents filed the same
|
||||
"fix the oversized changeset summary" follow-up with different phrasings (exceeds limit /
|
||||
so check:changesets passes / oversized / blocking) and different fingerprints; all four
|
||||
must converge on one cross-parent claim anchored to the named changeset file.
|
||||
*/
|
||||
it("derives one cross-parent claim for a gate failure named by file path or slug (FN-8514)", () => {
|
||||
const incidents = [
|
||||
{
|
||||
title: "Shorten mobile board changeset summary",
|
||||
description: "Fix the pre-existing `.changeset/mobile-board-pointercancel-settle.md` summary exceeding the 120-character changeset-format limit, so `pnpm check:changesets` passes.",
|
||||
},
|
||||
{
|
||||
description: "Shorten `.changeset/mobile-board-pointercancel-settle.md` summary to <=120 chars so `pnpm check:changesets` passes. Existing summary is 131 chars; unrelated to FN-8503.",
|
||||
},
|
||||
{
|
||||
description: "Fix oversized summary in existing mobile-board-pointercancel-settle changeset so pnpm check:changesets passes.",
|
||||
},
|
||||
{
|
||||
description: "Fix existing changeset format failure: .changeset/mobile-board-pointercancel-settle.md summary exceeds 120-character limit, blocking pnpm check:changesets.",
|
||||
},
|
||||
];
|
||||
|
||||
const claims = incidents.map((input) => computeCrossParentDiagnosticClaimId(input));
|
||||
|
||||
expect(new Set(claims).size).toBe(1);
|
||||
expect(claims[0]).toMatch(/^agent-diagnostic-intent:/);
|
||||
});
|
||||
|
||||
it("converges failure paraphrases naming the same file path without a distinctive slug", () => {
|
||||
const first = computeCrossParentDiagnosticClaimId({
|
||||
description: "Fix broken import in packages/core/src/store.ts causing a typecheck error.",
|
||||
});
|
||||
const second = computeCrossParentDiagnosticClaimId({
|
||||
description: "Investigate packages/core/src/store.ts typecheck failure observed during pnpm verify:fast.",
|
||||
});
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("does not claim ordinary work that merely names a file path", () => {
|
||||
expect(computeCrossParentDiagnosticClaimId({
|
||||
description: "Add caching to packages/core/src/store.ts for faster board loads",
|
||||
})).toBeNull();
|
||||
expect(computeCrossParentDiagnosticClaimId({
|
||||
description: "Shorten the onboarding copy in WelcomeModal",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("does not globally claim ordinary work or unrelated work on the same module", () => {
|
||||
expect(computeCrossParentDiagnosticClaimId({
|
||||
description: "Add screenshot upload support using html2canvas",
|
||||
|
||||
@@ -78,12 +78,37 @@ export function computeParentIntentClaimId(input: SameAgentDuplicateInput): stri
|
||||
return parentId && anchor ? `agent-parent-intent:${parentId}:${anchor}` : null;
|
||||
}
|
||||
|
||||
const DIAGNOSTIC_ACTION_PATTERN = /\b(?:fix|investigate|repair|resolve|restore)\b/i;
|
||||
const DIAGNOSTIC_FAILURE_PATTERN = /\b(?:cannot|can't|error|fail(?:ed|ure|s)?|missing|ts\d{4}|typecheck|unresolved)\b/i;
|
||||
/*
|
||||
FNXC:TaskCreationDeduplication 2026-07-22-14:30:
|
||||
Widened after the FN-8510/8511/8513/8514 incident: four executors on unrelated parent tasks
|
||||
each filed "fix the oversized .changeset/mobile-board-pointercancel-settle.md summary" follow-ups,
|
||||
and none converged at creation time because the failure was phrased as "exceeds the limit /
|
||||
blocking pnpm check:changesets / so check:changesets passes" with a file path — not as the
|
||||
"missing/unresolved <module>" shape the original diagnostic patterns required.
|
||||
The action/failure gates now cover remediation verbs (shorten, correct, unblock) and
|
||||
gate-failure idioms (exceeds, oversized, blocks/blocking, "so <command> passes").
|
||||
"blocked" is deliberately excluded: "blocked by FN-XXXX" is dependency prose, not a failure report.
|
||||
*/
|
||||
const DIAGNOSTIC_ACTION_PATTERN = /\b(?:correct|fix|investigate|repair|resolve|restore|shorten|unblock)\b/i;
|
||||
const DIAGNOSTIC_FAILURE_PATTERN = /\b(?:cannot|can't|error|exceed(?:s|ed|ing)?|fail(?:ed|ure|s)?|missing|oversized|block(?:s|ing)|ts\d{4}|typecheck|unresolved)\b|\bso\b[^.\n]*\bpass(?:es)?\b/i;
|
||||
const DIAGNOSTIC_OBJECT_PATTERNS = [
|
||||
/\b(?:missing|unresolved)\s+([`'"]?[@a-z0-9][@a-z0-9._/-]*[`'"]?)/gi,
|
||||
/\b(?:cannot|can't)\s+(?:find|resolve)(?:\s+module)?\s+(?:the\s+)?([`'"]?[@a-z0-9][@a-z0-9._/-]*[`'"]?)/gi,
|
||||
];
|
||||
/*
|
||||
FNXC:TaskCreationDeduplication 2026-07-22-14:30:
|
||||
Fallback objects for failure-shaped text with no missing/unresolved-style object.
|
||||
A file path (segments joined by "/" with an extension) is a safe global convergence anchor:
|
||||
two repair tasks naming the same file within the 24h window are the same follow-up.
|
||||
Path objects normalize to their basename stem when the stem is a distinctive multi-hyphen slug
|
||||
(>= 3 hyphen-separated segments), so ".changeset/foo-bar-baz.md" and prose that names only
|
||||
"foo-bar-baz" converge; generic stems like "index" keep the full path so
|
||||
"packages/alpha/index.ts" and "packages/beta/index.ts" stay distinct.
|
||||
Bare multi-hyphen slugs are extracted for the same reason (FN-8513 named the changeset only
|
||||
by its slug, never by path).
|
||||
*/
|
||||
const DIAGNOSTIC_PATH_PATTERN = /(?:^|[\s`'"(])(\.?[a-z0-9_@-]+(?:\/[a-z0-9._@-]+)+\.[a-z0-9]+)/gim;
|
||||
const DIAGNOSTIC_SLUG_PATTERN = /\b([a-z0-9]+(?:-[a-z0-9]+){2,})\b/gi;
|
||||
const IGNORED_DIAGNOSTIC_OBJECTS = new Set(["a", "an", "dependency", "module", "the", "type", "types"]);
|
||||
|
||||
function normalizeDiagnosticObject(value: string): string | null {
|
||||
@@ -96,6 +121,17 @@ function normalizeDiagnosticObject(value: string): string | null {
|
||||
return /[0-9@./_-]/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function isDistinctiveSlug(value: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+){2,}$/.test(value);
|
||||
}
|
||||
|
||||
function normalizeDiagnosticPath(value: string): string {
|
||||
const path = value.toLowerCase().replace(/^[`'".]+|[`'".,;:)]+$/g, "");
|
||||
const basename = path.split("/").at(-1) ?? path;
|
||||
const stem = basename.replace(/\.[a-z0-9]+$/, "");
|
||||
return isDistinctiveSlug(stem) ? stem : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable claim for an active diagnostic follow-up that may be discovered by
|
||||
* several unrelated parent tasks. This is intentionally narrower than general
|
||||
@@ -111,7 +147,21 @@ export function computeCrossParentDiagnosticClaim(input: Pick<SameAgentDuplicate
|
||||
.map((match) => normalizeDiagnosticObject(match[1] ?? ""))
|
||||
.filter((value): value is string => value !== null),
|
||||
);
|
||||
const diagnosticObject = [...new Set(objects)].sort()[0];
|
||||
/*
|
||||
FNXC:TaskCreationDeduplication 2026-07-22-14:30:
|
||||
Primary missing/unresolved objects take precedence over path/slug fallbacks so that a text
|
||||
naming both (e.g. "app/utils/capture-screenshot.ts imports unresolved html2canvas") keeps
|
||||
converging on the precise object regardless of how the paraphrase spells the path.
|
||||
*/
|
||||
if (objects.length === 0) {
|
||||
objects.push(
|
||||
...[...text.matchAll(DIAGNOSTIC_PATH_PATTERN)].map((match) => normalizeDiagnosticPath(match[1] ?? "")),
|
||||
...[...text.matchAll(DIAGNOSTIC_SLUG_PATTERN)]
|
||||
.map((match) => (match[1] ?? "").toLowerCase())
|
||||
.filter(isDistinctiveSlug),
|
||||
);
|
||||
}
|
||||
const diagnosticObject = [...new Set(objects.filter(Boolean))].sort()[0];
|
||||
if (!diagnosticObject) return null;
|
||||
const fingerprint = computeContentFingerprint({ title: "agent-diagnostic-intent", description: diagnosticObject });
|
||||
return fingerprint ? { id: `agent-diagnostic-intent:${fingerprint}`, searchTerm: diagnosticObject } : null;
|
||||
|
||||
@@ -36,6 +36,10 @@ interface TaskTokenStatsPanelProps {
|
||||
| "planningStartedAt"
|
||||
| "column"
|
||||
| "columnMovedAt"
|
||||
| "sourceType"
|
||||
| "sourceAgentId"
|
||||
| "sourceParentTaskId"
|
||||
| "sourceMetadata"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -268,6 +272,60 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
FNXC:TaskStatsProvenance 2026-07-22-14:45:
|
||||
Operators triaging duplicate follow-up tasks (FN-8510/8511/8513/8514 incident) need to see
|
||||
WHO filed a task without querying the DB: the Stats sub-tab surfaces creation provenance —
|
||||
source type, the parent task whose executor called fn_task_create, the creating agent, and
|
||||
the triage near-duplicate marker (canonical task id) when one was recorded.
|
||||
Rows with no value are omitted rather than rendered empty.
|
||||
*/}
|
||||
{task?.sourceType ? (
|
||||
<div className="task-token-stats-panel__section">
|
||||
{/*
|
||||
FNXC:TaskStatsProvenance 2026-07-22-14:45:
|
||||
`taskDetail.provenance` is a nested locale OBJECT (Summary-tab provenance labels);
|
||||
calling t() on it returns an object and crashes the render (issue #1863 pattern).
|
||||
All keys here must be leaves under that namespace, e.g. `taskDetail.provenance.title`.
|
||||
*/}
|
||||
<h5>{t("taskDetail.provenance.title", "Provenance")}</h5>
|
||||
<dl className="task-token-stats-panel__details">
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>{t("taskDetail.provenance.createdVia", "Created via")}</dt>
|
||||
<dd>{task.sourceType}</dd>
|
||||
</div>
|
||||
{task.sourceParentTaskId ? (
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>{t("taskDetail.provenance.parentTask", "Parent task")}</dt>
|
||||
<dd>{task.sourceParentTaskId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{task.sourceAgentId ? (
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>{t("taskDetail.provenance.creatingAgent", "Creating agent")}</dt>
|
||||
<dd>{task.sourceAgentId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{typeof task.sourceMetadata?.issueUrl === "string" ? (
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>{t("taskDetail.provenance.importedFrom", "Imported from")}</dt>
|
||||
<dd>
|
||||
<a href={task.sourceMetadata.issueUrl} target="_blank" rel="noreferrer">
|
||||
{task.sourceMetadata.issueUrl}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{typeof task.sourceMetadata?.nearDuplicateOf === "string" ? (
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>{t("taskDetail.provenance.nearDuplicateOf", "Flagged near-duplicate of")}</dt>
|
||||
<dd>{task.sourceMetadata.nearDuplicateOf}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="task-token-stats-panel__section">
|
||||
<h5>{t("taskDetail.tokenUsage", "Token Usage")}</h5>
|
||||
{!tokenUsage && loading ? (
|
||||
|
||||
@@ -285,6 +285,38 @@ describe("TaskTokenStatsPanel", () => {
|
||||
* options, and assert the Execution Details label resolves to the leaf string
|
||||
* without i18next's "returned an object instead of string" fallback.
|
||||
*/
|
||||
/*
|
||||
FNXC:TaskStatsProvenance 2026-07-22-14:45:
|
||||
Duplicate triage (FN-8510/8511/8513/8514) needs task provenance visible in the UI:
|
||||
the Stats tab must show source type, the parent task whose executor filed this task,
|
||||
the creating agent, and the triage near-duplicate marker when recorded.
|
||||
*/
|
||||
it("renders creation provenance including parent task, agent, and near-duplicate marker", () => {
|
||||
render(<TaskTokenStatsPanel loading={false} tokenUsage={undefined} task={makeTask({
|
||||
sourceType: "api",
|
||||
sourceParentTaskId: "FN-8504",
|
||||
sourceAgentId: "executor-9",
|
||||
sourceMetadata: { nearDuplicateOf: "FN-8510", issueUrl: "https://github.com/Runfusion/Fusion/issues/2356" },
|
||||
})} />);
|
||||
|
||||
expect(screen.getByText("Provenance")).toBeInTheDocument();
|
||||
expect(screen.getByText("api")).toBeInTheDocument();
|
||||
expect(screen.getByText("Parent task")).toBeInTheDocument();
|
||||
expect(screen.getByText("FN-8504")).toBeInTheDocument();
|
||||
expect(screen.getByText("Creating agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("executor-9")).toBeInTheDocument();
|
||||
expect(screen.getByText("Flagged near-duplicate of")).toBeInTheDocument();
|
||||
expect(screen.getByText("FN-8510")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "https://github.com/Runfusion/Fusion/issues/2356" }))
|
||||
.toHaveAttribute("href", "https://github.com/Runfusion/Fusion/issues/2356");
|
||||
});
|
||||
|
||||
it("omits the provenance section when the task has no recorded source", () => {
|
||||
render(<TaskTokenStatsPanel loading={false} tokenUsage={undefined} task={makeTask()} />);
|
||||
|
||||
expect(screen.queryByText("Provenance")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the execution-mode label against the real locale bundle without an object-key crash", async () => {
|
||||
const instance = createInstance();
|
||||
await instance.use(initReactI18next).init({
|
||||
|
||||
@@ -7861,9 +7861,14 @@
|
||||
"provenance": {
|
||||
"createdBy": "Created by",
|
||||
"createdVia": "Created via",
|
||||
"creatingAgent": "Creating agent",
|
||||
"importedFrom": "Imported from",
|
||||
"nearDuplicateOf": "Flagged near-duplicate of",
|
||||
"parentTask": "Parent task",
|
||||
"parentTaskOf": "of",
|
||||
"createdToUndo": "Created to undo",
|
||||
"undoTask": "Undo task"
|
||||
"undoTask": "Undo task",
|
||||
"title": "Provenance"
|
||||
},
|
||||
"recoveryState": "Recovery state",
|
||||
"refine": {
|
||||
|
||||
Reference in New Issue
Block a user