feat(FN-822): unify stuck-task detection with configurable project timeout

- Extract isTaskStuck and countStuckTasks into shared utils/taskStuck.ts utility
- Replace hardcoded 10-minute threshold with project taskStuckTimeoutMs setting
- Thread taskStuckTimeoutMs from App settings fetch through ExecutorStatusBar to useExecutorStats
- Return 0 stuck tasks when timeout is undefined/0 (disabled), matching engine behavior
- Update tests to verify disabled state, custom thresholds, and zero threshold edge case
This commit is contained in:
gsxdsm
2026-04-04 00:35:21 -07:00
parent cf83579a34
commit 38a095cc25
6 changed files with 119 additions and 32 deletions

View File

@@ -164,7 +164,7 @@ describe("useExecutorStats", () => {
});
describe("stuck task detection", () => {
it("detects tasks in in-progress with no activity for > 10 minutes as stuck", async () => {
it("detects tasks in in-progress with no activity beyond threshold as stuck", async () => {
// Set updatedAt to 11 minutes ago
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const tasks: Task[] = [
@@ -172,7 +172,8 @@ describe("useExecutorStats", () => {
{ ...createMockTask("FN-002", "in-progress") }, // just updated
];
const { result } = renderHook(() => useExecutorStats(tasks));
// Pass 10-minute (600000ms) threshold
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 600000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
@@ -181,13 +182,13 @@ describe("useExecutorStats", () => {
expect(result.current.stats.stuckTaskCount).toBe(1);
});
it("does not count non-in-progress tasks as stuck even if old", async () => {
// Set updatedAt to 11 minutes ago for a todo task
it("returns 0 stuck tasks when taskStuckTimeoutMs is undefined (disabled)", async () => {
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "todo"), updatedAt: elevenMinutesAgo },
{ ...createMockTask("FN-001", "in-progress"), updatedAt: elevenMinutesAgo },
];
// No threshold = stuck detection disabled
const { result } = renderHook(() => useExecutorStats(tasks));
await act(async () => {
@@ -197,14 +198,62 @@ describe("useExecutorStats", () => {
expect(result.current.stats.stuckTaskCount).toBe(0);
});
it("does not count non-in-progress tasks as stuck even if old", async () => {
// Set updatedAt to 11 minutes ago for a todo task
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "todo"), updatedAt: elevenMinutesAgo },
];
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 600000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(0);
});
it("does not count recent in-progress tasks as stuck", async () => {
// Set updatedAt to 5 minutes ago
// Set updatedAt to 5 minutes ago — below the 10-minute threshold
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "in-progress"), updatedAt: fiveMinutesAgo },
];
const { result } = renderHook(() => useExecutorStats(tasks));
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 600000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(0);
});
it("respects custom threshold values", async () => {
// Set updatedAt to 3 minutes ago
const threeMinutesAgo = new Date(Date.now() - 3 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "in-progress"), updatedAt: threeMinutesAgo },
];
// With a 2-minute threshold, it should be stuck
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 120000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(1);
});
it("returns 0 when taskStuckTimeoutMs is 0", async () => {
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "in-progress"), updatedAt: elevenMinutesAgo },
];
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 0));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);