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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user