FN-6875: allow manual unpause for assigned tasks

Allow users to recover paused agent-assigned tasks without switching to agent controls.

- Remove the API conflict that blocked manual pause and unpause on assigned tasks.
- Keep pause and unpause actions visible for assigned, paused, and agent-paused tasks in the detail menu.
- Cover assigned-task pause controls and route unpause behavior with regression tests.
- Document manual unpause support and add a patch changeset.

Files changed:
 .changeset/fn-6875-allow-unpause-assigned.md       |   5 +
 docs/agents.md                                     |   2 +-
 docs/dashboard-guide.md                            |   1 +
 .../dashboard/app/components/TaskDetailModal.tsx   |  17 +++-
 .../TaskDetailModal.definition-actions.test.tsx    |  97 +++++++++++++++++--
 .../register-task-workflow-routes.unpause.test.ts  | 103 ++++++++++++++-------
 .../src/routes/register-task-workflow-routes.ts    |  14 ++-
 7 files changed, 187 insertions(+), 52 deletions(-)

Fusion-Task-Id: FN-6875

Fusion-Task-Lineage: 9233822c-2013-475d-b17a-2212a38ab388
This commit is contained in:
gsxdsm
2026-06-21 16:34:41 -07:00
parent 55266b6e9e
commit 09acfbb707
7 changed files with 187 additions and 52 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Allow users to manually pause and unpause agent-assigned tasks from the dashboard task detail view and API.

View File

@@ -152,7 +152,7 @@ Approval pause/resume lifecycle (FN-3548):
- Permanent-agent gating short-circuits `block` and `require-approval` actions before tool execution and returns structured non-success tool results. - Permanent-agent gating short-circuits `block` and `require-approval` actions before tool execution and returns structured non-success tool results.
- For `require-approval`, the engine creates/reuses a durable approval request and pauses execution with canonical `pauseReason: "awaiting-approval"`. - For `require-approval`, the engine creates/reuses a durable approval request and pauses execution with canonical `pauseReason: "awaiting-approval"`.
- If task-backed, the owning task is paused (`Task.paused=true`, `pausedByAgentId=<requester>`); the requesting agent is paused (`state="paused"`, `pauseReason="awaiting-approval"`). - If task-backed, the owning task is paused (`Task.paused=true`, `pausedByAgentId=<requester>`); the requesting agent is paused (`state="paused"`, `pauseReason="awaiting-approval"`). The task-detail **Paused by agent** indicator is context only: operators may still manually pause or unpause an agent-assigned task, and unpause clears the task pause latch.
- Dedupe semantics by `approvalDedupeKey`: `pending` reuses the same request, `approved` allows exactly one execution and then marks request `completed`, `denied` stays blocked, `completed` requires a fresh request. - Dedupe semantics by `approvalDedupeKey`: `pending` reuses the same request, `approved` allows exactly one execution and then marks request `completed`, `denied` stays blocked, `completed` requires a fresh request.
- HTTP decision endpoint resumes best-effort: `POST /api/approvals/:id/decision` with `{ decision: "approve" | "deny", comment? }` unpauses matching task/agent when they are paused for `awaiting-approval`. - HTTP decision endpoint resumes best-effort: `POST /api/approvals/:id/decision` with `{ decision: "approve" | "deny", comment? }` unpauses matching task/agent when they are paused for `awaiting-approval`.
- Approval API surface: `GET /api/approvals` (supports status/limit/offset and returns `{ requests, total, pendingCount }`), `GET /api/approvals/:id` (includes request context + audit/history), `POST /api/approvals/:id/decision`. - Approval API surface: `GET /api/approvals` (supports status/limit/offset and returns `{ requests, total, pendingCount }`), `GET /api/approvals/:id` (includes request context + audit/history), `POST /api/approvals/:id/decision`.

View File

@@ -813,6 +813,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form. - Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group. - These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.
- Task metadata keeps priority, execution mode, provenance, optional PR context, and compact `Created` / `Updated` timestamps in one wrapping row across desktop and mobile widths; recent timestamps render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates. - Task metadata keeps priority, execution mode, provenance, optional PR context, and compact `Created` / `Updated` timestamps in one wrapping row across desktop and mobile widths; recent timestamps render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates.
- The **Actions** menu exposes **Pause** / **Unpause** for eligible non-terminal tasks, including tasks assigned to agents. If a task was paused by an agent, the **Paused by agent** note is informational; users can still unpause it manually from the same menu.
- Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled. - Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled.
- The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls. - The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls.
- Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained. - Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained.

View File

@@ -4281,7 +4281,13 @@ export function TaskDetailContent({
)} )}
{/* Actions dropdown — less common operations */} {/* Actions dropdown — less common operations */}
{(task.column !== "triage" || task.status === "awaiting-approval" || canRetryTask || isTaskPaused) && ( {(
task.column !== "triage"
|| task.status === "awaiting-approval"
|| canRetryTask
|| isTaskPaused
|| Boolean(task.assignedAgentId)
) && (
<div className="detail-actions-dropdown" ref={actionsMenuRef}> <div className="detail-actions-dropdown" ref={actionsMenuRef}>
<button <button
className="btn btn-sm" className="btn btn-sm"
@@ -4359,8 +4365,11 @@ export function TaskDetailContent({
</button> </button>
)} )}
{/* Pause/Unpause */} {/*
{task.column !== "done" && !task.assignedAgentId && ( FNXC:TaskPauseControls 2026-06-21-00:00:
Users may pause or unpause agent-assigned and agent-paused tasks at any time from the detail Actions menu. The Paused by agent note remains informational context, not a substitute for the actionable unpause control.
*/}
{task.column !== "done" && task.column !== "archived" && (
<button <button
className="detail-actions-menu-item" className="detail-actions-menu-item"
role="menuitem" role="menuitem"
@@ -4369,7 +4378,7 @@ export function TaskDetailContent({
{isTaskPaused ? t("taskDetail.pause.unpauseBtn", "Unpause") : t("taskDetail.pause.pauseBtn", "Pause")} {isTaskPaused ? t("taskDetail.pause.unpauseBtn", "Unpause") : t("taskDetail.pause.pauseBtn", "Pause")}
</button> </button>
)} )}
{task.column !== "done" && task.paused && task.pausedByAgentId && ( {task.column !== "done" && task.column !== "archived" && task.paused && task.pausedByAgentId && (
<span <span
className="detail-actions-menu-item detail-actions-menu-note" className="detail-actions-menu-item detail-actions-menu-note"
role="note" role="note"

View File

@@ -943,14 +943,16 @@ describe("TaskDetailModal", () => {
}); });
}); });
it("hides Pause/Unpause button for agent-assigned tasks", async () => { it("renders actionable Unpause button for agent-assigned paused tasks", async () => {
const { fetchAgent } = await import("../../api"); const { fetchAgent, unpauseTask } = await import("../../api");
const mockFetchAgent = vi.mocked(fetchAgent); const mockFetchAgent = vi.mocked(fetchAgent);
const mockUnpauseTask = vi.mocked(unpauseTask);
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any); mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
mockUnpauseTask.mockClear();
render( render(
<TaskDetailModal <TaskDetailModal
task={makeTask({ column: "triage", paused: true, assignedAgentId: "agent-1" })} task={makeTask({ id: "FN-ASSIGNED", column: "triage", paused: true, assignedAgentId: "agent-1" })}
initialTab="definition" initialTab="definition"
onClose={noop} onClose={noop}
onMoveTask={noopMove} onMoveTask={noopMove}
@@ -966,14 +968,15 @@ describe("TaskDetailModal", () => {
}); });
await userEvent.click(screen.getByRole("button", { name: /actions/i })); await userEvent.click(screen.getByRole("button", { name: /actions/i }));
await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("menuitem", { name: "Pause" })).toBeNull(); expect(mockUnpauseTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menuitem", { name: "Unpause" })).toBeNull(); expect(mockUnpauseTask).toHaveBeenCalledWith("FN-ASSIGNED", undefined);
}); });
}); });
it("shows paused-by-agent indicator for agent-paused tasks", async () => { it("shows paused-by-agent indicator alongside actionable Unpause for agent-paused tasks", async () => {
const { fetchAgent } = await import("../../api"); const { fetchAgent } = await import("../../api");
const mockFetchAgent = vi.mocked(fetchAgent); const mockFetchAgent = vi.mocked(fetchAgent);
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "paused" } as any); mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "paused" } as any);
@@ -997,9 +1000,91 @@ describe("TaskDetailModal", () => {
await userEvent.click(screen.getByRole("button", { name: /actions/i })); await userEvent.click(screen.getByRole("button", { name: /actions/i }));
expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy();
expect(await screen.findByText("Paused by agent")).toBeTruthy(); expect(await screen.findByText("Paused by agent")).toBeTruthy();
}); });
it("renders actionable Pause button for agent-assigned tasks that are not paused", async () => {
const { fetchAgent, pauseTask } = await import("../../api");
const mockFetchAgent = vi.mocked(fetchAgent);
const mockPauseTask = vi.mocked(pauseTask);
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
mockPauseTask.mockClear();
render(
<TaskDetailModal
task={makeTask({ id: "FN-ASSIGNED", column: "triage", paused: false, userPaused: false, assignedAgentId: "agent-1" })}
initialTab="definition"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(mockFetchAgent).toHaveBeenCalledWith("agent-1", undefined);
});
await userEvent.click(screen.getByRole("button", { name: /actions/i }));
await userEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
await waitFor(() => {
expect(mockPauseTask).toHaveBeenCalledTimes(1);
expect(mockPauseTask).toHaveBeenCalledWith("FN-ASSIGNED", undefined);
});
});
it.each([
["paused-only", { paused: true, userPaused: false }, "Unpause"],
["userPaused-only", { paused: false, userPaused: true }, "Unpause"],
["paused-and-userPaused", { paused: true, userPaused: true }, "Unpause"],
["not-paused", { paused: false, userPaused: false }, "Pause"],
])("uses the correct Pause/Unpause label for agent-assigned %s tasks", async (_name, state, expectedLabel) => {
const { fetchAgent } = await import("../../api");
const mockFetchAgent = vi.mocked(fetchAgent);
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
render(
<TaskDetailModal
task={makeTask({ column: "todo", assignedAgentId: "agent-1", ...state })}
initialTab="definition"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /actions/i }));
expect(screen.getByRole("menuitem", { name: expectedLabel })).toBeTruthy();
});
it.each(["done", "archived"])("hides Pause/Unpause button for %s tasks", async (column) => {
render(
<TaskDetailModal
task={makeTask({ column: column as "done" | "archived", paused: true, userPaused: true, assignedAgentId: "agent-1" })}
initialTab="definition"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /actions/i }));
expect(screen.queryByRole("menuitem", { name: "Pause" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Unpause" })).toBeNull();
});
it("does NOT render Actions dropdown for a non-paused, non-awaiting-approval, non-retryable triage task", () => { it("does NOT render Actions dropdown for a non-paused, non-awaiting-approval, non-retryable triage task", () => {
render( render(
<TaskDetailModal <TaskDetailModal

View File

@@ -6,46 +6,83 @@ import type { TaskStore } from "@fusion/core";
import { createApiRoutes } from "../../routes.js"; import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js"; import { request as REQUEST } from "../../test-request.js";
describe("task workflow unpause route", () => { const makeTaskState = (overrides: Record<string, unknown> = {}) => ({
id: "FN-001",
description: "todo parked task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-05-15T00:00:00.000Z",
updatedAt: "2026-05-15T00:00:00.000Z",
paused: undefined,
userPaused: undefined,
...overrides,
} as any);
const createPauseRouteHarness = (initialTaskState: any) => {
let taskState = initialTaskState;
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
getTask: vi.fn(async () => taskState),
pauseTask: vi.fn(async (_id: string, paused: boolean) => {
taskState = {
...taskState,
paused: paused ? true : undefined,
userPaused: paused ? taskState.userPaused : undefined,
pausedByAgentId: paused ? taskState.pausedByAgentId : undefined,
};
return taskState;
}),
} as unknown as TaskStore;
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return { app, store, getTaskState: () => taskState };
};
describe("task workflow pause routes", () => {
it("clears userPaused latch for todo user-paused tasks", async () => { it("clears userPaused latch for todo user-paused tasks", async () => {
let taskState = { const { app, store, getTaskState } = createPauseRouteHarness(makeTaskState({ userPaused: true }));
id: "FN-001",
description: "todo parked task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-05-15T00:00:00.000Z",
updatedAt: "2026-05-15T00:00:00.000Z",
paused: undefined,
userPaused: true,
} as any;
const store: TaskStore = {
getRootDir: vi.fn(() => process.cwd()),
getTask: vi.fn(async () => taskState),
pauseTask: vi.fn(async (_id: string, paused: boolean) => {
taskState = {
...taskState,
paused: paused ? true : undefined,
userPaused: paused ? taskState.userPaused : undefined,
};
return taskState;
}),
} as unknown as TaskStore;
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "POST", "/api/tasks/FN-001/unpause", JSON.stringify({}), { const res = await REQUEST(app, "POST", "/api/tasks/FN-001/unpause", JSON.stringify({}), {
"content-type": "application/json", "content-type": "application/json",
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(taskState.userPaused).toBeUndefined(); expect(getTaskState().userPaused).toBeUndefined();
expect(taskState.userPaused === true).toBe(false); expect(getTaskState().userPaused === true).toBe(false);
expect(store.pauseTask).toHaveBeenCalledWith("FN-001", false); expect(store.pauseTask).toHaveBeenCalledWith("FN-001", false);
}); });
it("allows agent-assigned paused tasks to be manually unpaused", async () => {
const { app, store, getTaskState } = createPauseRouteHarness(makeTaskState({
assignedAgentId: "agent-1",
paused: true,
pausedByAgentId: "agent-1",
}));
const res = await REQUEST(app, "POST", "/api/tasks/FN-001/unpause", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(getTaskState().paused).toBeUndefined();
expect(getTaskState().pausedByAgentId).toBeUndefined();
expect(store.pauseTask).toHaveBeenCalledWith("FN-001", false);
});
it("allows agent-assigned tasks to be manually paused", async () => {
const { app, store, getTaskState } = createPauseRouteHarness(makeTaskState({ assignedAgentId: "agent-1" }));
const res = await REQUEST(app, "POST", "/api/tasks/FN-001/pause", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(getTaskState().paused).toBe(true);
expect(store.pauseTask).toHaveBeenCalledWith("FN-001", true);
});
}); });

View File

@@ -2222,14 +2222,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
} }
}); });
/*
FNXC:TaskPauseControls 2026-06-21-00:00:
Agent-assigned tasks must remain manually recoverable from approval-gating and other pauses. The engine still owns automatic pauses recorded with pausedByAgentId, while pauseTask(id, false) clears pausedByAgentId and userPaused so a human unpause can resume dispatch.
*/
// Pause task // Pause task
router.post("/tasks/:id/pause", async (req, res) => { router.post("/tasks/:id/pause", async (req, res) => {
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id); await scopedStore.getTask(req.params.id);
if (task.assignedAgentId) {
throw conflict(`Cannot manually pause/unpause task assigned to agent ${task.assignedAgentId}. Use agent pause controls instead.`);
}
const updated = await scopedStore.pauseTask(req.params.id, true); const updated = await scopedStore.pauseTask(req.params.id, true);
res.json(updated); res.json(updated);
} catch (err: unknown) { } catch (err: unknown) {
@@ -2244,10 +2245,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.post("/tasks/:id/unpause", async (req, res) => { router.post("/tasks/:id/unpause", async (req, res) => {
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id); await scopedStore.getTask(req.params.id);
if (task.assignedAgentId) {
throw conflict(`Cannot manually pause/unpause task assigned to agent ${task.assignedAgentId}. Use agent pause controls instead.`);
}
const updated = await scopedStore.pauseTask(req.params.id, false); const updated = await scopedStore.pauseTask(req.params.id, false);
res.json(updated); res.json(updated);
} catch (err: unknown) { } catch (err: unknown) {