fix: retry with new agent session when task_done is not called
Instead of immediately failing when an agent finishes without calling task_done, spawn a fresh session with a recovery prompt that asks the agent to review the worktree state and complete the task. Only fail if the retry also doesn't produce a task_done call. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -370,10 +370,10 @@ describe("TaskExecutor worktreeInitCommand", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The init command failure itself does not abort execution, but the mocked
|
// The init command failure itself does not abort execution, but the mocked
|
||||||
// agent still exits without task_done, which now reports an execution error.
|
// agent still exits without task_done. After the retry also fails, it reports an error.
|
||||||
expect(onError).toHaveBeenCalledWith(
|
expect(onError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: "FN-010" }),
|
expect.objectContaining({ id: "FN-010" }),
|
||||||
expect.objectContaining({ message: "Agent finished without calling task_done" }),
|
expect.objectContaining({ message: "Agent finished without calling task_done (after retry)" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Agent should still have been created
|
// Agent should still have been created
|
||||||
@@ -4549,13 +4549,15 @@ describe("Invalid transition error handling", () => {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// A missing task_done is now treated as a failure before the transition is attempted.
|
// A missing task_done triggers a retry. Both attempts fail to call task_done,
|
||||||
|
// then the moveTask in the retry path throws the Invalid transition error,
|
||||||
|
// which is caught by the outer handler.
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: "Agent finished without calling task_done",
|
error: "Agent finished without calling task_done (after retry)",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should log informative message
|
// Should log informative message from the outer catch for Invalid transition
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"FN-001",
|
"FN-001",
|
||||||
"Task already moved from 'done' — skipping transition to 'in-review'",
|
"Task already moved from 'done' — skipping transition to 'in-review'",
|
||||||
@@ -4779,18 +4781,22 @@ describe("Workflow Steps Execution", () => {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Should have been called twice: initial + retry
|
||||||
|
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Retry still didn't call task_done, so it fails with the retry message
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: "Agent finished without calling task_done",
|
error: "Agent finished without calling task_done (after retry)",
|
||||||
});
|
});
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"FN-001",
|
"FN-001",
|
||||||
"Agent finished without calling task_done — moved to in-review for inspection",
|
"Agent finished without calling task_done (after retry) — moved to in-review for inspection",
|
||||||
);
|
);
|
||||||
expect(onError).toHaveBeenCalledWith(
|
expect(onError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: "FN-001" }),
|
expect.objectContaining({ id: "FN-001" }),
|
||||||
expect.objectContaining({ message: "Agent finished without calling task_done" }),
|
expect.objectContaining({ message: "Agent finished without calling task_done (after retry)" }),
|
||||||
);
|
);
|
||||||
expect(onComplete).not.toHaveBeenCalled();
|
expect(onComplete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ export class TaskExecutor {
|
|||||||
const executorFallbackProvider = settings.fallbackProvider;
|
const executorFallbackProvider = settings.fallbackProvider;
|
||||||
const executorFallbackModelId = settings.fallbackModelId;
|
const executorFallbackModelId = settings.fallbackModelId;
|
||||||
|
|
||||||
const { session } = await createKbAgent({
|
let { session } = await createKbAgent({
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
@@ -692,12 +692,78 @@ export class TaskExecutor {
|
|||||||
executorLog.log(`✓ ${task.id} completed → in-review`);
|
executorLog.log(`✓ ${task.id} completed → in-review`);
|
||||||
this.options.onComplete?.(task);
|
this.options.onComplete?.(task);
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = "Agent finished without calling task_done";
|
// Agent finished without calling task_done — retry once with a fresh session
|
||||||
await this.store.updateTask(task.id, { status: "failed", error: errorMessage });
|
executorLog.log(`⚠ ${task.id} finished without task_done — retrying with new session`);
|
||||||
await this.store.logEntry(task.id, `${errorMessage} — moved to in-review for inspection`);
|
await this.store.logEntry(task.id, "Agent finished without calling task_done — retrying with new session");
|
||||||
await this.store.moveTask(task.id, "in-review");
|
|
||||||
executorLog.log(`⚠ ${task.id} finished without task_done → in-review`);
|
// Dispose old session and create a fresh one
|
||||||
this.options.onError?.(task, new Error(errorMessage));
|
this.activeSessions.delete(task.id);
|
||||||
|
session.dispose();
|
||||||
|
|
||||||
|
const { session: retrySession } = await createKbAgent({
|
||||||
|
cwd: worktreePath,
|
||||||
|
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
||||||
|
tools: "coding",
|
||||||
|
customTools,
|
||||||
|
onText: agentLogger.onText,
|
||||||
|
onThinking: agentLogger.onThinking,
|
||||||
|
onToolStart: agentLogger.onToolStart,
|
||||||
|
onToolEnd: agentLogger.onToolEnd,
|
||||||
|
defaultProvider: executorProvider,
|
||||||
|
defaultModelId: executorModelId,
|
||||||
|
fallbackProvider: executorFallbackProvider,
|
||||||
|
fallbackModelId: executorFallbackModelId,
|
||||||
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reassign so finally{} disposes the correct session
|
||||||
|
session = retrySession;
|
||||||
|
sessionRef.current = retrySession;
|
||||||
|
this.activeSessions.set(task.id, { session: retrySession, seenSteeringIds });
|
||||||
|
stuckDetector?.trackTask(task.id, retrySession);
|
||||||
|
|
||||||
|
const retryPrompt = [
|
||||||
|
"Your previous session ended without calling the task_done tool.",
|
||||||
|
"The task may already be complete — review the current state of the worktree and either:",
|
||||||
|
"1. If the work is done, call task_done with a summary of what was accomplished.",
|
||||||
|
"2. If there is remaining work, finish it and then call task_done.",
|
||||||
|
"",
|
||||||
|
"Original task:",
|
||||||
|
buildExecutionPrompt(detail, this.rootDir, settings),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
stuckDetector?.recordActivity(task.id);
|
||||||
|
await promptWithFallback(retrySession, retryPrompt);
|
||||||
|
checkSessionError(retrySession);
|
||||||
|
|
||||||
|
if (taskDone) {
|
||||||
|
const updatedTask = await this.store.getTask(task.id);
|
||||||
|
const modifiedFiles = this.captureModifiedFiles(worktreePath, updatedTask.baseCommitSha);
|
||||||
|
if (modifiedFiles.length > 0) {
|
||||||
|
await this.store.updateTask(task.id, { modifiedFiles });
|
||||||
|
executorLog.log(`${task.id}: captured ${modifiedFiles.length} modified files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowSuccess = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||||
|
if (!workflowSuccess) {
|
||||||
|
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
||||||
|
await this.store.moveTask(task.id, "in-review");
|
||||||
|
executorLog.log(`✗ ${task.id} workflow step failed on retry → in-review`);
|
||||||
|
this.options.onError?.(task, new Error("Workflow step failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.store.moveTask(task.id, "in-review");
|
||||||
|
executorLog.log(`✓ ${task.id} completed on retry → in-review`);
|
||||||
|
this.options.onComplete?.(task);
|
||||||
|
} else {
|
||||||
|
const errorMessage = "Agent finished without calling task_done (after retry)";
|
||||||
|
await this.store.updateTask(task.id, { status: "failed", error: errorMessage });
|
||||||
|
await this.store.logEntry(task.id, `${errorMessage} — moved to in-review for inspection`);
|
||||||
|
await this.store.moveTask(task.id, "in-review");
|
||||||
|
executorLog.log(`✗ ${task.id} failed after retry — no task_done → in-review`);
|
||||||
|
this.options.onError?.(task, new Error(errorMessage));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.activeSessions.delete(task.id);
|
this.activeSessions.delete(task.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user