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,
|
summarizeCommitBody,
|
||||||
sanitizeCommitSubject,
|
sanitizeCommitSubject,
|
||||||
sanitizeTitle,
|
sanitizeTitle,
|
||||||
|
deriveFallbackTaskTitle,
|
||||||
|
FALLBACK_TASK_TITLE,
|
||||||
MAX_COMMIT_SUBJECT_LENGTH,
|
MAX_COMMIT_SUBJECT_LENGTH,
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getRateLimitResetTime,
|
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", () => {
|
describe("summarizeMergeCommit", () => {
|
||||||
it("returns null when commit log and diff stat are empty", async () => {
|
it("returns null when commit log and diff stat are empty", async () => {
|
||||||
expect(await summarizeMergeCommit("", "", "/tmp")).toBeNull();
|
expect(await summarizeMergeCommit("", "", "/tmp")).toBeNull();
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ export const MAX_TITLE_LENGTH = 60;
|
|||||||
/** Maximum merge commit summary length in characters */
|
/** Maximum merge commit summary length in characters */
|
||||||
export const MAX_MERGE_COMMIT_SUMMARY_LENGTH = 300;
|
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 */
|
/** Rate limit: max requests per IP per hour */
|
||||||
export const MAX_REQUESTS_PER_HOUR = 10;
|
export const MAX_REQUESTS_PER_HOUR = 10;
|
||||||
|
|
||||||
@@ -959,6 +962,45 @@ export function sanitizeTitle(raw: string | undefined | null): string | null {
|
|||||||
return title || 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 ───────────────────────────────────────────────────────────
|
// ── Test Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1334,6 +1334,7 @@ export {
|
|||||||
summarizeCommitBody,
|
summarizeCommitBody,
|
||||||
summarizeCommitSubject,
|
summarizeCommitSubject,
|
||||||
sanitizeCommitSubject,
|
sanitizeCommitSubject,
|
||||||
|
deriveFallbackTaskTitle,
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getRateLimitResetTime,
|
getRateLimitResetTime,
|
||||||
validateDescription,
|
validateDescription,
|
||||||
@@ -1348,6 +1349,7 @@ export {
|
|||||||
MIN_DESCRIPTION_LENGTH,
|
MIN_DESCRIPTION_LENGTH,
|
||||||
MAX_TITLE_LENGTH,
|
MAX_TITLE_LENGTH,
|
||||||
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
|
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
|
||||||
|
FALLBACK_TASK_TITLE,
|
||||||
MAX_COMMIT_BODY_INPUT_LENGTH,
|
MAX_COMMIT_BODY_INPUT_LENGTH,
|
||||||
MAX_COMMIT_BODY_LENGTH,
|
MAX_COMMIT_BODY_LENGTH,
|
||||||
DEFAULT_COMMIT_BODY_TIMEOUT_MS,
|
DEFAULT_COMMIT_BODY_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -1382,6 +1382,7 @@ export {
|
|||||||
summarizeCommitBody,
|
summarizeCommitBody,
|
||||||
summarizeCommitSubject,
|
summarizeCommitSubject,
|
||||||
sanitizeCommitSubject,
|
sanitizeCommitSubject,
|
||||||
|
deriveFallbackTaskTitle,
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getRateLimitResetTime,
|
getRateLimitResetTime,
|
||||||
validateDescription,
|
validateDescription,
|
||||||
@@ -1396,6 +1397,7 @@ export {
|
|||||||
MIN_DESCRIPTION_LENGTH,
|
MIN_DESCRIPTION_LENGTH,
|
||||||
MAX_TITLE_LENGTH,
|
MAX_TITLE_LENGTH,
|
||||||
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
|
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
|
||||||
|
FALLBACK_TASK_TITLE,
|
||||||
MAX_COMMIT_BODY_INPUT_LENGTH,
|
MAX_COMMIT_BODY_INPUT_LENGTH,
|
||||||
MAX_COMMIT_BODY_LENGTH,
|
MAX_COMMIT_BODY_LENGTH,
|
||||||
DEFAULT_COMMIT_BODY_TIMEOUT_MS,
|
DEFAULT_COMMIT_BODY_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -4752,6 +4752,215 @@ describe("taskCreate tool model inheritance", () => {
|
|||||||
nextRecoveryAt: expect.any(String),
|
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({ 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,
|
extractEffectiveWriteScopeFromPrompt,
|
||||||
MAX_TASK_LIST_TEXT_CHARS,
|
MAX_TASK_LIST_TEXT_CHARS,
|
||||||
upsertWorkflowStepResult,
|
upsertWorkflowStepResult,
|
||||||
|
deriveFallbackTaskTitle,
|
||||||
type NearDuplicateCandidate,
|
type NearDuplicateCandidate,
|
||||||
} from "@fusion/core";
|
} 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.
|
* Specify a triage task by spawning an AI agent to generate a PROMPT.md.
|
||||||
*
|
*
|
||||||
@@ -1459,6 +1478,7 @@ export class TriageProcessor {
|
|||||||
recoveryRetryCount: null,
|
recoveryRetryCount: null,
|
||||||
nextRecoveryAt: null,
|
nextRecoveryAt: null,
|
||||||
});
|
});
|
||||||
|
await this.backfillBlankTitleAfterTerminalTriageFailure(task);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1549,6 +1569,7 @@ export class TriageProcessor {
|
|||||||
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
|
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
|
||||||
planLog.warn(`${task.id}: failed to persist planner fallback exhaustion: ${msg}`);
|
planLog.warn(`${task.id}: failed to persist planner fallback exhaustion: ${msg}`);
|
||||||
});
|
});
|
||||||
|
await this.backfillBlankTitleAfterTerminalTriageFailure(task);
|
||||||
this.options.onSpecifyError?.(task, err);
|
this.options.onSpecifyError?.(task, err);
|
||||||
return;
|
return;
|
||||||
} else if (isOperatorActionableAgentError(errorMessage) && !isTransientError(errorMessage)) {
|
} else if (isOperatorActionableAgentError(errorMessage) && !isTransientError(errorMessage)) {
|
||||||
@@ -1574,6 +1595,7 @@ export class TriageProcessor {
|
|||||||
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
|
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
|
||||||
planLog.warn(`${task.id}: failed to park operator-actionable specification failure: ${msg}`);
|
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));
|
this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage));
|
||||||
return;
|
return;
|
||||||
} else if (isTransientError(errorMessage)) {
|
} else if (isTransientError(errorMessage)) {
|
||||||
@@ -1620,6 +1642,7 @@ export class TriageProcessor {
|
|||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
planLog.warn(`${task.id}: failed to persist transient-error retries-exhausted state: ${msg}`);
|
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));
|
this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user