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:
16
.changeset/task-pause-no-cascade-on-recovery.md
Normal file
16
.changeset/task-pause-no-cascade-on-recovery.md
Normal 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.
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user