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:/);
|
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", () => {
|
it("does not globally claim ordinary work or unrelated work on the same module", () => {
|
||||||
expect(computeCrossParentDiagnosticClaimId({
|
expect(computeCrossParentDiagnosticClaimId({
|
||||||
description: "Add screenshot upload support using html2canvas",
|
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;
|
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 = [
|
const DIAGNOSTIC_OBJECT_PATTERNS = [
|
||||||
/\b(?:missing|unresolved)\s+([`'"]?[@a-z0-9][@a-z0-9._/-]*[`'"]?)/gi,
|
/\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,
|
/\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"]);
|
const IGNORED_DIAGNOSTIC_OBJECTS = new Set(["a", "an", "dependency", "module", "the", "type", "types"]);
|
||||||
|
|
||||||
function normalizeDiagnosticObject(value: string): string | null {
|
function normalizeDiagnosticObject(value: string): string | null {
|
||||||
@@ -96,6 +121,17 @@ function normalizeDiagnosticObject(value: string): string | null {
|
|||||||
return /[0-9@./_-]/.test(normalized) ? normalized : 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
|
* Stable claim for an active diagnostic follow-up that may be discovered by
|
||||||
* several unrelated parent tasks. This is intentionally narrower than general
|
* 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] ?? ""))
|
.map((match) => normalizeDiagnosticObject(match[1] ?? ""))
|
||||||
.filter((value): value is string => value !== null),
|
.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;
|
if (!diagnosticObject) return null;
|
||||||
const fingerprint = computeContentFingerprint({ title: "agent-diagnostic-intent", description: diagnosticObject });
|
const fingerprint = computeContentFingerprint({ title: "agent-diagnostic-intent", description: diagnosticObject });
|
||||||
return fingerprint ? { id: `agent-diagnostic-intent:${fingerprint}`, searchTerm: diagnosticObject } : null;
|
return fingerprint ? { id: `agent-diagnostic-intent:${fingerprint}`, searchTerm: diagnosticObject } : null;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ interface TaskTokenStatsPanelProps {
|
|||||||
| "planningStartedAt"
|
| "planningStartedAt"
|
||||||
| "column"
|
| "column"
|
||||||
| "columnMovedAt"
|
| "columnMovedAt"
|
||||||
|
| "sourceType"
|
||||||
|
| "sourceAgentId"
|
||||||
|
| "sourceParentTaskId"
|
||||||
|
| "sourceMetadata"
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +272,60 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</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">
|
<div className="task-token-stats-panel__section">
|
||||||
<h5>{t("taskDetail.tokenUsage", "Token Usage")}</h5>
|
<h5>{t("taskDetail.tokenUsage", "Token Usage")}</h5>
|
||||||
{!tokenUsage && loading ? (
|
{!tokenUsage && loading ? (
|
||||||
|
|||||||
@@ -285,6 +285,38 @@ describe("TaskTokenStatsPanel", () => {
|
|||||||
* options, and assert the Execution Details label resolves to the leaf string
|
* options, and assert the Execution Details label resolves to the leaf string
|
||||||
* without i18next's "returned an object instead of string" fallback.
|
* 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 () => {
|
it("renders the execution-mode label against the real locale bundle without an object-key crash", async () => {
|
||||||
const instance = createInstance();
|
const instance = createInstance();
|
||||||
await instance.use(initReactI18next).init({
|
await instance.use(initReactI18next).init({
|
||||||
|
|||||||
@@ -7861,9 +7861,14 @@
|
|||||||
"provenance": {
|
"provenance": {
|
||||||
"createdBy": "Created by",
|
"createdBy": "Created by",
|
||||||
"createdVia": "Created via",
|
"createdVia": "Created via",
|
||||||
|
"creatingAgent": "Creating agent",
|
||||||
|
"importedFrom": "Imported from",
|
||||||
|
"nearDuplicateOf": "Flagged near-duplicate of",
|
||||||
|
"parentTask": "Parent task",
|
||||||
"parentTaskOf": "of",
|
"parentTaskOf": "of",
|
||||||
"createdToUndo": "Created to undo",
|
"createdToUndo": "Created to undo",
|
||||||
"undoTask": "Undo task"
|
"undoTask": "Undo task",
|
||||||
|
"title": "Provenance"
|
||||||
},
|
},
|
||||||
"recoveryState": "Recovery state",
|
"recoveryState": "Recovery state",
|
||||||
"refine": {
|
"refine": {
|
||||||
|
|||||||
Reference in New Issue
Block a user