From ddc8e6dd1a479ffe05a3a491977d926597e4bee4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 10:32:48 -0700 Subject: [PATCH] FN-7961: backfill blank titles on terminal triage failures Give terminally failed planning tasks deterministic non-LLM titles so orphaned blank-title rows stay visible after model unavailability. - Add deriveFallbackTaskTitle / FALLBACK_TASK_TITLE for description-based title derivation - Export the helper from @fusion/core (public + gate entrypoints) - Backfill blank titles on terminal triage failure paths without overwriting existing titles - Cover helper and all terminal specifyTask failure surfaces with tests - Add patch changeset for the operator-visible fix Files changed: .changeset/fn-7961-blank-title-fallback.md | 7 + packages/core/src/__tests__/ai-summarize.test.ts | 42 +++++ packages/core/src/ai-summarize.ts | 42 +++++ packages/core/src/index.gate.ts | 2 + packages/core/src/index.ts | 2 + packages/engine/src/__tests__/triage.test.ts | 209 +++++++++++++++++++++++ packages/engine/src/triage.ts | 23 +++ 7 files changed, 327 insertions(+) Fusion-Task-Id: FN-7961 Fusion-Task-Lineage: 1984c31d-e184-4592-b32f-1736a9ce27f6 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7961-blank-title-fallback.md | 7 + .../core/src/__tests__/ai-summarize.test.ts | 42 ++++ packages/core/src/ai-summarize.ts | 42 ++++ packages/core/src/index.gate.ts | 2 + packages/core/src/index.ts | 2 + packages/engine/src/__tests__/triage.test.ts | 209 ++++++++++++++++++ packages/engine/src/triage.ts | 23 ++ 7 files changed, 327 insertions(+) create mode 100644 .changeset/fn-7961-blank-title-fallback.md diff --git a/.changeset/fn-7961-blank-title-fallback.md b/.changeset/fn-7961-blank-title-fallback.md new file mode 100644 index 0000000000..2bce85d03e --- /dev/null +++ b/.changeset/fn-7961-blank-title-fallback.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Give terminally failed planning tasks deterministic fallback titles. +category: fix +dev: Adds non-LLM title derivation for terminal triage/specification failure paths. diff --git a/packages/core/src/__tests__/ai-summarize.test.ts b/packages/core/src/__tests__/ai-summarize.test.ts index a977332d4d..0b4e65051b 100644 --- a/packages/core/src/__tests__/ai-summarize.test.ts +++ b/packages/core/src/__tests__/ai-summarize.test.ts @@ -14,6 +14,8 @@ import { summarizeCommitBody, sanitizeCommitSubject, sanitizeTitle, + deriveFallbackTaskTitle, + FALLBACK_TASK_TITLE, MAX_COMMIT_SUBJECT_LENGTH, checkRateLimit, getRateLimitResetTime, @@ -458,6 +460,46 @@ describe("ai-summarize", () => { }); }); + describe("deriveFallbackTaskTitle", () => { + it("derives a deterministic title from a normal description", () => { + expect(deriveFallbackTaskTitle("Fix blank task titles after triage failure.")) + .toBe("Fix blank task titles after triage failure"); + }); + + it("strips common markdown heading and list prefixes", () => { + expect(deriveFallbackTaskTitle("### Restore workflow selection state\n\nDetails follow.")) + .toBe("Restore workflow selection state"); + expect(deriveFallbackTaskTitle("- Add a retry budget for planning failures")) + .toBe("Add a retry budget for planning failures"); + expect(deriveFallbackTaskTitle("1. Backfill failed task titles")) + .toBe("Backfill failed task titles"); + expect(deriveFallbackTaskTitle("[ ] Cover blank-title failures")) + .toBe("Cover blank-title failures"); + }); + + it("truncates long descriptions at a word boundary", () => { + const title = deriveFallbackTaskTitle( + "Fix permanently blank orphaned task titles when planner model selection fails terminally", + ); + expect(title).toBe("Fix permanently blank orphaned task titles when planner"); + expect(title.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH); + }); + + it("returns the generic fallback for empty or whitespace-only inputs", () => { + expect(deriveFallbackTaskTitle("")).toBe(FALLBACK_TASK_TITLE); + expect(deriveFallbackTaskTitle(" \n\t ")).toBe(FALLBACK_TASK_TITLE); + expect(deriveFallbackTaskTitle(null)).toBe(FALLBACK_TASK_TITLE); + expect(deriveFallbackTaskTitle(undefined)).toBe(FALLBACK_TASK_TITLE); + }); + + it("returns the generic fallback when sanitization rejects the text", () => { + expect(deriveFallbackTaskTitle("Created task **FN-3058** with the full spec")) + .toBe(FALLBACK_TASK_TITLE); + expect(deriveFallbackTaskTitle("- ( )")) + .toBe(FALLBACK_TASK_TITLE); + }); + }); + describe("summarizeMergeCommit", () => { it("returns null when commit log and diff stat are empty", async () => { expect(await summarizeMergeCommit("", "", "/tmp")).toBeNull(); diff --git a/packages/core/src/ai-summarize.ts b/packages/core/src/ai-summarize.ts index 23c8ace7fc..6e9c0adc7c 100644 --- a/packages/core/src/ai-summarize.ts +++ b/packages/core/src/ai-summarize.ts @@ -56,6 +56,9 @@ export const MAX_TITLE_LENGTH = 60; /** Maximum merge commit summary length in characters */ export const MAX_MERGE_COMMIT_SUMMARY_LENGTH = 300; +/** Safe generic fallback when deterministic title derivation cannot keep useful description text. */ +export const FALLBACK_TASK_TITLE = "Untitled task"; + /** Rate limit: max requests per IP per hour */ export const MAX_REQUESTS_PER_HOUR = 10; @@ -959,6 +962,45 @@ export function sanitizeTitle(raw: string | undefined | null): string | null { return title || null; } +function stripLeadingDescriptionMarkdown(text: string): string { + return text + .replace(/^\s{0,3}#{1,6}\s+/, "") + .replace(/^\s{0,3}>\s?/, "") + .replace(/^\s{0,3}(?:[-*+]\s+|\d+[.)]\s+|\[[ xX]\]\s+)/, "") + .trim(); +} + +function truncateTitleAtWordBoundary(text: string): string { + if (text.length <= MAX_TITLE_LENGTH) { + return text; + } + const capped = text.slice(0, MAX_TITLE_LENGTH).trim(); + const boundary = capped.search(/\s+\S*$/); + const candidate = boundary > Math.floor(MAX_TITLE_LENGTH * 0.5) + ? capped.slice(0, boundary).trim() + : capped; + return stripDanglingTail(stripEmptyPlaceholders(candidate)) || capped; +} + +/** + * Derive a deterministic, model-independent title from task description text. + * + * FNXC:TriageTitleFallback 2026-07-14-00:00: + * Terminal triage/specification failures can happen before PROMPT.md title finalization, especially when model selection is unavailable. This helper must never call an LLM; it gives failed agent-created rows a stable visible title while preserving the original failure state and any existing non-empty title chosen elsewhere. + */ +export function deriveFallbackTaskTitle(description: string | undefined | null): string { + const firstMeaningfulLine = (description ?? "") + .split(/\r?\n/) + .map(stripLeadingDescriptionMarkdown) + .find((line) => line.length > 0); + if (!firstMeaningfulLine) { + return FALLBACK_TASK_TITLE; + } + + const truncated = truncateTitleAtWordBoundary(firstMeaningfulLine); + return sanitizeTitle(truncated) ?? FALLBACK_TASK_TITLE; +} + // ── Test Helpers ─────────────────────────────────────────────────────────── /** diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 5813d4187f..f391373956 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1334,6 +1334,7 @@ export { summarizeCommitBody, summarizeCommitSubject, sanitizeCommitSubject, + deriveFallbackTaskTitle, checkRateLimit, getRateLimitResetTime, validateDescription, @@ -1348,6 +1349,7 @@ export { MIN_DESCRIPTION_LENGTH, MAX_TITLE_LENGTH, MAX_MERGE_COMMIT_SUMMARY_LENGTH, + FALLBACK_TASK_TITLE, MAX_COMMIT_BODY_INPUT_LENGTH, MAX_COMMIT_BODY_LENGTH, DEFAULT_COMMIT_BODY_TIMEOUT_MS, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7925e425c3..f17e6dd836 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1382,6 +1382,7 @@ export { summarizeCommitBody, summarizeCommitSubject, sanitizeCommitSubject, + deriveFallbackTaskTitle, checkRateLimit, getRateLimitResetTime, validateDescription, @@ -1396,6 +1397,7 @@ export { MIN_DESCRIPTION_LENGTH, MAX_TITLE_LENGTH, MAX_MERGE_COMMIT_SUMMARY_LENGTH, + FALLBACK_TASK_TITLE, MAX_COMMIT_BODY_INPUT_LENGTH, MAX_COMMIT_BODY_LENGTH, DEFAULT_COMMIT_BODY_TIMEOUT_MS, diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 4c21cd7262..f818bf54f8 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -4752,6 +4752,215 @@ describe("taskCreate tool model inheritance", () => { nextRecoveryAt: expect.any(String), })); expect(store.updateTask).not.toHaveBeenCalledWith("FN-7952-TRANSIENT", expect.objectContaining({ status: "failed" })); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-7952-TRANSIENT", expect.objectContaining({ + title: expect.any(String), + })); + }); + + it("backfills blank titles when deterministic validation retries are exhausted", async () => { + const task = { + id: "FN-7961-DETERMINISTIC", + title: "", + description: "Backfill blank titles after deterministic prompt validation failure", + column: "triage", + recoveryRetryCount: 3, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as unknown as Task; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }), + }); + mockCreateFnAgent.mockResolvedValue({ + session: { + state: {}, + sessionManager: {}, + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + navigateTree: vi.fn(), + }, + }); + + const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 }); + await processor.specifyTask(task); + + const expectedError = "Specification failed deterministic validation after 3 retries (PROMPT.md file not found or empty). Retry after adjusting the task prompt or model."; + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-DETERMINISTIC", { + status: "failed", + error: expectedError, + recoveryRetryCount: null, + nextRecoveryAt: null, + }); + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-DETERMINISTIC", { + title: "Backfill blank titles after deterministic prompt", + }); + }); + + it("backfills blank titles when planner model fallback is exhausted", async () => { + const task = { + id: "FN-7961-MODEL", + title: "", + description: "Repair blank title rows after planner model fallback exhaustion", + column: "triage", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as unknown as Task; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }), + }); + mockCreateFnAgent.mockResolvedValue({ + session: { + state: {}, + sessionManager: {}, + prompt: vi.fn(), + dispose: vi.fn(), + navigateTree: vi.fn(), + }, + }); + const { ModelFallbackExhaustedError, promptWithFallback } = await import("../pi.js"); + (promptWithFallback as ReturnType).mockRejectedValueOnce( + new ModelFallbackExhaustedError({ + primaryModel: "openai/gpt-4o", + fallbackModel: "anthropic/claude-3-5-haiku-20241022", + triggerPoint: "prompt-time", + attempts: 2, + underlyingReason: "model unavailable", + }), + ); + + const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 }); + await processor.specifyTask(task); + + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-MODEL", expect.objectContaining({ + status: "failed", + error: expect.stringContaining("Triage failed: unable to select a usable model after 2 attempts"), + recoveryRetryCount: null, + nextRecoveryAt: null, + })); + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-MODEL", { + title: "Repair blank title rows after planner model fallback", + }); + }); + + it("backfills blank titles when operator-actionable provider failures park planning", async () => { + const task = { + id: "FN-7961-OPERATOR", + title: "", + description: "Show failed tasks when provider credentials are unavailable", + column: "triage", + status: "planning", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as unknown as Task; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }), + }); + mockCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic")); + + const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 }); + await processor.specifyTask(task); + + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-OPERATOR", { + status: "failed", + error: "Specification failed: No API key for provider: anthropic", + recoveryRetryCount: null, + nextRecoveryAt: null, + }); + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-OPERATOR", { + title: "Show failed tasks when provider credentials are unavailable", + }); + }); + + it("backfills blank titles when transient retries are exhausted", async () => { + const task = { + id: "FN-7961-TRANSIENT", + title: "", + description: "Identify failed rows after exhausted transient planning retries", + column: "triage", + status: "planning", + recoveryRetryCount: 3, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as unknown as Task; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }), + }); + mockCreateFnAgent.mockRejectedValue(new Error("connection reset")); + + const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 }); + await processor.specifyTask(task); + + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-TRANSIENT", { + error: "Specification failed after 3 transient errors: connection reset", + recoveryRetryCount: null, + nextRecoveryAt: null, + }); + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-TRANSIENT", { + title: "Identify failed rows after exhausted transient planning", + }); + }); + + it("does not overwrite an existing title during terminal fallback exhaustion", async () => { + const task = { + id: "FN-7961-EXISTING", + title: "Existing operator title", + description: "This description would otherwise become the fallback title", + column: "triage", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as unknown as Task; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }), + }); + mockCreateFnAgent.mockResolvedValue({ + session: { + state: {}, + sessionManager: {}, + prompt: vi.fn(), + dispose: vi.fn(), + navigateTree: vi.fn(), + }, + }); + const { ModelFallbackExhaustedError, promptWithFallback } = await import("../pi.js"); + (promptWithFallback as ReturnType).mockRejectedValueOnce( + new ModelFallbackExhaustedError({ + primaryModel: "openai/gpt-4o", + triggerPoint: "prompt-time", + attempts: 1, + underlyingReason: "model not found", + }), + ); + + const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 }); + await processor.specifyTask(task); + + expect(store.updateTask).toHaveBeenCalledWith("FN-7961-EXISTING", expect.objectContaining({ + status: "failed", + recoveryRetryCount: null, + nextRecoveryAt: null, + })); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-7961-EXISTING", expect.objectContaining({ + title: expect.any(String), + })); }); }); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index aaee4f262e..5205a6086f 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -38,6 +38,7 @@ import { extractEffectiveWriteScopeFromPrompt, MAX_TASK_LIST_TEXT_CHARS, upsertWorkflowStepResult, + deriveFallbackTaskTitle, type NearDuplicateCandidate, } from "@fusion/core"; @@ -931,6 +932,24 @@ export class TriageProcessor { } } + private async backfillBlankTitleAfterTerminalTriageFailure(task: Task): Promise { + /* + FNXC:TriageTitleFallback 2026-07-14-00:00: + Agent-created tasks may begin triage with a blank title because fn_task_create only accepts a description. Terminal planner failures must keep their original failed/error state, but they should best-effort derive a deterministic non-LLM title so dashboard and CLI rows are not permanently invisible. + */ + try { + const current = await this.store.getTask(task.id); + if (current.title?.trim()) { + return; + } + const fallbackTitle = deriveFallbackTaskTitle(current.description || task.description); + await this.store.updateTask(task.id, { title: fallbackTitle }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + planLog.warn(`${task.id}: failed to backfill blank title after terminal triage failure: ${msg}`); + } + } + /** * Specify a triage task by spawning an AI agent to generate a PROMPT.md. * @@ -1459,6 +1478,7 @@ export class TriageProcessor { recoveryRetryCount: null, nextRecoveryAt: null, }); + await this.backfillBlankTitleAfterTerminalTriageFailure(task); return; } @@ -1549,6 +1569,7 @@ export class TriageProcessor { const msg = updateErr instanceof Error ? updateErr.message : String(updateErr); planLog.warn(`${task.id}: failed to persist planner fallback exhaustion: ${msg}`); }); + await this.backfillBlankTitleAfterTerminalTriageFailure(task); this.options.onSpecifyError?.(task, err); return; } else if (isOperatorActionableAgentError(errorMessage) && !isTransientError(errorMessage)) { @@ -1574,6 +1595,7 @@ export class TriageProcessor { const msg = updateErr instanceof Error ? updateErr.message : String(updateErr); planLog.warn(`${task.id}: failed to park operator-actionable specification failure: ${msg}`); }); + await this.backfillBlankTitleAfterTerminalTriageFailure(task); this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage)); return; } else if (isTransientError(errorMessage)) { @@ -1620,6 +1642,7 @@ export class TriageProcessor { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to persist transient-error retries-exhausted state: ${msg}`); }); + await this.backfillBlankTitleAfterTerminalTriageFailure(task); this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage)); return; }