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`
|
- Agent state transitions `running/active → paused → active`
|
||||||
- Orphan reconcile uses `3 × heartbeatTimeoutMs` where the timeout is likewise multiplier-scaled first
|
- 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
|
- `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`
|
- 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
|
- `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
|
- **Pre-merge** steps run in executor (`runWorkflowSteps()`) — bypassed in fast mode
|
||||||
- **Post-merge** steps run in merger (`runPostMergeWorkflowSteps()`)
|
- **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
|
### 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.
|
- `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.
|
- 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();
|
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 () => {
|
it("falls back to direct state update when monitor lacks lifecycle helpers", async () => {
|
||||||
const { createServer } = await import("../server.js");
|
const { createServer } = await import("../server.js");
|
||||||
app = createServer(store as any, {
|
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") {
|
if (nextState === "active") {
|
||||||
const pausedTasks = await scopedStore.getTasksByAssignedAgent(agentId, {
|
const pausedTasks = await scopedStore.getTasksByAssignedAgent(agentId, {
|
||||||
pausedOnly: true,
|
pausedOnly: true,
|
||||||
|
|||||||
@@ -571,6 +571,71 @@ describe("executeHeartbeat", () => {
|
|||||||
expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted");
|
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 () => {
|
it("pauseForApproval pauses task and agent when taskId exists", async () => {
|
||||||
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
||||||
const pauseTask = vi.fn().mockResolvedValue(undefined);
|
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 () => {
|
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 {
|
try {
|
||||||
writeFileSync(join(tmpRoot, "HEARTBEAT.md"), HEARTBEAT_PROCEDURE, "utf-8");
|
writeFileSync(join(tmpDir, "HEARTBEAT.md"), HEARTBEAT_PROCEDURE, "utf-8");
|
||||||
|
|
||||||
const store = createStoreWithAgentForExec({
|
const store = createStoreWithAgentForExec({
|
||||||
taskId: undefined,
|
taskId: undefined,
|
||||||
soul: "I am a coordinator",
|
soul: "I am a coordinator",
|
||||||
heartbeatProcedurePath: "HEARTBEAT.md",
|
heartbeatProcedurePath: `${tmpDir.split("/").pop()}/HEARTBEAT.md`,
|
||||||
});
|
});
|
||||||
const mockSession = createMockAgentSession();
|
const mockSession = createMockAgentSession();
|
||||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
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" });
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||||
|
|
||||||
expect(result.status).toBe("completed");
|
expect(result.status).toBe("completed");
|
||||||
@@ -1248,7 +1313,7 @@ describe("executeHeartbeat", () => {
|
|||||||
const savedRun = await store.getRunDetail("agent-001", result.id);
|
const savedRun = await store.getRunDetail("agent-001", result.id);
|
||||||
expect(savedRun?.heartbeatProcedureSource).toBe("default-no-task-override");
|
expect(savedRun?.heartbeatProcedureSource).toBe("default-no-task-override");
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(tmpRoot, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -158,10 +158,9 @@ export interface PauseAgentOptions {
|
|||||||
pauseReason?: string;
|
pauseReason?: string;
|
||||||
stopActiveRun?: boolean;
|
stopActiveRun?: boolean;
|
||||||
/**
|
/**
|
||||||
* When true (default), assigned tasks are also paused with `pausedByAgentId`
|
* Deprecated/ignored for pause: pausing or sleeping an agent never pauses
|
||||||
* set to this agent. Set to false for internal/recovery flows that should
|
* assigned tasks. Tasks remain in their current column so the scheduler can
|
||||||
* not visibly pause user-facing tasks (e.g. heartbeat-unresponsive recovery,
|
* re-dispatch them.
|
||||||
* which immediately calls resumeAgent afterward).
|
|
||||||
*/
|
*/
|
||||||
cascadeToTasks?: boolean;
|
cascadeToTasks?: boolean;
|
||||||
}
|
}
|
||||||
@@ -170,7 +169,10 @@ export interface ResumeAgentOptions {
|
|||||||
triggerDetail?: string;
|
triggerDetail?: string;
|
||||||
triggerSource?: string;
|
triggerSource?: string;
|
||||||
clearPauseReason?: boolean;
|
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;
|
cascadeToTasks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1611,7 +1613,7 @@ export class HeartbeatMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async pauseAgent(agentId: string, options: PauseAgentOptions = {}): Promise<Agent> {
|
async pauseAgent(agentId: string, options: PauseAgentOptions = {}): Promise<Agent> {
|
||||||
const { pauseReason, stopActiveRun = false, cascadeToTasks = true } = options;
|
const { pauseReason, stopActiveRun = false } = options;
|
||||||
|
|
||||||
if (stopActiveRun) {
|
if (stopActiveRun) {
|
||||||
try {
|
try {
|
||||||
@@ -1635,19 +1637,6 @@ export class HeartbeatMonitor {
|
|||||||
updated = await this.store.updateAgent(agentId, { pauseReason });
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,7 +1645,7 @@ export class HeartbeatMonitor {
|
|||||||
triggerDetail = "Triggered from state resume",
|
triggerDetail = "Triggered from state resume",
|
||||||
triggerSource = "state-resume",
|
triggerSource = "state-resume",
|
||||||
clearPauseReason = true,
|
clearPauseReason = true,
|
||||||
cascadeToTasks = true,
|
cascadeToTasks = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const current = await this.store.getAgent(agentId);
|
const current = await this.store.getAgent(agentId);
|
||||||
|
|||||||
Reference in New Issue
Block a user