feat(FN-5077): merge fusion/fn-5077
This commit is contained in:
5
.changeset/fn-5077-title-connective-strip.md
Normal file
5
.changeset/fn-5077-title-connective-strip.md
Normal 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.
|
||||
@@ -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-healing’s 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.
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user