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

@@ -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[] = [];