fix: decouple stuck detector timeout

This commit is contained in:
Berlin Luk
2026-05-11 00:11:23 +08:00
parent 4205309114
commit 572e7a8f69
6 changed files with 23 additions and 19 deletions

View File

@@ -2,4 +2,4 @@
"@runfusion/fusion": patch
---
Keep stuck task detection active by falling back to the workflow step timeout when no explicit stuck timeout is configured.
Keep stuck task detection active by default with an explicit task-stuck timeout default, without coupling it to workflow step timeout settings.

View File

@@ -69,6 +69,11 @@ describe("settings key parity", () => {
expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off");
});
it("keeps task stuck timeout active by default without coupling to workflow step timeout", () => {
expect(DEFAULT_PROJECT_SETTINGS.taskStuckTimeoutMs).toBe(600_000);
expect(DEFAULT_PROJECT_SETTINGS.workflowStepTimeoutMs).toBe(360_000);
});
it("keeps github tracking keys in expected scopes with documented defaults", () => {
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingEnabledByDefault).toBe(false);
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingDefaultRepo).toBeUndefined();

View File

@@ -214,7 +214,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
requirePlanApproval: false,
specStalenessEnabled: false,
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
taskStuckTimeoutMs: undefined,
taskStuckTimeoutMs: 600_000,
aiSessionTtlMs: 7 * 24 * 60 * 60 * 1000,
aiSessionCleanupIntervalMs: 60 * 60 * 1000,
autoUnpauseEnabled: true,

View File

@@ -2180,7 +2180,7 @@ export interface ProjectSettings {
/** Timeout in milliseconds for detecting stuck tasks. When a task's agent session
* shows no activity (no text deltas, tool calls, or progress updates) for longer
* than this duration, the task is considered stuck and will be terminated and retried.
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
* Default: 600000 (10 minutes). Set to 0 to disable. */
taskStuckTimeoutMs?: number;
/** TTL in milliseconds for persisted AI planning/subtask/mission interview sessions.
* Sessions older than this cutoff are expired by the dashboard session cleanup loop.

View File

@@ -718,7 +718,7 @@ describe("StuckTaskDetector", () => {
vi.useRealTimers();
});
it("falls back to workflow step timeout when stuck timeout is unset", async () => {
it("does not couple stuck detection to workflow step timeout", async () => {
store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
taskStuckTimeoutMs: undefined,
@@ -736,19 +736,17 @@ describe("StuckTaskDetector", () => {
await customDetector.checkNow();
expect(onStuck).toHaveBeenCalledWith(
expect.objectContaining({ taskId: "FN-001", reason: "inactivity" }),
);
expect(session.dispose).toHaveBeenCalled();
expect(onStuck).not.toHaveBeenCalled();
expect(session.dispose).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("does nothing when both stuck and workflow timeouts are disabled", async () => {
it("kills stuck sessions when the project default stuck timeout is present", async () => {
store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
taskStuckTimeoutMs: undefined,
workflowStepTimeoutMs: undefined,
taskStuckTimeoutMs: 600_000,
workflowStepTimeoutMs: 60_000,
}),
});
const onStuck = vi.fn();
@@ -758,12 +756,14 @@ describe("StuckTaskDetector", () => {
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61_000);
vi.advanceTimersByTime(601_000);
await customDetector.checkNow();
expect(onStuck).not.toHaveBeenCalled();
expect(session.dispose).not.toHaveBeenCalled();
expect(onStuck).toHaveBeenCalledWith(
expect.objectContaining({ taskId: "FN-001", reason: "inactivity" }),
);
expect(session.dispose).toHaveBeenCalled();
vi.useRealTimers();
});

View File

@@ -11,9 +11,8 @@
* - `recordProgress(taskId)` — step transitions (in-progress, done, skipped); resets counters
*
* The detector polls at a configurable interval and compares timestamps against
* `taskStuckTimeoutMs` from settings. When that explicit override is unset, the
* detector falls back to `workflowStepTimeoutMs` so in-flight tool calls cannot
* leave an in-progress task unmonitored by default.
* `taskStuckTimeoutMs` from settings. Project defaults keep this active by
* default while keeping workflow-step execution timeouts independent.
*/
import type { TaskStore, Settings } from "@fusion/core";
@@ -472,8 +471,8 @@ export class StuckTaskDetector {
// Defensive fallback for pause windows where lifecycle hooks haven't run yet.
if (settings.globalPause || settings.enginePaused) return;
const timeoutMs = settings.taskStuckTimeoutMs ?? settings.workflowStepTimeoutMs;
if (!timeoutMs || timeoutMs <= 0) return; // Disabled only when both stuck and workflow timeouts are unset/disabled
const timeoutMs = settings.taskStuckTimeoutMs;
if (!timeoutMs || timeoutMs <= 0) return; // Disabled when task stuck timeout is explicitly unset/disabled
const stuckTasks: string[] = [];