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:
5
.changeset/fn-6875-allow-unpause-assigned.md
Normal file
5
.changeset/fn-6875-allow-unpause-assigned.md
Normal 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.
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -4281,7 +4281,13 @@ export function TaskDetailContent({
|
||||
)}
|
||||
|
||||
{/* 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}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
@@ -4359,8 +4365,11 @@ export function TaskDetailContent({
|
||||
</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
|
||||
className="detail-actions-menu-item"
|
||||
role="menuitem"
|
||||
@@ -4369,7 +4378,7 @@ export function TaskDetailContent({
|
||||
{isTaskPaused ? t("taskDetail.pause.unpauseBtn", "Unpause") : t("taskDetail.pause.pauseBtn", "Pause")}
|
||||
</button>
|
||||
)}
|
||||
{task.column !== "done" && task.paused && task.pausedByAgentId && (
|
||||
{task.column !== "done" && task.column !== "archived" && task.paused && task.pausedByAgentId && (
|
||||
<span
|
||||
className="detail-actions-menu-item detail-actions-menu-note"
|
||||
role="note"
|
||||
|
||||
@@ -943,14 +943,16 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Pause/Unpause button for agent-assigned tasks", async () => {
|
||||
const { fetchAgent } = await import("../../api");
|
||||
it("renders actionable Unpause button for agent-assigned paused tasks", async () => {
|
||||
const { fetchAgent, unpauseTask } = await import("../../api");
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
const mockUnpauseTask = vi.mocked(unpauseTask);
|
||||
mockFetchAgent.mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
|
||||
mockUnpauseTask.mockClear();
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ column: "triage", paused: true, assignedAgentId: "agent-1" })}
|
||||
task={makeTask({ id: "FN-ASSIGNED", column: "triage", paused: true, assignedAgentId: "agent-1" })}
|
||||
initialTab="definition"
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
@@ -966,14 +968,15 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Unpause" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menuitem", { name: "Pause" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Unpause" })).toBeNull();
|
||||
expect(mockUnpauseTask).toHaveBeenCalledTimes(1);
|
||||
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 mockFetchAgent = vi.mocked(fetchAgent);
|
||||
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 }));
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Unpause" })).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", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -6,46 +6,83 @@ import type { TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.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 () => {
|
||||
let taskState = {
|
||||
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 { app, store, getTaskState } = createPauseRouteHarness(makeTaskState({ userPaused: true }));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/tasks/FN-001/unpause", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(taskState.userPaused).toBeUndefined();
|
||||
expect(taskState.userPaused === true).toBe(false);
|
||||
expect(getTaskState().userPaused).toBeUndefined();
|
||||
expect(getTaskState().userPaused === true).toBe(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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
router.post("/tasks/:id/pause", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const task = 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.`);
|
||||
}
|
||||
await scopedStore.getTask(req.params.id);
|
||||
const updated = await scopedStore.pauseTask(req.params.id, true);
|
||||
res.json(updated);
|
||||
} catch (err: unknown) {
|
||||
@@ -2244,10 +2245,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
router.post("/tasks/:id/unpause", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const task = 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.`);
|
||||
}
|
||||
await scopedStore.getTask(req.params.id);
|
||||
const updated = await scopedStore.pauseTask(req.params.id, false);
|
||||
res.json(updated);
|
||||
} catch (err: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user