chore: update stuck task handling and dashboard tweaks

This commit is contained in:
gsxdsm
2026-04-05 21:36:18 -07:00
parent 83d306cc5f
commit 9a6592e299
9 changed files with 84 additions and 13 deletions

View File

@@ -836,7 +836,7 @@ export interface ProjectSettings {
/** Maximum delay cap in milliseconds for auto-unpause backoff. Default: 3600000 (60 min). */
autoUnpauseMaxDelayMs?: number;
/** Maximum number of times the stuck-task detector can kill and re-queue a task
* before it is marked as permanently failed. Default: 3. */
* before it is marked as permanently failed. Default: 6. */
maxStuckKills?: number;
/** Maximum number of child agents a single parent agent can spawn.
* Limits the fan-out per executor task to prevent resource exhaustion.
@@ -977,7 +977,7 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
autoUnpauseEnabled: true,
autoUnpauseBaseDelayMs: 300_000,
autoUnpauseMaxDelayMs: 3_600_000,
maxStuckKills: 3,
maxStuckKills: 6,
maxSpawnedAgentsPerParent: 5,
maxSpawnedAgentsGlobal: 20,
maintenanceIntervalMs: 900_000,
@@ -1058,6 +1058,7 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"smartConflictResolution",
"requirePlanApproval",
"taskStuckTimeoutMs",
"maxStuckKills",
"autoUpdatePrStatus",
"autoCreatePr",
"autoBackupEnabled",

View File

@@ -1202,6 +1202,22 @@ export function SettingsModal({
/>
<small>Timeout in minutes for detecting stuck tasks. When a task&apos;s agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.</small>
</div>
<div className="form-group">
<label htmlFor="maxStuckKills">Max Stuck Retries</label>
<input
id="maxStuckKills"
type="number"
min={1}
step={1}
value={form.maxStuckKills ?? ""}
onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, maxStuckKills: val && num > 0 ? num : undefined }));
}}
/>
<small>Maximum stuck-detector retries before a task is marked failed. Default: 6.</small>
</div>
<div className="form-group">
<label htmlFor="groupOverlappingFiles" className="checkbox-label">
<input

View File

@@ -24,6 +24,7 @@ const defaultSettings: Settings = {
ntfyTopic: undefined,
ntfyEvents: ["in-review", "merged", "failed"],
taskStuckTimeoutMs: undefined,
maxStuckKills: 6,
};
vi.mock("../../api", () => ({
@@ -1950,6 +1951,42 @@ describe("SettingsModal", () => {
expect(input.value).toBe("10");
});
it("Max Stuck Retries field saves correctly when set to a value", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
const input = screen.getByLabelText("Max Stuck Retries") as HTMLInputElement;
fireEvent.change(input, { target: { value: "8" } });
expect(input.value).toBe("8");
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.maxStuckKills).toBe(8);
});
it("Max Stuck Retries field submits undefined when cleared", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
maxStuckKills: 6,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
const input = screen.getByLabelText("Max Stuck Retries") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.maxStuckKills).toBeUndefined();
});
it("scope banners render for global and project sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());

View File

@@ -117,8 +117,6 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
const buffer = initialBufferRef.current;
if (buffer.connected) {
callback(buffer.connected);
// Clear after replay — connected info is one-shot
buffer.connected = null;
}
return () => onConnectCallbacksRef.current.delete(callback);
}, []);
@@ -129,8 +127,6 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
const buffer = initialBufferRef.current;
if (buffer.scrollback) {
callback(buffer.scrollback);
// Clear after replay — scrollback is one-shot per connection
buffer.scrollback = null;
}
return () => onScrollbackCallbacksRef.current.delete(callback);
}, []);

View File

@@ -47,6 +47,18 @@ describe("isTaskStuck", () => {
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for failed in-progress tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ status: "failed", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for stuck-killed in-progress tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ status: "stuck-killed", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for recent in-progress tasks within timeout", () => {
const recent = new Date(Date.now() - 300000).toISOString(); // 5 minutes ago
const task = createTask({ updatedAt: recent });
@@ -122,6 +134,7 @@ describe("countStuckTasks", () => {
const tasks = [
createTask({ id: "FN-001", updatedAt: stale }), // stuck
createTask({ id: "FN-002", updatedAt: recent }), // not stuck
createTask({ id: "FN-004", status: "failed", updatedAt: stale }), // terminal status
createTask({ id: "FN-003", column: "todo", updatedAt: stale }), // not in-progress
];
expect(countStuckTasks(tasks, 600000)).toBe(1);

View File

@@ -1,5 +1,7 @@
import type { Task } from "@fusion/core";
const NON_STUCK_STATUSES = new Set(["failed", "stuck-killed"]);
/**
* Check if a task is stuck based on the project's stuck timeout setting.
*
@@ -16,6 +18,10 @@ export function isTaskStuck(task: Task, taskStuckTimeoutMs: number | undefined):
return false;
}
if (task.status && NON_STUCK_STATUSES.has(task.status)) {
return false;
}
if (!taskStuckTimeoutMs || taskStuckTimeoutMs <= 0) {
return false;
}

View File

@@ -736,7 +736,7 @@ export class TaskExecutor {
this.createTaskCreateTool(),
this.createTaskAddDepTool(task.id),
this.createTaskDoneTool(task.id, () => { taskDone = true; }),
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints, detail),
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints, detail, stuckDetector),
this.createSpawnAgentTool(task.id, worktreePath, settings),
];
@@ -1471,6 +1471,7 @@ export class TaskExecutor {
sessionRef: { current: AgentSession | null },
stepCheckpoints: Map<number, string>,
detail: TaskDetail,
stuckDetector?: StuckTaskDetector,
): ToolDefinition {
const store = this.store;
const options = this.options;
@@ -1518,6 +1519,7 @@ export class TaskExecutor {
result.summary,
);
reviewerLog.log(`${taskId}: Step ${step} ${reviewType}${result.verdict}`);
stuckDetector?.recordProgress(taskId);
// Track code review verdicts for enforcement. Plan reviews remain
// advisory — only code reviews write to the verdict map.

View File

@@ -30,7 +30,7 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
autoUnpauseEnabled: true,
autoUnpauseBaseDelayMs: 100,
autoUnpauseMaxDelayMs: 800,
maxStuckKills: 3,
maxStuckKills: 6,
maintenanceIntervalMs: 0,
maxWorktrees: 4,
globalPause: true, // default: paused (for auto-unpause tests)
@@ -192,7 +192,7 @@ describe("SelfHealingManager", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { stuckKillCount: 1 });
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
expect.stringContaining("Stuck kill 1/3"),
expect.stringContaining("Stuck kill 1/6"),
);
});
@@ -213,7 +213,7 @@ describe("SelfHealingManager", () => {
it("returns false and marks failed when budget exceeded", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-001",
stuckKillCount: 3,
stuckKillCount: 6,
} as unknown as Task);
manager.start();
@@ -222,9 +222,9 @@ describe("SelfHealingManager", () => {
expect(result).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
stuckKillCount: 4,
stuckKillCount: 7,
status: "failed",
error: expect.stringContaining("exceeded maximum of 3"),
error: expect.stringContaining("exceeded maximum of 6"),
});
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",

View File

@@ -188,7 +188,7 @@ export class SelfHealingManager {
async checkStuckBudget(taskId: string): Promise<boolean> {
try {
const settings = await this.store.getSettings();
const maxKills = settings.maxStuckKills ?? 3;
const maxKills = settings.maxStuckKills ?? 6;
const task = await this.store.getTask(taskId);
const newCount = (task.stuckKillCount ?? 0) + 1;