diff --git a/.changeset/fn-6252-no-agent-task-autopause.md b/.changeset/fn-6252-no-agent-task-autopause.md new file mode 100644 index 0000000000..80a807b00a --- /dev/null +++ b/.changeset/fn-6252-no-agent-task-autopause.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Pausing or sleeping an agent no longer pauses its assigned tasks. Assigned tasks now keep their existing pause state so only explicit user actions pause ordinary task work. diff --git a/docs/agents.md b/docs/agents.md index 6eefc41e3b..40f133dbe0 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1222,7 +1222,7 @@ Effects: - Agent state transitions `running/active → paused → active` - Orphan reconcile uses `3 × heartbeatTimeoutMs` where the timeout is likewise multiplier-scaled first - `pauseReason` is set to `heartbeat-unresponsive` during recovery and cleared on resume -- Assigned tasks are auto-paused with `pausedByAgentId` during pause, then only those same tasks are auto-unpaused on resume +- Assigned tasks are not paused or unpaused by agent sleep/heartbeat recovery; unpaused work stays eligible for scheduler re-dispatch, while tasks already paused by a user retain their existing pause state - Resume triggers one on-demand heartbeat restart only when `runtimeConfig.enabled !== false` - `onTerminated` is a run-level callback for terminated heartbeat runs and is not used by unresponsive recovery diff --git a/docs/architecture.md b/docs/architecture.md index 67e475b3e2..71ac7cf571 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1214,6 +1214,10 @@ Task steps use statuses: `pending`, `in-progress`, `done`, `skipped`. - **Pre-merge** steps run in executor (`runWorkflowSteps()`) — bypassed in fast mode - **Post-merge** steps run in merger (`runPostMergeWorkflowSteps()`) +### Task pause ownership +- Only explicit user actions pause ordinary tasks: the dashboard/CLI task pause controls and manual `in-progress → todo` moves. System safety pauses remain reserved for explicit approval waits and bounded guardrails such as token-budget, worktrunk-failure, and dispatch-oscillation protection. +- Agent pause/sleep and heartbeat recovery never pause assigned tasks. Assigned tasks stay in their current column and retain their existing `paused`/`pausedByAgentId` state so the scheduler can re-dispatch unpaused work and user-paused work remains intentionally parked. + ### User cancel via move-to-todo - `TaskStore.moveTask()` accepts `moveSource: "user" | "engine"` (default `"engine"`) and emits `task:moved` with `source` so listeners can distinguish manual moves from engine rebounds. - Manual `in-progress → todo` moves (dashboard route `/tasks/:id/move` with `moveSource: "user"`) atomically set `task.userPaused = true`; engine/default rebounds do not. diff --git a/packages/dashboard/src/__tests__/routes-agent-runs.test.ts b/packages/dashboard/src/__tests__/routes-agent-runs.test.ts index 453a14358c..7c0f21167d 100644 --- a/packages/dashboard/src/__tests__/routes-agent-runs.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-runs.test.ts @@ -537,6 +537,38 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => { }); expect(mockExecuteHeartbeat).not.toHaveBeenCalled(); }); + it("fallback pause updates only agent state and does not auto-pause assigned tasks", async () => { + const { createServer } = await import("../server.js"); + app = createServer(store as any, { + heartbeatMonitor: { + executeHeartbeat: mockExecuteHeartbeat, + stopRun: mockStopRun, + }, + }); + (store.getTasksByAssignedAgent as ReturnType).mockResolvedValueOnce([ + { id: "FN-1", paused: false }, + { id: "FN-2", paused: undefined }, + ]); + mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "paused" }); + + const response = await request( + app, + "POST", + "/api/agents/agent-001/state", + JSON.stringify({ state: "paused" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ id: "agent-001", state: "paused" }); + await vi.waitFor(() => { + expect(mockGetActiveHeartbeatRun).toHaveBeenCalledWith("agent-001"); + }); + expect(store.getTasksByAssignedAgent).not.toHaveBeenCalled(); + expect(store.pauseTask).not.toHaveBeenCalledWith(expect.any(String), true, expect.anything(), expect.anything()); + expect(store.pauseTask).not.toHaveBeenCalled(); + }); + it("falls back to direct state update when monitor lacks lifecycle helpers", async () => { const { createServer } = await import("../server.js"); app = createServer(store as any, { diff --git a/packages/dashboard/src/routes/register-agent-runtime-routes.ts b/packages/dashboard/src/routes/register-agent-runtime-routes.ts index 1d32fe7dce..3e5f73766e 100644 --- a/packages/dashboard/src/routes/register-agent-runtime-routes.ts +++ b/packages/dashboard/src/routes/register-agent-runtime-routes.ts @@ -465,23 +465,6 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun } } - if (nextState === "paused") { - const assignedTasks = await scopedStore.getTasksByAssignedAgent(agentId, { excludeArchived: true }); - const toPause = assignedTasks.filter((task) => task.paused !== true); - const results = await Promise.allSettled( - toPause.map((task) => scopedStore.pauseTask(task.id, true, undefined, { pausedByAgentId: agentId })), - ); - results.forEach((result, index) => { - if (result.status === "rejected") { - runtimeLogger.child("agent-state").warn("Failed to auto-pause assigned task", { - agentId, - taskId: toPause[index]?.id, - error: String(result.reason), - }); - } - }); - } - if (nextState === "active") { const pausedTasks = await scopedStore.getTasksByAssignedAgent(agentId, { pausedOnly: true, diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 938c16c790..6ffd5c2ca1 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -571,6 +571,71 @@ describe("executeHeartbeat", () => { expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted"); }); + describe("agent pause does not pause assigned tasks", () => { + it("pauseAgent leaves zero, one, and many assigned tasks untouched", async () => { + for (const assignedTasks of [ + [], + [{ id: "FN-001", paused: undefined, pausedByAgentId: undefined }], + [ + { id: "FN-001", paused: undefined, pausedByAgentId: undefined }, + { id: "FN-002", paused: false, pausedByAgentId: undefined }, + { id: "FN-003", paused: true, userPaused: true, pausedByAgentId: undefined }, + ], + ]) { + const pauseTask = vi.fn().mockResolvedValue(undefined); + const getTasksByAssignedAgent = vi.fn().mockResolvedValue(assignedTasks); + mockTaskStore = createMockTaskStore({ pauseTask, getTasksByAssignedAgent }); + const store = createStoreWithAgentForExec({ taskId: assignedTasks[0]?.id }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + const before = structuredClone(assignedTasks); + + await monitor.pauseAgent("agent-001"); + + expect(pauseTask).not.toHaveBeenCalledWith(expect.any(String), true, expect.anything(), expect.anything()); + expect(pauseTask).not.toHaveBeenCalled(); + expect(getTasksByAssignedAgent).not.toHaveBeenCalled(); + expect(assignedTasks).toEqual(before); + } + }); + + it("reproduces agent sleep symptom and keeps assigned task pause fields unchanged", async () => { + const assignedTask = { + id: "FN-001", + column: "todo", + paused: undefined, + pausedByAgentId: undefined, + }; + const pauseTask = vi.fn().mockResolvedValue(undefined); + mockTaskStore = createMockTaskStore({ + pauseTask, + getTasksByAssignedAgent: vi.fn().mockResolvedValue([assignedTask]), + }); + const store = createStoreWithAgentForExec({ taskId: "FN-001" }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + + await monitor.pauseAgent("agent-001"); + + expect(pauseTask).not.toHaveBeenCalled(); + expect(assignedTask.paused).toBeUndefined(); + expect(assignedTask.pausedByAgentId).toBeUndefined(); + expect(assignedTask.column).toBe("todo"); + }); + + it("executeHeartbeat does not pause its assigned task", async () => { + const pauseTask = vi.fn().mockResolvedValue(undefined); + mockTaskStore = createMockTaskStore({ pauseTask }); + const store = createStoreWithAgentForExec({ taskId: "FN-001" }); + const mockSession = createMockAgentSession(); + mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + + await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }); + + expect(pauseTask).not.toHaveBeenCalledWith(expect.any(String), true, expect.anything(), expect.anything()); + expect(pauseTask).not.toHaveBeenCalled(); + }); + }); + it("pauseForApproval pauses task and agent when taskId exists", async () => { const store = createStoreWithAgentForExec({ taskId: "FN-001" }); const pauseTask = vi.fn().mockResolvedValue(undefined); @@ -1222,19 +1287,19 @@ describe("executeHeartbeat", () => { }); it("no-task run overrides a seeded task-scoped heartbeatProcedurePath in the assembled prompt", async () => { - const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-no-task-procedure-")); + const tmpDir = mkdtempSync(join(process.cwd(), ".tmp-fn-hb-no-task-procedure-")); try { - writeFileSync(join(tmpRoot, "HEARTBEAT.md"), HEARTBEAT_PROCEDURE, "utf-8"); + writeFileSync(join(tmpDir, "HEARTBEAT.md"), HEARTBEAT_PROCEDURE, "utf-8"); const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator", - heartbeatProcedurePath: "HEARTBEAT.md", + heartbeatProcedurePath: `${tmpDir.split("/").pop()}/HEARTBEAT.md`, }); const mockSession = createMockAgentSession(); mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any }); - const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: process.cwd() }); const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }); expect(result.status).toBe("completed"); @@ -1248,7 +1313,7 @@ describe("executeHeartbeat", () => { const savedRun = await store.getRunDetail("agent-001", result.id); expect(savedRun?.heartbeatProcedureSource).toBe("default-no-task-override"); } finally { - rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); } }); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 69657b35c2..8184148e66 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -158,10 +158,9 @@ export interface PauseAgentOptions { pauseReason?: string; stopActiveRun?: boolean; /** - * When true (default), assigned tasks are also paused with `pausedByAgentId` - * set to this agent. Set to false for internal/recovery flows that should - * not visibly pause user-facing tasks (e.g. heartbeat-unresponsive recovery, - * which immediately calls resumeAgent afterward). + * Deprecated/ignored for pause: pausing or sleeping an agent never pauses + * assigned tasks. Tasks remain in their current column so the scheduler can + * re-dispatch them. */ cascadeToTasks?: boolean; } @@ -170,7 +169,10 @@ export interface ResumeAgentOptions { triggerDetail?: string; triggerSource?: string; clearPauseReason?: boolean; - /** When true (default), unpauses tasks paused by this agent. */ + /** + * When true, unpauses tasks paused by this agent. Defaults to false; this is + * legacy cleanup only and correctness must not depend on cascade-unpause. + */ cascadeToTasks?: boolean; } @@ -1611,7 +1613,7 @@ export class HeartbeatMonitor { } async pauseAgent(agentId: string, options: PauseAgentOptions = {}): Promise { - const { pauseReason, stopActiveRun = false, cascadeToTasks = true } = options; + const { pauseReason, stopActiveRun = false } = options; if (stopActiveRun) { try { @@ -1635,19 +1637,6 @@ export class HeartbeatMonitor { updated = await this.store.updateAgent(agentId, { pauseReason }); } - if (this.taskStore && cascadeToTasks) { - const assignedTasks = await this.taskStore.getTasksByAssignedAgent(agentId, { excludeArchived: true }); - const toPause = assignedTasks.filter((task) => task.paused !== true); - const results = await Promise.allSettled( - toPause.map((task) => this.taskStore!.pauseTask(task.id, true, undefined, { pausedByAgentId: agentId })), - ); - results.forEach((result, index) => { - if (result.status === "rejected") { - heartbeatLog.warn(`pauseAgent(${agentId}) failed to pause assigned task ${toPause[index]?.id}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`); - } - }); - } - return updated; } @@ -1656,7 +1645,7 @@ export class HeartbeatMonitor { triggerDetail = "Triggered from state resume", triggerSource = "state-resume", clearPauseReason = true, - cascadeToTasks = true, + cascadeToTasks = false, } = options; const current = await this.store.getAgent(agentId);