fix(HAI-086): handle ENOENT gracefully in specifyTask

- Move updateTask call inside try block so ENOENT from deleted tasks is caught
- Add ENOENT check in catch to log and skip instead of treating as a failure
- Preserve existing error handling for non-ENOENT errors
- Add resilience tests covering ENOENT scenarios in specifyTask
This commit is contained in:
Dustin Byrne
2026-03-26 00:57:49 -04:00
parent cab27b5096
commit 93a79a5bfa
2 changed files with 97 additions and 4 deletions

View File

@@ -334,3 +334,89 @@ describe("buildSpecificationPrompt", () => {
expect(result).not.toContain("## Attachments");
});
});
function createEnoentError(path = "/fake/path"): NodeJS.ErrnoException {
return Object.assign(
new Error(`ENOENT: no such file or directory, open '${path}'`),
{ code: "ENOENT", errno: -2, syscall: "open" },
);
}
const dummyTask = {
id: "HAI-099",
title: "Deleted task",
description: "This task was deleted",
column: "triage" as const,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
describe("TriageProcessor deleted task handling", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("handles ENOENT from updateTask gracefully without calling onSpecifyError", async () => {
const store = createMockStore();
store.updateTask.mockRejectedValue(createEnoentError());
const onError = vi.fn();
const triage = new TriageProcessor(store, "/tmp/test", {
onSpecifyError: onError,
});
// Should not throw
await triage.specifyTask(dummyTask);
expect(onError).not.toHaveBeenCalled();
// updateTask was called once (the "specifying" call that threw)
expect(store.updateTask).toHaveBeenCalledTimes(1);
});
it("handles ENOENT from getTask gracefully", async () => {
const store = createMockStore();
store.updateTask.mockResolvedValue({});
store.getTask.mockRejectedValue(createEnoentError());
const onError = vi.fn();
const triage = new TriageProcessor(store, "/tmp/test", {
onSpecifyError: onError,
});
await triage.specifyTask(dummyTask);
expect(onError).not.toHaveBeenCalled();
// updateTask called once for "specifying", but NOT for status reset (ENOENT path skips it)
expect(store.updateTask).toHaveBeenCalledTimes(1);
});
it("cleans up processing Set on ENOENT so task is not stuck", async () => {
const store = createMockStore();
store.updateTask.mockRejectedValueOnce(createEnoentError());
const triage = new TriageProcessor(store, "/tmp/test", {});
// First call — ENOENT
await triage.specifyTask(dummyTask);
// Second call with same task should NOT short-circuit from processing guard.
// Reset mock to succeed and set up agent mock for the retry path.
store.updateTask.mockResolvedValue({});
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
await triage.specifyTask(dummyTask);
// If processing Set was cleaned up, updateTask will be called again for "specifying"
expect(store.updateTask).toHaveBeenCalledWith("HAI-099", { status: "specifying" });
expect(mockedCreateHaiAgent).toHaveBeenCalled();
});
});