fix(triage): clear specifying status on pause/stuck and add StuckTaskDetector support

- Fix pause-abort handler using updateTask({status: undefined}) which was a
  no-op, leaving tasks stuck in 'specifying' forever after a pause interrupts
  a session post-APPROVE. Changed to status: null to actually clear the field.
- Apply same fix to transient-error retry and general error catch paths.
- Wire StuckTaskDetector into TriageProcessor: trackTask/untrackTask/recordActivity
  on session lifecycle, markStuckAborted to prevent stuck kills from being
  reported as errors, and clear status to null on stuck-kill for next-poll retry.
- Update dashboard.ts to pass stuckTaskDetector to TriageProcessor and call
  triageRef.current?.markStuckAborted in the shared onStuck callback.
- Add 3 tests covering pause-abort status clearing and stuck detector wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-07 21:51:09 -07:00
parent b1c5fa03a3
commit 5f7f351a8a
3 changed files with 193 additions and 20 deletions

View File

@@ -1734,3 +1734,137 @@ describe("stale approval detection", () => {
await rm(rootDir, { recursive: true, force: true });
});
});
describe("pause-abort status clearing (bug fix)", () => {
it("clears specifying status to null on global pause (not a no-op)", async () => {
const settingsListeners: Array<(e: any) => void> = [];
const store = {
on: vi.fn((event: string, cb: (e: any) => void) => {
if (event === "settings:updated") settingsListeners.push(cb);
}),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail }),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true } as Settings),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
let resolveDispose: () => void;
const disposePromise = new Promise<void>((r) => { resolveDispose = r; });
mockCreateKbAgent.mockResolvedValue({
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockReturnValue(disposePromise),
dispose: vi.fn().mockImplementation(() => resolveDispose()),
navigateTree: vi.fn(),
},
});
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
const processor = new TriageProcessor(store, "/tmp/root");
const specifyPromise = processor.specifyTask(task);
await new Promise((r) => setTimeout(r, 20));
for (const fn of settingsListeners) {
fn({ settings: { globalPause: true }, previous: { globalPause: false } });
}
await specifyPromise;
// Status must be set to null so the next poll can retry (old bug: undefined was a no-op)
const nullStatusCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
.find((c) => c[1]?.status === null);
expect(nullStatusCall).toBeDefined();
const undefinedStatusCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
.find((c) => "status" in c[1] && c[1].status === undefined);
expect(undefinedStatusCall).toBeUndefined();
});
});
describe("stuck task detector integration", () => {
it("markStuckAborted clears specifying status to null for retry", async () => {
const store = {
on: vi.fn(),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail }),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true } as Settings),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
let resolveDispose: () => void;
let mockDispose: ReturnType<typeof vi.fn>;
const disposePromise = new Promise<void>((r) => { resolveDispose = r; });
mockDispose = vi.fn().mockImplementation(() => resolveDispose());
mockCreateKbAgent.mockResolvedValue({
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockReturnValue(disposePromise),
dispose: mockDispose,
navigateTree: vi.fn(),
},
});
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
const processor = new TriageProcessor(store, "/tmp/root");
const specifyPromise = processor.specifyTask(task);
await new Promise((r) => setTimeout(r, 20));
// Stuck detector marks task then disposes the session (simulating StuckTaskDetector.killAndRetry)
processor.markStuckAborted("FN-001");
mockDispose();
await specifyPromise;
// Status cleared to null so next poll retries
const nullStatusCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
.find((c) => c[1]?.status === null);
expect(nullStatusCall).toBeDefined();
});
it("tracks and untracks sessions with stuckTaskDetector", async () => {
const trackTask = vi.fn();
const untrackTask = vi.fn();
const recordActivity = vi.fn();
const mockDetector = { trackTask, untrackTask, recordActivity } as any;
const store = {
on: vi.fn(),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail }),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true } as Settings),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
mockCreateKbAgent.mockResolvedValue({
session: {
state: {},
sessionManager: {},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
});
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
const processor = new TriageProcessor(store, "/tmp/root", { stuckTaskDetector: mockDetector });
await processor.specifyTask(task);
expect(trackTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ dispose: expect.any(Function) }));
expect(untrackTask).toHaveBeenCalledWith("FN-001");
expect(recordActivity).toHaveBeenCalled();
});
});