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

This commit is contained in:
gsxdsm
2026-05-18 03:46:28 -07:00
parent da9aedd1b1
commit 3a3f4edbef
6 changed files with 69 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Normalize task titles by stripping empty placeholder bracket groups (`()`, `[]`, `{}`) left by FN-token removal and AI-generated blank template slots.

View File

@@ -176,7 +176,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. - **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. - **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. - **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`. 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.
- **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-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.
- **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. - **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. - **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

@@ -305,6 +305,13 @@ describe("ai-summarize", () => {
expect(sanitizeTitle("Why?")).toBe("Why"); expect(sanitizeTitle("Why?")).toBe("Why");
}); });
it("strips empty placeholder groups (FN-4978 regression)", () => {
expect(sanitizeTitle("Fix executor.ts stale session deadlock ( )")).toBe("Fix executor.ts stale session deadlock");
expect(sanitizeTitle("Add feature []")).toBe("Add feature");
expect(sanitizeTitle("Refactor (hotfix)")).toBe("Refactor (hotfix)");
expect(sanitizeTitle("( )")).toBeNull();
});
it("hard-caps at MAX_TITLE_LENGTH", () => { it("hard-caps at MAX_TITLE_LENGTH", () => {
const long = "x".repeat(100); const long = "x".repeat(100);
const out = sanitizeTitle(long)!; const out = sanitizeTitle(long)!;

View File

@@ -48,9 +48,36 @@ describe("task-title-id-drift", () => {
expect(normalizeTitleForTaskId("Foo FN-100:", "FN-999")).toEqual({ title: "Foo", changed: true }); expect(normalizeTitleForTaskId("Foo FN-100:", "FN-999")).toEqual({ title: "Foo", changed: true });
}); });
it("strips empty placeholder left by fn-token removal (FN-4978 regression)", () => {
const normalized = normalizeTitleForTaskId("Fix executor.ts stale session deadlock (FN-1234)", "FN-9999");
expect(normalized).toEqual({ title: "Fix executor.ts stale session deadlock", changed: true });
expect(normalized.title).not.toContain("(");
expect(normalized.title).not.toContain(")");
});
it("strips empty square and curly bracket placeholders", () => {
expect(normalizeTitleForTaskId("Bug [FN-1] thing", "FN-9")).toEqual({ title: "Bug thing", changed: true });
expect(normalizeTitleForTaskId("Bug {FN-1} thing", "FN-9")).toEqual({ title: "Bug thing", changed: true });
});
it("preserves valid qualifiers and matching row id token", () => {
expect(normalizeTitleForTaskId("Refactor parser (hotfix)", "FN-9")).toEqual({
title: "Refactor parser (hotfix)",
changed: false,
});
expect(normalizeTitleForTaskId("Add docs (FN-9)", "FN-9")).toEqual({
title: "Add docs (FN-9)",
changed: false,
});
});
it("is idempotent", () => { it("is idempotent", () => {
const first = normalizeTitleForTaskId("Refinement: FN-100: foo", "FN-200"); const first = normalizeTitleForTaskId("Refinement: FN-100: foo", "FN-200");
const second = normalizeTitleForTaskId(first.title ?? "", "FN-200"); const second = normalizeTitleForTaskId(first.title ?? "", "FN-200");
expect(second.changed).toBe(false); expect(second.changed).toBe(false);
const firstPlaceholderPass = normalizeTitleForTaskId("Fix executor.ts stale session deadlock (FN-1234)", "FN-9999");
const secondPlaceholderPass = normalizeTitleForTaskId(firstPlaceholderPass.title ?? "", "FN-9999");
expect(secondPlaceholderPass.changed).toBe(false);
}); });
}); });

View File

@@ -12,6 +12,7 @@
*/ */
import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js"; import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js";
import { stripEmptyPlaceholders } from "./task-title-id-drift.js";
// ── Constants ─────────────────────────────────────────────────────────────── // ── Constants ───────────────────────────────────────────────────────────────
@@ -880,6 +881,8 @@ export function sanitizeTitle(raw: string | undefined | null): string | null {
return null; return null;
} }
title = stripEmptyPlaceholders(title);
// Drop trailing punctuation that summary-like sentences leave behind. // Drop trailing punctuation that summary-like sentences leave behind.
title = title.replace(/[.!?,;:]+$/, "").trim(); title = title.replace(/[.!?,;:]+$/, "").trim();
if (!title) return null; if (!title) return null;

View File

@@ -3,6 +3,31 @@ import { MAX_TITLE_LENGTH } from "./ai-summarize.js";
export const TASK_ID_TOKEN_RE = /\bFN-(\d+)\b/gi; export const TASK_ID_TOKEN_RE = /\bFN-(\d+)\b/gi;
const CONNECTOR_RE = /[:\-—–]/; const CONNECTOR_RE = /[:\-—–]/;
const EMPTY_PLACEHOLDER_CONTENT_RE = /^[\s,:;\-—–.!?]*$/;
export function stripEmptyPlaceholders(text: string): string {
let normalized = text;
// Remove empty bracketed placeholders ((), [], {}) that contain only
// whitespace or punctuation residue/connectors.
normalized = normalized.replace(/\(([^)]*)\)|\[([^\]]*)\]|\{([^}]*)\}/g, (match, paren, square, brace) => {
const content = (paren ?? square ?? brace ?? "").trim();
return EMPTY_PLACEHOLDER_CONTENT_RE.test(content) ? " " : match;
});
normalized = normalized
.replace(/\s+([,:;.!?])/g, "$1")
.replace(/([:\-—–])\s*(?=[:\-—–])/g, "$1")
.replace(/\s+/g, " ")
.trim();
normalized = normalized
.replace(new RegExp(`(?:\\s*${CONNECTOR_RE.source}\\s*)+$`), "")
.replace(/[,:;.!?]+$/g, "")
.trim();
return normalized;
}
export function extractTaskIdTokens(title: string): string[] { export function extractTaskIdTokens(title: string): string[] {
const tokens = new Set<string>(); const tokens = new Set<string>();
@@ -36,15 +61,7 @@ export function normalizeTitleForTaskId(
} }
let normalized = initial.replace(/\bFN-\d+\b\s*([:\-—–])?\s*/gi, " "); let normalized = initial.replace(/\bFN-\d+\b\s*([:\-—–])?\s*/gi, " ");
normalized = normalized normalized = stripEmptyPlaceholders(normalized);
.replace(/\s+([,:;.!?])/g, "$1")
.replace(/\s+/g, " ")
.trim();
normalized = normalized
.replace(new RegExp(`(?:\\s*${CONNECTOR_RE.source}\\s*)+$`), "")
.replace(/[,:;.!?]+$/g, "")
.trim();
if (normalized.length > MAX_TITLE_LENGTH) { if (normalized.length > MAX_TITLE_LENGTH) {
normalized = normalized.slice(0, MAX_TITLE_LENGTH).trim(); normalized = normalized.slice(0, MAX_TITLE_LENGTH).trim();