fix: defer stuck-kill requeue to executor finally block to prevent race
When the stuck task detector killed a task and immediately called
moveTask("todo"), the scheduler could re-dispatch the task before the
old execution's finally block cleared this.executing. The new execute()
call hit the guard and silently returned, stranding the task in
"in-progress" with no active session or worktree (seen on FN-810/FN-912).
Move the requeue responsibility from StuckTaskDetector.killAndRetry to
the executor's finally block, which runs after this.executing.delete().
The beforeRequeue budget check now runs before session.dispose() and its
result is passed via StuckTaskEvent.shouldRequeue → markStuckAborted().
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4635,7 +4635,7 @@ describe("TaskExecutor bounded recovery retries", () => {
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
|
||||
mockedCreateHaiAgent.mockRejectedValue(new Error("Aborted"));
|
||||
(executor as any).stuckAborted.add("FN-001");
|
||||
(executor as any).stuckAborted.set("FN-001", true);
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
@@ -4657,14 +4657,14 @@ describe("TaskExecutor bounded recovery retries", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("exits cleanly when a stuck-killed session resolves without throwing", async () => {
|
||||
it("requeues to todo when a stuck-killed session resolves without throwing", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn(async () => {
|
||||
executor.markStuckAborted("FN-001");
|
||||
executor.markStuckAborted("FN-001", true);
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: {},
|
||||
@@ -4690,6 +4690,46 @@ describe("TaskExecutor bounded recovery retries", () => {
|
||||
expect.objectContaining({ status: "failed" }),
|
||||
);
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
|
||||
// Executor now handles the requeue in its finally block
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "stuck-killed" });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
|
||||
it("does not requeue when stuck-kill budget is exhausted", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn(async () => {
|
||||
// Budget exhausted — shouldRequeue=false
|
||||
executor.markStuckAborted("FN-001", false);
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: {},
|
||||
},
|
||||
}) as any);
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should NOT requeue or mark as failed (budget handler already did that)
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "stuck-killed" });
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({ status: "failed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears recovery metadata after successful run completes", async () => {
|
||||
|
||||
Reference in New Issue
Block a user