feat(FN-5077): merge fusion/fn-5077

This commit is contained in:
gsxdsm
2026-05-18 15:01:20 -07:00
parent 6c1732d234
commit 896ae7a931
8 changed files with 127 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix malformed task titles when foreign FN-XXX tokens are stripped: dangling trailing connective words (e.g. "of", "to", "for") that would otherwise produce fragments like "Close as duplicate of" are now rejected, so token-stripped residuals never persist as task titles.

View File

@@ -185,7 +185,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
- **Restart recovery**: `RestartRecoveryCoordinator` classifies interrupted `in-progress` runs. Unusable-worktree session-start failures (`missing`, `incomplete`, `unregistered git worktree`) are recoverable; retries are capped at `MAX_WORKTREE_SESSION_RETRIES=3` before escalating.
- **Executor pre-session liveness gate (FN-4935)**: the gate now skips for fresh acquisitions (`acquisition.source === "fresh"`), emits structured `not_usable_task_worktree:<classification>` diagnostics (including canonicalized registered-path snapshots) and a `worktree:incomplete-detected` audit event with `source: "executor-liveness-gate"`, while preserving the existing `taskDoneRetryCount` / `MAX_TASK_DONE_REQUEUE_RETRIES` requeue contract. FN-4651 `worktreeSessionRetryCount` remains scoped to the in-review/session-start recovery path.
- **Stale self-owned active-session reconcile on conflict cleanup (FN-4973)**: when executor worktree-conflict cleanup finds only a same-task stale `activeSessionRegistry` entry and no live in-memory `activeWorktrees` binding for that task/path, it must unregister the stale entry before `removeWorktree` (plus one-shot backstop reconcile on same-task `ActiveSessionWorktreeRemovalError` races). Foreign-task entries remain protected by FN-4811 and must never be reconciled by the requesting task.
- **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Empty placeholder groups (`()`, `[]`, `{}`) left behind by token stripping are also removed in both `normalizeTitleForTaskId` and `sanitizeTitle` (FN-4978). Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds.
- **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Empty placeholder groups (`()`, `[]`, `{}`) left behind by token stripping are also removed in both `normalizeTitleForTaskId` and `sanitizeTitle` (FN-4978). Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds. FN-5077 extends drift normalization to reject dangling-connector fragments (`"Close as duplicate of"`) so token-stripped residuals never persist as task titles.
- **PR-conflict reclaim wiring (FN-4763)**: GitHub PR refresh now persists normalized `prInfo.mergeable` conflict state and, when conflicting, funnels tasks into self-healings existing reclaim machinery (`reclaimPrConflictForTask` / `reclaim-pr-conflicts` stage) so branch-conflict handling stays centralized with existing `inspectBranchConflict` outcomes and unrecoverable pause semantics. PR refresh also captures `prInfo.conflictDiagnostics` (conflicting files + suggested local recovery commands) for dashboard surfacing.
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native.
- **Post-finalize verification no-op (FN-4944)**: when auto-merge receives a delayed `VerificationError` after a task is already `done` with `mergeDetails.mergeConfirmed === true` (already-on-main fast-path), it must log one `[verification] ... no action` diagnostic and must not bounce the task back to `in-progress` / `merging-fix`. Defense-in-depth now re-checks the done+mergeConfirmed condition immediately before each verification-failure status write site, and emits `task:post-finalize-verification-no-op` database audit events with failure metadata for forensics.

View File

@@ -312,6 +312,14 @@ describe("ai-summarize", () => {
expect(sanitizeTitle("( )")).toBeNull();
});
it("FN-5077: rejects 'Close as duplicate of' as a dangling fragment", () => {
expect(sanitizeTitle("Close as duplicate of")).toBeNull();
});
it("FN-5077: rejects dangling connector tails", () => {
expect(sanitizeTitle("Refinement notes for")).toBeNull();
});
it("hard-caps at MAX_TITLE_LENGTH", () => {
const long = "x".repeat(100);
const out = sanitizeTitle(long)!;

View File

@@ -460,6 +460,19 @@ describe("TaskStore", () => {
expect(updatedTask.title).toBeUndefined();
});
it("FN-5077: malformed drift-stripped fragment title is not persisted", async () => {
const description = "Extend deterministic content-fingerprint dedup guard beyond dashboard POST /tasks to remaining intake surfaces (CLI direct-store create path, planning/subtask flow, InlineCreateCard, mission feature-triage).";
const task = await store.createTask(
{ title: "Close as duplicate of FN-5060", description },
{ onSummarize: vi.fn().mockResolvedValue(null), settings: { autoSummarizeTitles: true } },
);
expect(task.title).toBeUndefined();
const persisted = await store.getTask(task.id);
expect(persisted.title).toBeUndefined();
expect(persisted.description).toBe(description);
});
it("should handle onSummarize returning null", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue(null);

View File

@@ -19,6 +19,39 @@ describe("task-title-id-drift", () => {
expect(normalizeTitleForTaskId("FN-123", "FN-999")).toEqual({ title: null, changed: true });
});
it("FN-5077: returns null for dangling 'Close as duplicate of' fragment after FN-token strip", () => {
expect(normalizeTitleForTaskId("Close as duplicate of FN-5060", "FN-5073")).toEqual({
title: null,
changed: true,
});
});
it.each([
"FN-100 of",
"FN-100 for",
"FN-100 to",
"FN-100 from",
"FN-100 as",
"FN-100 in",
"FN-100 on",
"FN-100 with",
"FN-100 by",
"FN-100 and",
"FN-100 or",
"FN-100 the",
"FN-100 a",
"FN-100 an",
"FN-100 at",
"FN-100 into",
"FN-100 onto",
"FN-100 about",
"FN-100 via",
"FN-100 per",
"FN-100 vs",
])("FN-5077: drops dangling stop-word fragment: %s", (input) => {
expect(normalizeTitleForTaskId(input, "FN-999")).toEqual({ title: null, changed: true });
});
it("handles refinement prefix", () => {
expect(normalizeTitleForTaskId("Refinement: FN-4847: foo", "FN-9999")).toEqual({ title: "Refinement: foo", changed: true });
});
@@ -34,6 +67,17 @@ describe("task-title-id-drift", () => {
expect(normalizeTitleForTaskId("hello", "FN-1")).toEqual({ title: "hello", changed: false });
});
it("FN-5077: preserves legitimate short titles with no drift", () => {
expect(normalizeTitleForTaskId("Fix CI", "FN-1")).toEqual({ title: "Fix CI", changed: false });
});
it("FN-5077: keeps matching row-id title unchanged", () => {
expect(normalizeTitleForTaskId("Fix FN-1 of regression", "FN-1")).toEqual({
title: "Fix FN-1 of regression",
changed: false,
});
});
it("caps title length", () => {
const long = `prefix FN-100 ${"x".repeat(MAX_TITLE_LENGTH + 60)}`;
const normalized = normalizeTitleForTaskId(long, "FN-999");

View File

@@ -12,7 +12,7 @@
*/
import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js";
import { stripEmptyPlaceholders } from "./task-title-id-drift.js";
import { DANGLING_TAIL_STOPWORDS, stripDanglingTail, stripEmptyPlaceholders } from "./task-title-id-drift.js";
// ── Constants ───────────────────────────────────────────────────────────────
@@ -882,11 +882,24 @@ export function sanitizeTitle(raw: string | undefined | null): string | null {
}
title = stripEmptyPlaceholders(title);
const beforeDanglingTail = title;
const beforeTail = beforeDanglingTail.split(/\s+/).filter(Boolean).at(-1)?.toLowerCase();
const hadDanglingStopwordTail = Boolean(beforeTail && DANGLING_TAIL_STOPWORDS.has(beforeTail));
title = stripDanglingTail(title);
// Drop trailing punctuation that summary-like sentences leave behind.
title = title.replace(/[.!?,;:]+$/, "").trim();
if (!title) return null;
const words = title.split(/\s+/).filter(Boolean);
if (
(hadDanglingStopwordTail && title !== beforeDanglingTail)
|| /^close\s+as\s+duplicate(?:\s+of)?$/i.test(title)
|| (words.length === 1 && DANGLING_TAIL_STOPWORDS.has(words[0]!.toLowerCase()))
) {
return null;
}
if (title.length > MAX_TITLE_LENGTH) {
title = title.slice(0, MAX_TITLE_LENGTH).trim();
}

View File

@@ -3033,6 +3033,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (sanitizedTitle) {
const currentTask = this.readTaskFromDb(id);
if (currentTask && !currentTask.title) {
// FN-5077: normalizeTitleForTaskId may return null for dangling fragments; only persist usable titles.
const normalizedTitle = normalizeTitleForTaskId(sanitizedTitle, id);
if (normalizedTitle.title) {
await this.updateTask(id, { title: normalizedTitle.title });
@@ -3196,6 +3197,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
},
): Promise<Task> {
const now = options?.createdAt ?? new Date().toISOString();
// FN-5077: null normalized titles are treated as "no title" and allow standard fallback/summarization behavior.
const normalizedTitle = normalizeTitleForTaskId(title, id);
const task: Task = {
id,
@@ -3348,6 +3350,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return this.createTaskWithDistributedReservation({ description: sourceTask.description }, {
createTaskWithId: async (newId) => {
// FN-5077: duplicated drift-stripped fragments may normalize to null and should remain unset.
const normalizedTitle = normalizeTitleForTaskId(sourceTask.title, newId);
if (normalizedTitle.changed) {
const removed = extractTaskIdTokens(sourceTask.title ?? "").filter((token) => token !== newId.toUpperCase());
@@ -3429,6 +3432,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return this.createTaskWithDistributedReservation({ description: feedback.trim() }, {
createTaskWithId: async (newId) => {
// FN-5077: keep deterministic "Refinement" fallback when normalized refinement label is unusable (null).
const normalizedTitle = normalizeTitleForTaskId(`Refinement: ${sourceLabel}`, newId);
if (normalizedTitle.changed) {
const removed = extractTaskIdTokens(`Refinement: ${sourceLabel}`).filter((token) => token !== newId.toUpperCase());
@@ -4496,6 +4500,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
let titleNormalized = false;
if (updates.title !== undefined) {
task.title = updates.title;
// FN-5077: load-time repair tolerates null normalized titles (title cleared instead of fragment persisted).
const normalizedTitle = normalizeTitleForTaskId(task.title, id);
if (normalizedTitle.changed) {
titleNormalized = true;

View File

@@ -5,6 +5,28 @@ export const TASK_ID_TOKEN_RE = /\bFN-(\d+)\b/gi;
const CONNECTOR_RE = /[:\-—–]/;
const EMPTY_PLACEHOLDER_CONTENT_RE = /^[\s,:;\-—–.!?]*$/;
export const DANGLING_TAIL_STOPWORDS = new Set([
"of", "for", "to", "from", "as", "in", "on", "with", "by", "and", "or", "the", "a", "an", "at", "into", "onto",
"about", "via", "per", "vs",
]);
export function stripDanglingTail(text: string): string {
let normalized = text.trim();
for (let i = 0; i < 4; i += 1) {
const words = normalized.split(/\s+/).filter(Boolean);
const tail = words.at(-1)?.toLowerCase();
if (!tail || !DANGLING_TAIL_STOPWORDS.has(tail)) {
break;
}
words.pop();
normalized = stripEmptyPlaceholders(words.join(" "));
if (!normalized) {
break;
}
}
return normalized;
}
export function stripEmptyPlaceholders(text: string): string {
let normalized = text;
@@ -62,11 +84,25 @@ export function normalizeTitleForTaskId(
let normalized = initial.replace(/\bFN-\d+\b\s*([:\-—–])?\s*/gi, " ");
normalized = stripEmptyPlaceholders(normalized);
const beforeDanglingTail = normalized;
const beforeTail = beforeDanglingTail.split(/\s+/).filter(Boolean).at(-1)?.toLowerCase();
const hadDanglingStopwordTail = Boolean(beforeTail && DANGLING_TAIL_STOPWORDS.has(beforeTail));
normalized = stripDanglingTail(normalized);
if (normalized.length > MAX_TITLE_LENGTH) {
normalized = normalized.slice(0, MAX_TITLE_LENGTH).trim();
}
const nextTitle = normalized.length > 0 ? normalized : null;
const words = normalized.split(/\s+/).filter(Boolean);
const nextTitle =
normalized.length === 0
? null
: hadDanglingStopwordTail && normalized !== beforeDanglingTail
? null
: /^close\s+as\s+duplicate(?:\s+of)?$/i.test(normalized)
? null
: words.length === 1 && DANGLING_TAIL_STOPWORDS.has(words[0]!.toLowerCase())
? null
: normalized;
return { title: nextTitle, changed: nextTitle !== initial };
}