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 4556df5954
commit 7f90308485
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);
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", () => {

View File

@@ -122,12 +122,21 @@ export interface HeartbeatExecutionOptions {
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).
*/
cascadeToTasks?: boolean;
}
export interface ResumeAgentOptions {
triggerDetail?: string;
triggerSource?: string;
clearPauseReason?: boolean;
/** When true (default), unpauses tasks paused by this agent. */
cascadeToTasks?: boolean;
}
/** Session interface for disposing agent resources */
@@ -979,7 +988,7 @@ export class HeartbeatMonitor {
}
async pauseAgent(agentId: string, options: PauseAgentOptions = {}): Promise<Agent> {
const { pauseReason, stopActiveRun = false } = options;
const { pauseReason, stopActiveRun = false, cascadeToTasks = true } = options;
if (stopActiveRun) {
try {
@@ -1003,7 +1012,7 @@ export class HeartbeatMonitor {
updated = await this.store.updateAgent(agentId, { pauseReason });
}
if (this.taskStore) {
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(
@@ -1024,6 +1033,7 @@ export class HeartbeatMonitor {
triggerDetail = "Triggered from state resume",
triggerSource = "state-resume",
clearPauseReason = true,
cascadeToTasks = true,
} = options;
const current = await this.store.getAgent(agentId);
@@ -1040,7 +1050,7 @@ export class HeartbeatMonitor {
updated = await this.store.updateAgent(agentId, { pauseReason: undefined });
}
if (this.taskStore) {
if (this.taskStore && cascadeToTasks) {
const pausedTasks = await this.taskStore.getTasksByAssignedAgent(agentId, {
pausedOnly: true,
excludeArchived: true,
@@ -2428,8 +2438,10 @@ export class HeartbeatMonitor {
// Canonically end the run record. Without this, dispose() relies on the
// in-flight execution self-completing — which never happens when the run
// is actually hung. completeRun also updates agent state, but we still
// call pauseAgent below to set `pauseReason="heartbeat-unresponsive"`
// and pause assigned tasks. The double state transition is harmless.
// call pauseAgent below to set `pauseReason="heartbeat-unresponsive"`.
// 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 {
await this.completeRun(tracked.agentId, runIdToTerminate, {
status: "terminated",
@@ -2440,7 +2452,11 @@ export class HeartbeatMonitor {
}
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) {
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",
triggerSource: "heartbeat-unresponsive",
clearPauseReason: true,
cascadeToTasks: false,
});
} catch (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 { homedir, tmpdir } from "node:os";
import { join, resolve, sep } from "node:path";
import { spawnSync } from "node:child_process";
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() {
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 = {
tmpNames: snapshotTmp().map((e) => e.name),
protectedFusion: snapshotProtectedFusion(),
protectedFusion: secondProtected,
unstableProtectedDirs,
};
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).`);
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() {
@@ -110,9 +134,11 @@ function checkAgainstBaseline() {
const leaks = snapshotTmp().filter((e) => !baselineNames.has(e.name));
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);
const currentProtected = snapshotProtectedFusion();
const protectedViolations = [];
for (const current of currentProtected) {
if (unstableProtectedDirs.has(current.dir)) continue;
const base = baselineByDir.get(current.dir) ?? { exists: false, entries: [] };
const changedExistence = Boolean(base.exists) !== Boolean(current.exists);
const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries);

View File

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