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();
});
});

View File

@@ -242,9 +242,10 @@ export class TriageProcessor {
console.log(`[triage] Specifying ${task.id}: ${task.title || task.description.slice(0, 60)}`);
this.options.onSpecifyStart?.(task);
await this.store.updateTask(task.id, { status: "specifying" });
try {
// Set status inside try so ENOENT (task deleted between poll and specify) is caught
await this.store.updateTask(task.id, { status: "specifying" });
const detail = await this.store.getTask(task.id);
const settings = await this.store.getSettings();
const promptPath = `.hai/tasks/${task.id}/PROMPT.md`;
@@ -299,9 +300,15 @@ export class TriageProcessor {
await agentWork();
}
} catch (err: any) {
await this.store.updateTask(task.id, { status: null }).catch(() => {});
console.error(`[triage] ✗ ${task.id} specification failed:`, err.message);
this.options.onSpecifyError?.(task, err);
// Race condition: task was deleted (e.g. as a duplicate) between listTasks()
// and specifyTask(). The file is gone, so just log and skip — no point retrying.
if (err.code === "ENOENT") {
console.log(`[triage] ${task.id} no longer exists — skipping`);
} else {
await this.store.updateTask(task.id, { status: null }).catch(() => {});
console.error(`[triage] ✗ ${task.id} specification failed:`, err.message);
this.options.onSpecifyError?.(task, err);
}
} finally {
this.processing.delete(task.id);
}