feat(FN-4978): merge fusion/fn-4978
This commit is contained in:
@@ -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.
|
||||||
@@ -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-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-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.
|
||||||
- **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.
|
||||||
|
|||||||
@@ -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)!;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user