feat(FN-3502): add comment-triggered retriage when task is in triage status

Merged four commits implementing comment-driven retriage: triage rules now respond to specific comment patterns (Step 1) and surface needs-replan feedback inputs in the UI (Step 2), with documentation for the new behavior and a bug fix restoring workspace typecheck defaults. Changes span the core ta

Fusion-Task-Id: FN-3502
This commit is contained in:
Fusion
2026-05-05 13:32:10 -07:00
committed by gsxdsm
parent 7d373286d9
commit 511c0c5442
8 changed files with 200 additions and 40 deletions

View File

@@ -5430,7 +5430,7 @@ Task with acceptance criteria
expect(updateSpy).toHaveBeenCalled();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment awaiting-approval invalidation failed"),
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment re-triage failed"),
);
expect(warningCall).toBeDefined();
@@ -5476,7 +5476,7 @@ Task with acceptance criteria
expect(logEntrySpy).toHaveBeenCalled();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment awaiting-approval invalidation failed"),
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment re-triage failed"),
);
expect(warningCall).toBeDefined();
@@ -5654,14 +5654,76 @@ Task with acceptance criteria
expect(updated.comments).toHaveLength(1);
});
it("does NOT transition to needs-replan when user comments on non-awaiting-approval triage task", async () => {
it("transitions to needs-replan when user comments on non-awaiting-approval triage task with real spec", async () => {
const task = await store.createTask({ description: "Task in triage" });
// Task is in triage with no status (not awaiting-approval)
expect(task.status).toBeUndefined();
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# Task: ${task.id} - Triage Plan\n\n## Mission\n\nPlanned task.`);
const updated = await store.addComment(task.id, "User feedback", "user");
await store.addComment(task.id, "User feedback", "user");
const updated = await store.getTask(task.id);
// Status should remain undefined
expect(updated.status).toBe("needs-replan");
expect(updated.column).toBe("triage");
expect(updated.comments?.[0]?.text).toBe("User feedback");
});
it("does NOT transition to needs-replan when user comments on triage task with bootstrap stub prompt", async () => {
const task = await store.createTask({ description: "Task in triage" });
await store.addComment(task.id, "User feedback", "user");
const updated = await store.getTask(task.id);
expect(updated.status).toBeUndefined();
});
it("transitions todo task to needs-replan when user comments and task has real spec", async () => {
const task = await store.createTask({ description: "Task in todo", column: "todo" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# Task: ${task.id} - Todo Plan\n\n## Mission\n\nPlanned task.`);
await store.addComment(task.id, "Please update approach", "user");
const updated = await store.getTask(task.id);
expect(updated.status).toBe("needs-replan");
expect(updated.column).toBe("todo");
expect(updated.log.some((entry) => entry.action === "User comment requested re-specification of planned task")).toBe(true);
});
it("does NOT transition todo task to needs-replan when prompt matches bootstrap stub", async () => {
const task = await store.createTask({ description: "Task in todo", column: "todo" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# ${task.id}\n\nTask in todo\n`);
await store.addComment(task.id, "Please update approach", "user");
const updated = await store.getTask(task.id);
expect(updated.status).toBeUndefined();
});
it("does NOT transition to needs-replan when user comments on in-progress task", async () => {
const task = await store.createTask({ description: "Task in progress", column: "todo" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# Task: ${task.id} - Plan\n\n## Mission\n\nPlanned task.`);
await store.moveTask(task.id, "in-progress");
await store.addComment(task.id, "Please adjust implementation", "user");
const updated = await store.getTask(task.id);
expect(updated.column).toBe("in-progress");
expect(updated.status).toBeUndefined();
});
it("does NOT transition to needs-replan when user comments on in-review task", async () => {
const task = await store.createTask({ description: "Task in review", column: "todo" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await writeFile(promptPath, `# Task: ${task.id} - Plan\n\n## Mission\n\nPlanned task.`);
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.addComment(task.id, "Please adjust before merge", "user");
const updated = await store.getTask(task.id);
expect(updated.column).toBe("in-review");
expect(updated.status).toBeUndefined();
});
});

View File

@@ -49,6 +49,8 @@ export interface EvalRunWindow {
since?: string;
until?: string;
baselineRunId?: string;
windowStartExclusive?: string;
windowEndInclusive?: string;
}
export interface EvalProvenance {

View File

@@ -292,6 +292,12 @@ export const DEFAULT_PROJECT_SETTINGS = {
researchDefaultTimeout: 300000,
researchMaxSourcesPerRun: 20,
researchMaxSynthesisRounds: 2,
taskEvaluationEnabled: false,
taskEvaluationSchedule: "0 5 * * *",
taskEvaluationProvider: undefined,
taskEvaluationModelId: undefined,
taskEvaluationFollowUpPolicy: "off",
taskEvaluationRetention: undefined,
} satisfies CompleteSettings<ProjectSettings>;
/**

View File

@@ -5169,52 +5169,73 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
// Phase 3: Invalidate stale spec approval when a user comments on
// a triage task that is awaiting manual approval. The new comment
// means the spec is now stale and must be re-specified/re-reviewed.
// Phase 3: user comments on already-planned, non-executing work should
// trigger triage re-specification. This includes awaiting-approval
// invalidation and todo/triage tasks that have a real non-bootstrap spec.
// This remains best-effort: failures are logged for observability but
// never fail the comment add operation itself.
// Note: The `task` returned above reflects the state BEFORE this
// transition. Callers that need the post-transition status should
// re-read the task (e.g., via getTask).
if (
task.column === "triage"
&& task.status === "awaiting-approval"
&& author === "user"
) {
let invalidatedStatus = false;
if (author === "user" && (task.column === "todo" || task.column === "triage")) {
let hasRealPrompt = false;
try {
await this.updateTask(id, {
status: "needs-replan",
});
invalidatedStatus = true;
const promptPath = join(this.taskDir(id), "PROMPT.md");
if (existsSync(promptPath)) {
const prompt = await readFile(promptPath, "utf-8");
hasRealPrompt = !isBootstrapPromptStub(prompt, task.id, task.title, task.description);
}
} catch (err) {
storeLog.warn("Best-effort post-comment awaiting-approval invalidation failed", {
storeLog.warn("Best-effort post-comment re-triage prompt-read failed", {
...commentContextBase,
phase: "addComment:awaiting-approval-invalidation",
stage: "status-update",
nextStatus: "needs-replan",
phase: "addComment:retriage-prompt-read",
error: err instanceof Error ? err.message : String(err),
});
}
if (invalidatedStatus) {
const shouldInvalidateAwaitingApproval =
task.column === "triage" && task.status === "awaiting-approval";
const shouldRetriagePlannedTask = hasRealPrompt
&& (
task.column === "todo"
|| (task.column === "triage" && task.status !== "awaiting-approval")
);
if (shouldInvalidateAwaitingApproval || shouldRetriagePlannedTask) {
const phase = shouldInvalidateAwaitingApproval
? "addComment:awaiting-approval-invalidation"
: "addComment:planned-task-retriage";
const action = shouldInvalidateAwaitingApproval
? "User comment invalidated spec approval — task needs re-specification"
: "User comment requested re-specification of planned task";
let transitioned = false;
try {
await this.logEntry(
id,
`User comment invalidated spec approval — task needs re-specification`,
undefined,
runContext,
);
await this.updateTask(id, { status: "needs-replan" });
transitioned = true;
} catch (err) {
storeLog.warn("Best-effort post-comment awaiting-approval invalidation failed", {
storeLog.warn("Best-effort post-comment re-triage failed", {
...commentContextBase,
phase: "addComment:awaiting-approval-invalidation",
stage: "post-invalidation-log-entry",
phase,
stage: "status-update",
nextStatus: "needs-replan",
error: err instanceof Error ? err.message : String(err),
});
}
if (transitioned) {
try {
await this.logEntry(id, action, text, runContext);
} catch (err) {
storeLog.warn("Best-effort post-comment re-triage failed", {
...commentContextBase,
phase,
stage: "post-invalidation-log-entry",
nextStatus: "needs-replan",
error: err instanceof Error ? err.message : String(err),
});
}
}
}
}

View File

@@ -1687,6 +1687,18 @@ export interface ProjectSettings {
unavailableNodePolicy?: UnavailableNodePolicy;
/** Project-level research configuration overrides. */
researchSettings?: ResearchProjectSettings;
/** Enable scheduled evaluation batches for recently completed tasks. */
taskEvaluationEnabled?: boolean;
/** Cron expression for scheduled task-evaluation batches. */
taskEvaluationSchedule?: string;
/** Optional provider override for scheduled task evaluation runs. */
taskEvaluationProvider?: string;
/** Optional model override for scheduled task evaluation runs. */
taskEvaluationModelId?: string;
/** Follow-up policy for scheduled task evaluation findings. */
taskEvaluationFollowUpPolicy?: "off" | "suggest" | "create";
/** Optional retention window (days) for task evaluation history. */
taskEvaluationRetention?: number;
/** Enable or disable the research subsystem for this project.
* When undefined, falls back to global settings.
* @deprecated Prefer researchSettings.enabled */

View File

@@ -263,6 +263,7 @@ describe("buildSpecificationPrompt", () => {
expect(prompt).toContain(feedback);
expect(prompt).not.toContain("Existing Specification");
expect(prompt).toContain("without carrying forward stale assumptions");
expect(prompt).toContain("Treat the current task title and description as required primary inputs");
});
it("includes attachments when provided", () => {
@@ -1170,6 +1171,35 @@ describe("Re-specification flow", () => {
expect(revisionLogEntry?.outcome).toBe("Most recent feedback");
});
it("prefers latest comment-triggered re-spec feedback log over legacy revision requests", () => {
const taskWithCommentTriggeredFeedback: Task = {
...taskWithRevisionRequest,
log: [
{
timestamp: "2026-01-01T00:00:00.000Z",
action: "AI spec revision requested",
outcome: "Older feedback",
},
{
timestamp: "2026-01-01T00:03:00.000Z",
action: "User comment requested re-specification of planned task",
outcome: "Latest feedback",
},
],
};
const feedbackLogEntry = [...taskWithCommentTriggeredFeedback.log]
.reverse()
.find((entry) =>
entry.action === "User comment requested re-specification of planned task"
|| entry.action === "User comment invalidated spec approval — task needs re-specification"
|| entry.action === "AI spec revision requested"
);
expect(feedbackLogEntry?.outcome).toBe("Latest feedback");
});
});
describe("requirePlanApproval setting", () => {

View File

@@ -1077,11 +1077,24 @@ export class TriageProcessor {
let feedback: string | undefined;
if (isReplan) {
// Extract feedback from the most recent "AI spec revision requested" log entry
const revisionLogEntry = [...task.log]
// Prefer explicit re-specification feedback logged by comment-triggered
// and approval-invalidation flows; fall back to legacy revision logs.
const feedbackLogEntry = [...task.log]
.reverse()
.find((entry) => entry.action === "AI spec revision requested");
feedback = revisionLogEntry?.outcome;
.find((entry) =>
entry.action === "User comment requested re-specification of planned task"
|| entry.action === "User comment invalidated spec approval — task needs re-specification"
|| entry.action === "AI spec revision requested"
);
feedback = feedbackLogEntry?.outcome;
// Ensure the latest user feedback is always actionable for re-plans.
if (!feedback) {
const latestUserComment = [...(detail.comments || [])]
.reverse()
.find((comment) => comment.author === "user");
feedback = latestUserComment?.text;
}
planLog.log(
`${task.id} re-planning with feedback: ${feedback?.slice(0, 100)}...`,
@@ -2270,7 +2283,7 @@ Please revise the specification above to address this feedback. Write the comple
## Re-specification Instructions
You are creating a fresh replacement specification based on user feedback.
**Important:** Do not reuse stale PROMPT.md content. Start from the current task description, inspect the codebase, and write a complete new specification that addresses the feedback below.
**Important:** Do not reuse stale PROMPT.md content. Treat the current task title and description as required primary inputs, inspect the codebase, and write a complete new specification that addresses the feedback below.
## User Feedback
${feedback}
@@ -2340,7 +2353,7 @@ ${task.breakIntoSubtasks ? "- **Break into subtasks:** Yes (user requested)" : "
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}${revisionSection}${subtaskSection}
## Instructions
${isRevision ? "1. Review the existing specification and user feedback carefully\n2. Revise the PROMPT.md to address the feedback while maintaining the structure\n3. Ensure the specification is detailed enough for an AI agent to execute" : isFreshRespecification ? "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a fresh complete PROMPT.md specification to the given path following the format in your system prompt\n3. Address the user feedback without carrying forward stale assumptions from the old spec\n4. Name actual files, functions, and patterns from the codebase — be specific" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n4. Name actual files, functions, and patterns from the codebase — be specific"}
${isRevision ? "1. Review the existing specification and user feedback carefully\n2. Revise the PROMPT.md to address the feedback while maintaining the structure\n3. Ensure the specification is detailed enough for an AI agent to execute" : isFreshRespecification ? "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Treat the current task title and description as mandatory primary inputs for a new spec\n3. Write a fresh complete PROMPT.md specification to the given path following the format in your system prompt\n4. Address the user feedback without carrying forward stale assumptions from the old spec\n5. Name actual files, functions, and patterns from the codebase — be specific" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n4. Name actual files, functions, and patterns from the codebase — be specific"}
Use the write tool to write the specification file.${commandsSection}${completionDocumentationSection}${memorySection}${attachmentsSection}${userCommentsSection}`;
}