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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 10:32:48 -07:00
parent 16b0109300
commit ddc8e6dd1a
7 changed files with 327 additions and 0 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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 ───────────────────────────────────────────────────────────
/**

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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),
}));
});
});

View File

@@ -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<void> {
/*
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;
}