FN-6361: respect paused tasks during triage planning

Keep triage planning from advancing or continuing work after a task is paused.

- Abort active triage sessions and reviewer subagents when task updates mark the task paused.
- Skip approved-spec recovery and final approved-spec transitions while a task remains paused.
- Add regression coverage for paused planning, pause-driven aborts, and published package changeset metadata.

Files changed:
 .changeset/pause-triage-planning.md                |   5 +
 .../src/__tests__/triage-pause-abort.test.ts       | 237 +++++++++++++++++++++
 packages/engine/src/triage.ts                      |  57 +++++
 3 files changed, 299 insertions(+)

Fusion-Task-Id: FN-6361
Fusion-Task-Lineage: 33d849d5-1461-49ae-9fe0-be98b534ac35
This commit is contained in:
gsxdsm
2026-06-13 08:38:59 -07:00
parent 6941b7af1e
commit e305b1aa4c
3 changed files with 299 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Respect per-task pause state during triage planning so paused tasks do not auto-advance after specification approval.

View File

@@ -0,0 +1,237 @@
import "./executor-test-helpers.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { TriageProcessor } from "../triage.js";
import { resetExecutorMocks } from "./executor-test-helpers.js";
type Listener = (...args: any[]) => void;
function createEventedStore(overrides: Record<string, any> = {}) {
const listeners = new Map<string, Set<Listener>>();
const store = {
getSettings: vi.fn().mockResolvedValue({ pollIntervalMs: 60_000, maxConcurrent: 1, maxWorktrees: 1, autoMerge: true }),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
on: vi.fn((event: string, listener: Listener) => {
const set = listeners.get(event) ?? new Set<Listener>();
set.add(listener);
listeners.set(event, set);
}),
off: vi.fn((event: string, listener: Listener) => {
listeners.get(event)?.delete(listener);
}),
...overrides,
} as any;
return {
store,
emit(event: string, ...args: any[]) {
for (const listener of listeners.get(event) ?? []) {
listener(...args);
}
},
};
}
function createFinalizeStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn().mockResolvedValue(createTask()),
getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
deleteTask: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-PAUSE-1",
title: "Paused planning task",
description: "desc",
column: "triage",
status: "planning",
dependencies: [],
steps: [],
currentStep: 0,
log: [{ timestamp: new Date().toISOString(), action: "Spec review: APPROVE" }],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
describe("TriageProcessor per-task pause aborts", () => {
beforeEach(() => {
resetExecutorMocks();
vi.clearAllMocks();
});
it("does not start planning work for an already-paused triage task", async () => {
const task = createTask({ id: "FN-PAUSE-START", paused: true, status: null });
const { store } = createEventedStore({ listTasks: vi.fn().mockResolvedValue([task]) });
const processor = new TriageProcessor(store, "/tmp/root");
const specifyTask = vi.spyOn(processor as any, "specifyTask").mockResolvedValue(undefined);
(processor as any).running = true;
await (processor as any).poll();
expect(specifyTask).not.toHaveBeenCalled();
expect((processor as any).processing.has(task.id)).toBe(false);
});
it("aborts and disposes an active specify session on task:updated pause without moving to todo", async () => {
const { store, emit } = createEventedStore();
const stuckTaskDetector = { untrackTask: vi.fn() };
const processor = new TriageProcessor(store, "/tmp/root", { stuckTaskDetector } as any);
const abort = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
processor.start();
(processor as any).activeSessions.set("FN-PAUSE-2", { abort, dispose });
emit("task:updated", { id: "FN-PAUSE-2", paused: true });
await Promise.resolve();
expect(abort).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect((processor as any).activeSessions.has("FN-PAUSE-2")).toBe(false);
expect((processor as any).pauseAborted.has("FN-PAUSE-2")).toBe(true);
expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith("FN-PAUSE-2");
expect(store.moveTask).not.toHaveBeenCalled();
processor.stop();
});
it("treats userPaused task updates as pause aborts", async () => {
const { store, emit } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
const abort = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
processor.start();
(processor as any).activeSessions.set("FN-USER-PAUSE", { abort, dispose });
emit("task:updated", { id: "FN-USER-PAUSE", userPaused: true });
await Promise.resolve();
expect(abort).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect((processor as any).pauseAborted.has("FN-USER-PAUSE")).toBe(true);
processor.stop();
});
it("does not abort on non-paused updates or paused ids with no active session", () => {
const { store, emit } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
const abort = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
processor.start();
(processor as any).activeSessions.set("FN-ACTIVE", { abort, dispose });
expect(() => emit("task:updated", { id: "FN-ACTIVE", paused: false })).not.toThrow();
expect(() => emit("task:updated", { id: "FN-MISSING", paused: true })).not.toThrow();
expect(abort).not.toHaveBeenCalled();
expect(dispose).not.toHaveBeenCalled();
expect((processor as any).activeSessions.has("FN-ACTIVE")).toBe(true);
processor.stop();
});
it("detaches the task:updated pause listener on stop", () => {
const { store, emit } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
const abort = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
processor.start();
(processor as any).activeSessions.set("FN-PAUSE-STOP", { abort, dispose });
processor.stop();
const abortCallsAfterStop = abort.mock.calls.length;
const disposeCallsAfterStop = dispose.mock.calls.length;
emit("task:updated", { id: "FN-PAUSE-STOP", paused: true });
expect(abort).toHaveBeenCalledTimes(abortCallsAfterStop);
expect(dispose).toHaveBeenCalledTimes(disposeCallsAfterStop);
});
});
describe("TriageProcessor paused finalization guard", () => {
beforeEach(() => {
resetExecutorMocks();
vi.clearAllMocks();
});
it("does not move an approved task to todo when the re-read task is paused", async () => {
const task = createTask({ id: "FN-FINALIZE-PAUSED" });
const store = createFinalizeStore({ getTask: vi.fn().mockResolvedValue({ ...task, paused: true }) });
const processor = new TriageProcessor(store, "/tmp/root");
await (processor as any).finalizeApprovedTask(
task,
"# Task: FN-FINALIZE-PAUSED\n\n## File Scope\n- packages/engine/src/triage.ts\n",
{ requirePlanApproval: false } as Settings,
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenLastCalledWith(task.id, { status: null });
expect(store.logEntry).toHaveBeenCalledWith(
task.id,
"Specification approved but task is paused — leaving in triage, will resume on unpause",
);
});
it("does not move to awaiting-approval when the re-read task is userPaused", async () => {
const task = createTask({ id: "FN-FINALIZE-USER-PAUSED" });
const store = createFinalizeStore({ getTask: vi.fn().mockResolvedValue({ ...task, userPaused: true }) });
const processor = new TriageProcessor(store, "/tmp/root");
await (processor as any).finalizeApprovedTask(
task,
"# Task: FN-FINALIZE-USER-PAUSED\n\n## File Scope\n- packages/engine/src/triage.ts\n",
{ requirePlanApproval: true } as Settings,
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalledWith(task.id, expect.objectContaining({ status: "awaiting-approval" }));
expect(store.updateTask).toHaveBeenLastCalledWith(task.id, { status: null });
});
it("keeps the unpaused approved-spec happy path moving to todo", async () => {
const task = createTask({ id: "FN-FINALIZE-HAPPY" });
const store = createFinalizeStore({ getTask: vi.fn().mockResolvedValue({ ...task, paused: false, userPaused: false }) });
const processor = new TriageProcessor(store, "/tmp/root");
await (processor as any).finalizeApprovedTask(
task,
"# Task: FN-FINALIZE-HAPPY\n\n## File Scope\n- packages/engine/src/triage.ts\n",
{ requirePlanApproval: false } as Settings,
);
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
});
it("does not recover an approved planning task while it is paused", async () => {
const task = createTask({ id: "FN-RECOVER-PAUSED", paused: true });
const store = createFinalizeStore();
const processor = new TriageProcessor(store, "/tmp/root");
await expect(processor.recoverApprovedTask(task)).resolves.toBe(false);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
});
});

View File

@@ -146,6 +146,7 @@ export class TriageProcessor {
/** Tasks killed by the stuck task detector (to avoid reporting as errors). */
private stuckAborted = new Set<string>();
private taskDeletedHandler?: (task: Task) => void;
private taskPausedHandler?: (task: Task) => void;
/**
* @param store — Task store instance (also used to listen for `settings:updated` events)
@@ -218,6 +219,32 @@ export class TriageProcessor {
this.activeSessions.delete(task.id);
}
};
this.taskPausedHandler = (task: Task) => {
if (!task?.id || (task.paused !== true && task.userPaused !== true)) {
return;
}
if (this.activeSubagentSessions.has(task.id)) {
this.disposeSubagentsForTask(task.id, "task paused");
}
if (this.activeSessions.has(task.id)) {
const session = this.activeSessions.get(task.id)!;
planLog.log(`task paused — terminating triage session for ${task.id}`);
this.pauseAborted.add(task.id);
this.options.stuckTaskDetector?.untrackTask(task.id);
const sessionWithAbort = session as {
abort?: () => Promise<void>;
dispose: () => void;
};
if (typeof sessionWithAbort.abort === "function") {
void sessionWithAbort.abort().catch((err) => {
planLog.warn(`Failed to abort triage session for ${task.id}: ${err}`);
});
}
session.dispose();
this.activeSessions.delete(task.id);
}
};
}
start(): void {
@@ -226,6 +253,9 @@ export class TriageProcessor {
if (this.taskDeletedHandler && typeof this.store.on === "function") {
this.store.on("task:deleted", this.taskDeletedHandler);
}
if (this.taskPausedHandler && typeof this.store.on === "function") {
this.store.on("task:updated", this.taskPausedHandler);
}
// Clear stale "planning" statuses left by a prior crash/restart.
// No triage agent is actually running at startup, so any task still
@@ -267,6 +297,9 @@ export class TriageProcessor {
if (this.taskDeletedHandler && typeof this.store.off === "function") {
this.store.off("task:deleted", this.taskDeletedHandler);
}
if (this.taskPausedHandler && typeof this.store.off === "function") {
this.store.off("task:updated", this.taskPausedHandler);
}
// Tear down any in-flight specify sessions and reviewer subagents so they
// don't keep streaming LLM tokens / tool calls past engine shutdown.
this.abortAndDisposeActiveSessions("engine stop");
@@ -407,6 +440,11 @@ export class TriageProcessor {
return false;
}
if (task.paused === true || task.userPaused === true) {
planLog.log(`${task.id} approved-spec recovery skipped — task is paused`);
return false;
}
if (!hasLatestSpecReviewApproval(task)) {
return false;
}
@@ -2244,6 +2282,25 @@ export class TriageProcessor {
planLog.warn(`${task.id}: near-duplicate backstop failed open: ${message}`);
}
let latestTransitionTask: Task | undefined;
try {
latestTransitionTask = await this.store.getTask(task.id);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to re-read task before approved-spec transition (${message}); proceeding with original task snapshot`);
latestTransitionTask = task;
}
if (latestTransitionTask?.paused === true || latestTransitionTask?.userPaused === true) {
const restoreStatus = options.isReplan ? "needs-replan" : null;
await this.store.updateTask(task.id, { status: restoreStatus });
await this.store.logEntry(
task.id,
"Specification approved but task is paused — leaving in triage, will resume on unpause",
);
planLog.log(`${task.id} approved specification paused — leaving in triage, will resume on unpause`);
return;
}
if (settings.requirePlanApproval) {
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {