chore: update stuck task handling and dashboard tweaks
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1202,6 +1202,22 @@ export function SettingsModal({
|
||||
/>
|
||||
<small>Timeout in minutes for detecting stuck tasks. When a task'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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user