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:
7
.changeset/fn-7961-blank-title-fallback.md
Normal file
7
.changeset/fn-7961-blank-title-fallback.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user