FN-6252: stop agent pauses from pausing tasks
Agent pause and sleep flows now leave task pause state under explicit task controls.\n\n- Remove automatic task pausing from heartbeat pauseAgent and dashboard fallback state changes.\n- Default resume task cascade to off while retaining legacy opt-in unpause cleanup.\n- Cover agent sleep, heartbeat execution, and dashboard fallback pause behavior with regression tests.\n- Document task pause ownership and add a patch changeset.\n\nFiles changed:\n .changeset/fn-6252-no-agent-task-autopause.md | 5 ++\n docs/agents.md | 2 +-\n docs/architecture.md | 4 ++\n .../src/__tests__/routes-agent-runs.test.ts | 32 +++++++++\n .../src/routes/register-agent-runtime-routes.ts | 17 -----\n .../src/__tests__/heartbeat-executor.test.ts | 75 ++++++++++++++++++++--\n packages/engine/src/agent-heartbeat.ts | 29 +++------\n 7 files changed, 121 insertions(+), 43 deletions(-) Fusion-Task-Id: FN-6252 Fusion-Task-Lineage: f7a0ef30-0c99-4f9c-b0c1-43217d613187
This commit is contained in:
5
.changeset/fn-6252-no-agent-task-autopause.md
Normal file
5
.changeset/fn-6252-no-agent-task-autopause.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof vi.fn>).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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Agent> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user