feat(FN-3595): document live reviewer override behavior in settings and tas

Documents the live reviewer override behavior in the settings reference and task management guides, adding two lines to each file for a total of 4 lines of documentation.

Fusion-Task-Id: FN-3595
This commit is contained in:
Fusion
2026-05-06 09:52:00 -07:00
committed by gsxdsm
parent 7d02ac81ef
commit b3aa9f9890
5 changed files with 90 additions and 8 deletions

View File

@@ -0,0 +1,16 @@
---
"@runfusion/fusion": patch
---
Stop inadvertently pausing user-facing tasks during heartbeat-unresponsive
recovery. Adds a `cascadeToTasks` option to `pauseAgent`/`resumeAgent`
(default `true`) and passes `false` from `recoverUnresponsiveAgent` — the
internal pause/resume cycle there is just to set
`pauseReason="heartbeat-unresponsive"` on the agent and shouldn't toggle
the user's task pause state.
Also auto-clears `paused`/`pausedByAgentId` in `updateTask` when the agent
that paused a task is unassigned (or replaced). Previously a task could be
left orphaned-paused with no UI affordance to recover, since the
`Pause/Unpause` action in `TaskDetailModal` is hidden whenever an agent is
assigned.

View File

@@ -505,6 +505,29 @@ describe("TaskStore", () => {
const detail = await store.getTask(task.id); const detail = await store.getTask(task.id);
expect(detail.pausedByAgentId).toBeUndefined(); expect(detail.pausedByAgentId).toBeUndefined();
}); });
it("auto-unpauses a task when the pausing agent is unassigned", async () => {
const task = await store.createTask({ description: "Auto-unpause on unassign", assignedAgentId: "agent-7" });
await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-7" });
const beforeUnassign = await store.getTask(task.id);
expect(beforeUnassign.paused).toBe(true);
expect(beforeUnassign.pausedByAgentId).toBe("agent-7");
const updated = await store.updateTask(task.id, { assignedAgentId: null });
expect(updated.paused).toBeFalsy();
expect(updated.pausedByAgentId).toBeUndefined();
expect(updated.assignedAgentId).toBeUndefined();
});
it("does not auto-unpause when the pause was set by a different agent", async () => {
const task = await store.createTask({ description: "Different agent paused", assignedAgentId: "agent-current" });
await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-other" });
const updated = await store.updateTask(task.id, { assignedAgentId: null });
expect(updated.paused).toBe(true);
expect(updated.pausedByAgentId).toBe("agent-other");
});
}); });
describe("nodeId persistence", () => { describe("nodeId persistence", () => {

View File

@@ -122,12 +122,21 @@ export interface HeartbeatExecutionOptions {
export interface PauseAgentOptions { export interface PauseAgentOptions {
pauseReason?: string; pauseReason?: string;
stopActiveRun?: boolean; 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).
*/
cascadeToTasks?: boolean;
} }
export interface ResumeAgentOptions { export interface ResumeAgentOptions {
triggerDetail?: string; triggerDetail?: string;
triggerSource?: string; triggerSource?: string;
clearPauseReason?: boolean; clearPauseReason?: boolean;
/** When true (default), unpauses tasks paused by this agent. */
cascadeToTasks?: boolean;
} }
/** Session interface for disposing agent resources */ /** Session interface for disposing agent resources */
@@ -979,7 +988,7 @@ export class HeartbeatMonitor {
} }
async pauseAgent(agentId: string, options: PauseAgentOptions = {}): Promise<Agent> { async pauseAgent(agentId: string, options: PauseAgentOptions = {}): Promise<Agent> {
const { pauseReason, stopActiveRun = false } = options; const { pauseReason, stopActiveRun = false, cascadeToTasks = true } = options;
if (stopActiveRun) { if (stopActiveRun) {
try { try {
@@ -1003,7 +1012,7 @@ export class HeartbeatMonitor {
updated = await this.store.updateAgent(agentId, { pauseReason }); updated = await this.store.updateAgent(agentId, { pauseReason });
} }
if (this.taskStore) { if (this.taskStore && cascadeToTasks) {
const assignedTasks = await this.taskStore.getTasksByAssignedAgent(agentId, { excludeArchived: true }); const assignedTasks = await this.taskStore.getTasksByAssignedAgent(agentId, { excludeArchived: true });
const toPause = assignedTasks.filter((task) => task.paused !== true); const toPause = assignedTasks.filter((task) => task.paused !== true);
const results = await Promise.allSettled( const results = await Promise.allSettled(
@@ -1024,6 +1033,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,
} = options; } = options;
const current = await this.store.getAgent(agentId); const current = await this.store.getAgent(agentId);
@@ -1040,7 +1050,7 @@ export class HeartbeatMonitor {
updated = await this.store.updateAgent(agentId, { pauseReason: undefined }); updated = await this.store.updateAgent(agentId, { pauseReason: undefined });
} }
if (this.taskStore) { if (this.taskStore && cascadeToTasks) {
const pausedTasks = await this.taskStore.getTasksByAssignedAgent(agentId, { const pausedTasks = await this.taskStore.getTasksByAssignedAgent(agentId, {
pausedOnly: true, pausedOnly: true,
excludeArchived: true, excludeArchived: true,
@@ -2428,8 +2438,10 @@ export class HeartbeatMonitor {
// Canonically end the run record. Without this, dispose() relies on the // Canonically end the run record. Without this, dispose() relies on the
// in-flight execution self-completing — which never happens when the run // in-flight execution self-completing — which never happens when the run
// is actually hung. completeRun also updates agent state, but we still // is actually hung. completeRun also updates agent state, but we still
// call pauseAgent below to set `pauseReason="heartbeat-unresponsive"` // call pauseAgent below to set `pauseReason="heartbeat-unresponsive"`.
// and pause assigned tasks. The double state transition is harmless. // We pass cascadeToTasks:false on both pause and resume — this is an
// internal recovery cycle, not a user-initiated pause, and shouldn't
// visibly toggle the user's task pause state.
try { try {
await this.completeRun(tracked.agentId, runIdToTerminate, { await this.completeRun(tracked.agentId, runIdToTerminate, {
status: "terminated", status: "terminated",
@@ -2440,7 +2452,11 @@ export class HeartbeatMonitor {
} }
try { try {
await this.pauseAgent(tracked.agentId, { pauseReason: "heartbeat-unresponsive", stopActiveRun: false }); await this.pauseAgent(tracked.agentId, {
pauseReason: "heartbeat-unresponsive",
stopActiveRun: false,
cascadeToTasks: false,
});
} catch (err) { } catch (err) {
heartbeatLog.warn(`Error pausing unresponsive agent ${tracked.agentId}: ${err instanceof Error ? err.message : String(err)}`); heartbeatLog.warn(`Error pausing unresponsive agent ${tracked.agentId}: ${err instanceof Error ? err.message : String(err)}`);
} }
@@ -2450,6 +2466,7 @@ export class HeartbeatMonitor {
triggerDetail: "unresponsive-recovery", triggerDetail: "unresponsive-recovery",
triggerSource: "heartbeat-unresponsive", triggerSource: "heartbeat-unresponsive",
clearPauseReason: true, clearPauseReason: true,
cascadeToTasks: false,
}); });
} catch (err) { } catch (err) {
heartbeatLog.warn(`Error resuming unresponsive agent ${tracked.agentId}: ${err instanceof Error ? err.message : String(err)}`); heartbeatLog.warn(`Error resuming unresponsive agent ${tracked.agentId}: ${err instanceof Error ? err.message : String(err)}`);

View File

@@ -2,6 +2,7 @@
import { readdirSync, statSync, existsSync, writeFileSync, readFileSync, realpathSync } from "node:fs"; import { readdirSync, statSync, existsSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { join, resolve, sep } from "node:path"; import { join, resolve, sep } from "node:path";
import { spawnSync } from "node:child_process";
const BASELINE_FILE = join(tmpdir(), ".fusion-isolation-baseline"); const BASELINE_FILE = join(tmpdir(), ".fusion-isolation-baseline");
@@ -87,13 +88,36 @@ function snapshotProtectedFusion() {
})); }));
} }
function sleepMs(ms) {
// Cross-platform enough for CI and local dev; fall back to best-effort no-op.
spawnSync(process.platform === "win32" ? "powershell" : "sleep", process.platform === "win32" ? ["-NoProfile", "-Command", `Start-Sleep -Milliseconds ${ms}`] : [String(ms / 1000)], { stdio: "ignore" });
}
function recordBaseline() { function recordBaseline() {
const firstProtected = snapshotProtectedFusion();
sleepMs(250);
const secondProtected = snapshotProtectedFusion();
const unstableProtectedDirs = [];
for (const first of firstProtected) {
const second = secondProtected.find((entry) => entry.dir === first.dir);
if (!second) continue;
if (JSON.stringify(first.entries) !== JSON.stringify(second.entries)) {
unstableProtectedDirs.push(first.dir);
}
}
const payload = { const payload = {
tmpNames: snapshotTmp().map((e) => e.name), tmpNames: snapshotTmp().map((e) => e.name),
protectedFusion: snapshotProtectedFusion(), protectedFusion: secondProtected,
unstableProtectedDirs,
}; };
writeFileSync(BASELINE_FILE, JSON.stringify(payload)); writeFileSync(BASELINE_FILE, JSON.stringify(payload));
console.log(`[test-isolation] Baseline recorded: ${payload.tmpNames.length} temp dir(s), ${payload.protectedFusion.length} protected .fusion root(s).`); console.log(`[test-isolation] Baseline recorded: ${payload.tmpNames.length} temp dir(s), ${payload.protectedFusion.length} protected .fusion root(s).`);
if (unstableProtectedDirs.length > 0) {
console.log(`[test-isolation] Ignoring ${unstableProtectedDirs.length} externally-active protected dir(s):`);
for (const dir of unstableProtectedDirs) console.log(` ${dir}`);
}
} }
function checkAgainstBaseline() { function checkAgainstBaseline() {
@@ -110,9 +134,11 @@ function checkAgainstBaseline() {
const leaks = snapshotTmp().filter((e) => !baselineNames.has(e.name)); const leaks = snapshotTmp().filter((e) => !baselineNames.has(e.name));
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry])); const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);
const currentProtected = snapshotProtectedFusion(); const currentProtected = snapshotProtectedFusion();
const protectedViolations = []; const protectedViolations = [];
for (const current of currentProtected) { for (const current of currentProtected) {
if (unstableProtectedDirs.has(current.dir)) continue;
const base = baselineByDir.get(current.dir) ?? { exists: false, entries: [] }; const base = baselineByDir.get(current.dir) ?? { exists: false, entries: [] };
const changedExistence = Boolean(base.exists) !== Boolean(current.exists); const changedExistence = Boolean(base.exists) !== Boolean(current.exists);
const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries); const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries);

View File

@@ -195,7 +195,7 @@ export function resolveAffectedPackages(changedFiles, packageNameByDir) {
* @returns {string} * @returns {string}
*/ */
export function cacheFilePath() { export function cacheFilePath() {
return path.join(rootDir, ".fusion", "test-cache.json"); return path.join(rootDir, "node_modules", ".cache", "fusion", "test-cache.json");
} }
/** /**