fix: persist manual task pauses (#2536)

## Summary

- persist an explicit `userPaused` latch when operators pause tasks
through CLI, MCP, dashboard task routes, or mission stop
- keep automatic/internal pauses distinct (`userPaused` remains false
unless explicitly requested)
- clear the latch on unpause
- route the flag through in-memory and PostgreSQL task stores
- add contract coverage across core, CLI, MCP, dashboard task routes,
and mission stop

## Why

A manually paused task could lose the reason for its pause across
dashboard/runtime restart. Startup recovery then treated it like an
internally interrupted task and reclaimed it, restarting automation
against the operator’s intent. Manual pauses must survive restart and
remain non-runnable until explicitly unpaused.

## Verification

- core pause durability tests: 2 passed
- CLI task/extension tests: 150 passed; PostgreSQL integration lane
remains active in CI
- dashboard route tests: 261 passed
- `@fusion/core`, `@runfusion/fusion`, and `@fusion/dashboard`
typechecks passed
- full workspace build passed with pnpm 10.33.0
- changeset validation and `git diff --check` passed
- live aggregate runtime verification also confirmed
`paused=true,userPaused=true` survived a normal dashboard restart with
zero active tasks


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Manual task pauses now persist across application restarts and
recovery.
- Pauses initiated via the CLI, dashboard, MCP tools, and mission stop
controls are recorded as explicit user actions.
  - Automatically paused tasks remain eligible for recovery.
  - Unpausing clears the durable manual-pause state.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Phil Larson
2026-07-29 00:04:28 -07:00
committed by GitHub
parent 3ff98aae56
commit 9a8fc409ff
14 changed files with 213 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Persist explicit user intent across manual task pauses so startup recovery cannot reclaim paused work.
category: fix
dev: CLI, dashboard, MCP tool, and mission pause controls now set the durable userPaused latch while automatic holds remain recoverable.

View File

@@ -163,4 +163,19 @@ pgTest("extension task tools resolve repo root from worktrees", () => {
expect(Array.isArray(show.content)).toBe(true);
expect(show.content[0]?.text).toContain(created.id);
});
it("persists manual pause intent through fn_task_pause", async () => {
const store = h.store();
const created = await store.createTask({ description: "Operator-paused task" });
const api = createMockApi();
registerExtension(api);
const pauseTool = requireTool(api, "fn_task_pause");
await pauseTool.execute("pause", { id: created.id }, undefined, undefined, { cwd: h.rootDir() });
await expect(store.getTask(created.id)).resolves.toMatchObject({
paused: true,
userPaused: true,
});
});
});

View File

@@ -1360,6 +1360,10 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
makeCtx(tmpDir),
);
expect(pauseResult.content[0].text).toContain("Paused FN-001");
await expect(h.store().getTask("FN-001")).resolves.toMatchObject({
paused: true,
userPaused: true,
});
// Verify it's paused
const showTool = api.tools.get("fn_task_show")!;

View File

@@ -1217,7 +1217,7 @@ describe("project-aware task command behavior", () => {
await runTaskPause("FN-123", "demo-project");
await runTaskUnpause("FN-123", "demo-project");
expect(pauseTask).toHaveBeenNthCalledWith(1, "FN-123", true);
expect(pauseTask).toHaveBeenNthCalledWith(1, "FN-123", true, undefined, { userPaused: true });
expect(pauseTask).toHaveBeenNthCalledWith(2, "FN-123", false);
});

View File

@@ -1157,7 +1157,7 @@ export async function runTaskAttach(id: string, filePath: string, projectName?:
export async function runTaskPause(id: string, projectName?: string) {
// FNXC:CliBoardMutation 2026-07-09-00:00 (FN-7734): single board write.
await withBoardWrite(projectName, { id, action: "pause task" }, async (context) => {
const task = await context.store.pauseTask(id, true);
const task = await context.store.pauseTask(id, true, undefined, { userPaused: true });
console.log();
console.log(` ✓ Paused ${task.id}`);

View File

@@ -1799,7 +1799,7 @@ export default function kbExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.pauseTask(params.id, true);
const task = await store.pauseTask(params.id, true, undefined, { userPaused: true });
return {
content: [{ type: "text", text: `Paused ${task.id}` }],

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { pauseTaskImpl } from "../task-store/branch-group-ops.js";
import type { TaskStore } from "../store.js";
import type { Task } from "../types.js";
describe("TaskStore manual pause durability", () => {
it("marks a manual pause as user-paused", async () => {
let persisted = {
id: "FN-001",
column: "in-progress",
status: "executing",
log: [],
} as unknown as Task;
const store = {
withTaskLock: async (_id: string, operation: () => Promise<Task>) => operation(),
taskDir: () => "/tmp/FN-001",
readTaskJson: async () => ({ ...persisted, log: [...(persisted.log ?? [])] }),
atomicWriteTaskJson: async (_dir: string, task: Task) => {
persisted = task;
},
isWatching: false,
emit: () => undefined,
} as unknown as TaskStore;
const paused = await pauseTaskImpl(store, "FN-001", true, undefined, { userPaused: true });
expect(paused).toMatchObject({ paused: true, userPaused: true, status: "paused" });
expect(persisted).toMatchObject({ paused: true, userPaused: true, status: "paused" });
});
it("does not mark an automatic hold as user-paused", async () => {
let persisted = {
id: "FN-002",
column: "in-progress",
status: "executing",
log: [],
} as unknown as Task;
const store = {
withTaskLock: async (_id: string, operation: () => Promise<Task>) => operation(),
taskDir: () => "/tmp/FN-002",
readTaskJson: async () => ({ ...persisted, log: [...(persisted.log ?? [])] }),
atomicWriteTaskJson: async (_dir: string, task: Task) => {
persisted = task;
},
isWatching: false,
emit: () => undefined,
} as unknown as TaskStore;
const paused = await pauseTaskImpl(store, "FN-002", true, undefined, {
pausedReason: "token_budget_exceeded",
});
expect(paused).toMatchObject({ paused: true, status: "paused" });
expect(paused.userPaused).toBeUndefined();
expect(persisted.userPaused).toBeUndefined();
});
it("clears the durable user-pause latch when unpaused", async () => {
let persisted = {
id: "FN-003",
column: "in-progress",
status: "executing",
log: [],
} as unknown as Task;
const store = {
withTaskLock: async (_id: string, operation: () => Promise<Task>) => operation(),
taskDir: () => "/tmp/FN-003",
readTaskJson: async () => ({ ...persisted, log: [...(persisted.log ?? [])] }),
atomicWriteTaskJson: async (_dir: string, task: Task) => {
persisted = task;
},
isWatching: false,
emit: () => undefined,
} as unknown as TaskStore;
await pauseTaskImpl(store, "FN-003", true, undefined, { userPaused: true });
const unpaused = await pauseTaskImpl(store, "FN-003", false);
expect(unpaused.paused).toBeUndefined();
expect(unpaused.status).toBeUndefined();
expect(unpaused.userPaused).toBeUndefined();
expect(persisted.paused).toBeUndefined();
expect(persisted.status).toBeUndefined();
expect(persisted.userPaused).toBeUndefined();
});
});

View File

@@ -1353,7 +1353,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
public async updateTaskUnlocked( id: string, updates: Parameters<TaskStore["updateTask"]>[1], runContext?: RunMutationContext, ): Promise<Task> {
return updateTaskUnlockedImpl(this, id, updates, runContext);
}
async pauseTask( id: string, paused: boolean, runContext?: RunMutationContext, agentOptions?: { pausedByAgentId?: string; pausedReason?: string }, ): Promise<Task> {
async pauseTask( id: string, paused: boolean, runContext?: RunMutationContext, agentOptions?: { pausedByAgentId?: string; pausedReason?: string; userPaused?: boolean }, ): Promise<Task> {
return pauseTaskImpl(this, id, paused, runContext, agentOptions);
}

View File

@@ -172,7 +172,7 @@ export async function selectNextTaskForAgentImpl(store: TaskStore, agentId: stri
return null;
}
export async function pauseTaskImpl(store: TaskStore, id: string, paused: boolean, runContext?: RunMutationContext, agentOptions?: { pausedByAgentId?: string; pausedReason?: string },): Promise<Task> {
export async function pauseTaskImpl(store: TaskStore, id: string, paused: boolean, runContext?: RunMutationContext, agentOptions?: { pausedByAgentId?: string; pausedReason?: string; userPaused?: boolean },): Promise<Task> {
return store.withTaskLock(id, async () => {
const dir = store.taskDir(id);
const task = await store.readTaskJson(dir);
@@ -184,6 +184,9 @@ export async function pauseTaskImpl(store: TaskStore, id: string, paused: boolea
const previousPausedByAgentId = task.pausedByAgentId;
task.paused = paused || undefined;
if (paused && agentOptions?.userPaused) {
task.userPaused = true;
}
if (paused && agentOptions?.pausedByAgentId) {
task.pausedByAgentId = agentOptions.pausedByAgentId;
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { pauseMissionTasksForOperatorStop } from "../mission-routes.js";
describe("pauseMissionTasksForOperatorStop", () => {
it("durably marks every linked mission task as user-paused", async () => {
const pauseTask = vi.fn().mockResolvedValue(undefined);
const store = { pauseTask } as unknown as TaskStore;
const hierarchy = {
milestones: [
{
slices: [
{
features: [
{ taskId: "FN-001" },
{},
{ taskId: "FN-002" },
],
},
],
},
],
};
await expect(pauseMissionTasksForOperatorStop(store, hierarchy)).resolves.toEqual([
"FN-001",
"FN-002",
]);
expect(pauseTask).toHaveBeenNthCalledWith(1, "FN-001", true, undefined, { userPaused: true });
expect(pauseTask).toHaveBeenNthCalledWith(2, "FN-002", true, undefined, { userPaused: true });
});
it("continues after one linked task can no longer be paused", async () => {
const pauseTask = vi.fn()
.mockRejectedValueOnce(new Error("task not found"))
.mockResolvedValueOnce(undefined);
const store = { pauseTask } as unknown as TaskStore;
const hierarchy = {
milestones: [{ slices: [{ features: [{ taskId: "FN-gone" }, { taskId: "FN-live" }] }] }],
};
await expect(pauseMissionTasksForOperatorStop(store, hierarchy)).resolves.toEqual(["FN-live"]);
expect(pauseTask).toHaveBeenNthCalledWith(1, "FN-gone", true, undefined, { userPaused: true });
expect(pauseTask).toHaveBeenNthCalledWith(2, "FN-live", true, undefined, { userPaused: true });
});
});

View File

@@ -3704,7 +3704,7 @@ describe("Pause/Unpause endpoints", () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
expect(res.status).toBe(200);
expect(res.body).toEqual({ id: "FN-001", paused: true });
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", true);
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", true, undefined, { userPaused: true });
});
it("POST /tasks/:id/unpause — unpauses a task", async () => {

View File

@@ -16,6 +16,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"
import { AsyncLocalStorage } from "node:async_hooks";
import {
TaskStore,
createLogger,
resolvePlanningSettingsModel,
AgentStore,
THINKING_LEVELS,
@@ -71,6 +72,8 @@ import {
import type { AiSessionStore } from "./ai-session-store.js";
import { resolveBranchAssignmentContext, resolveBranchSelection } from "./routes/branch-selection.js";
const missionRoutesLog = createLogger("dashboard-mission-routes");
/** Resolve the mission-start override through the planning settings hierarchy. */
export function resolveMissionInterviewThinkingLevel(
settings: Partial<Settings> | undefined,
@@ -79,6 +82,39 @@ export function resolveMissionInterviewThinkingLevel(
return resolvePlanningThinkingLevel(settings, thinkingLevel) as ThinkingLevel | undefined;
}
type MissionTaskHierarchy = {
milestones: Array<{
slices: Array<{
features: Array<{ taskId?: string }>;
}>;
}>;
};
export async function pauseMissionTasksForOperatorStop(
store: Pick<TaskStore, "pauseTask">,
hierarchy: MissionTaskHierarchy,
): Promise<string[]> {
const pausedTaskIds: string[] = [];
for (const milestone of hierarchy.milestones) {
for (const slice of milestone.slices) {
for (const feature of slice.features) {
if (!feature.taskId) continue;
try {
await store.pauseTask(feature.taskId, true, undefined, { userPaused: true });
pausedTaskIds.push(feature.taskId);
} catch (error) {
// Continue stopping the mission if a linked task is already gone, but
// keep unexpected pause failures visible to operators.
missionRoutesLog.warn(
`Failed to pause mission-linked task ${feature.taskId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
}
return pausedTaskIds;
}
// ── Validation Utilities ────────────────────────────────────────────────────
/*
@@ -3012,22 +3048,8 @@ export function createMissionRouter(
// Set mission status to blocked
const updated = await missionStore.updateMission(missionId, { status: "blocked" }, { actor: DASHBOARD_MISSION_ACTOR });
// Pause all tasks linked to features in this mission
const pausedTaskIds: string[] = [];
for (const milestone of hierarchy.milestones) {
for (const slice of milestone.slices) {
for (const feature of slice.features) {
if (feature.taskId) {
try {
await store.pauseTask(feature.taskId, true);
pausedTaskIds.push(feature.taskId);
} catch (_err) {
// Log but don't fail — task may already be paused or not found
}
}
}
}
}
// Pause all tasks linked to features in this mission.
const pausedTaskIds = await pauseMissionTasksForOperatorStop(getScopedStore(), hierarchy);
res.json({ ...updated, pausedTaskIds });
})

View File

@@ -33,11 +33,11 @@ const createPauseRouteHarness = (initialTaskState: any) => {
*/
getProjectScopedPluginMcpServers: vi.fn(async () => []),
getTask: vi.fn(async () => taskState),
pauseTask: vi.fn(async (_id: string, paused: boolean) => {
pauseTask: vi.fn(async (_id: string, paused: boolean, _runContext, options) => {
taskState = {
...taskState,
paused: paused ? true : undefined,
userPaused: paused ? taskState.userPaused : undefined,
userPaused: paused ? (options?.userPaused ? true : taskState.userPaused) : undefined,
pausedByAgentId: paused ? taskState.pausedByAgentId : undefined,
};
return taskState;
@@ -90,6 +90,7 @@ describe("task workflow pause routes", () => {
expect(res.status).toBe(200);
expect(getTaskState().paused).toBe(true);
expect(store.pauseTask).toHaveBeenCalledWith("FN-001", true);
expect(getTaskState().userPaused).toBe(true);
expect(store.pauseTask).toHaveBeenCalledWith("FN-001", true, undefined, { userPaused: true });
});
});

View File

@@ -3549,7 +3549,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
try {
const { store: scopedStore } = await getProjectContext(req);
await scopedStore.getTask(req.params.id);
const updated = await scopedStore.pauseTask(req.params.id, true);
const updated = await scopedStore.pauseTask(req.params.id, true, undefined, { userPaused: true });
res.json(updated);
} catch (err: unknown) {
if (err instanceof ApiError) {