Skip to content

Commit 8c370a8

Browse files
HCLclaude
andcommitted
fix(agents): widen attempt-execution store clear to timeout/unknown FailoverErrors
The cli-runner already retried fresh for any FailoverError, but the store-clear in attempt-execution only fired for session_expired. Timeout and unknown failures left the stale binding in the store, causing the next turn to fail again. Also adds regression tests for timeout and unknown failure reasons at the attempt-execution caller level. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 2df3444 commit 8c370a8

2 files changed

Lines changed: 134 additions & 2 deletions

File tree

src/agents/command/attempt-execution.cli.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,136 @@ describe("CLI attempt execution", () => {
226226
expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined();
227227
});
228228

229+
it("clears stale Claude CLI session IDs before retrying after session timeout", async () => {
230+
const sessionKey = "agent:main:subagent:cli-timeout";
231+
const homeDir = path.join(tmpDir, "home-timeout");
232+
process.env.HOME = homeDir;
233+
const projectsDir = path.join(homeDir, ".claude", "projects", "demo-workspace");
234+
await fs.mkdir(projectsDir, { recursive: true });
235+
await fs.writeFile(
236+
path.join(projectsDir, "stale-cli-timeout-session.jsonl"),
237+
`${JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "old" }] } })}\n`,
238+
"utf-8",
239+
);
240+
const sessionEntry: SessionEntry = {
241+
sessionId: "session-cli-timeout",
242+
updatedAt: Date.now(),
243+
cliSessionIds: { "claude-cli": "stale-cli-timeout-session" },
244+
};
245+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
246+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
247+
248+
runCliAgentMock
249+
.mockRejectedValueOnce(
250+
new FailoverError("session timed out", {
251+
reason: "timeout",
252+
provider: "claude-cli",
253+
model: "opus",
254+
}),
255+
)
256+
.mockResolvedValueOnce(makeCliResult("recovered after timeout"));
257+
258+
await runAgentAttempt({
259+
providerOverride: "claude-cli",
260+
originalProvider: "claude-cli",
261+
modelOverride: "opus",
262+
cfg: {} as OpenClawConfig,
263+
sessionEntry,
264+
sessionId: sessionEntry.sessionId,
265+
sessionKey,
266+
sessionAgentId: "main",
267+
sessionFile: path.join(tmpDir, "session.jsonl"),
268+
workspaceDir: tmpDir,
269+
body: "retry after timeout",
270+
isFallbackRetry: false,
271+
resolvedThinkLevel: "medium",
272+
timeoutMs: 1_000,
273+
runId: "run-cli-timeout",
274+
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
275+
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
276+
spawnedBy: undefined,
277+
messageChannel: undefined,
278+
skillsSnapshot: undefined,
279+
resolvedVerboseLevel: undefined,
280+
agentDir: tmpDir,
281+
onAgentEvent: vi.fn(),
282+
authProfileProvider: "claude-cli",
283+
sessionStore,
284+
storePath,
285+
sessionHasHistory: false,
286+
});
287+
288+
expect(runCliAgentMock).toHaveBeenCalledTimes(2);
289+
expect(runCliAgentMock.mock.calls[0]?.[0]?.cliSessionId).toBe("stale-cli-timeout-session");
290+
expect(runCliAgentMock.mock.calls[1]?.[0]?.cliSessionId).toBeUndefined();
291+
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
292+
});
293+
294+
it("clears stale Claude CLI session IDs before retrying after unknown FailoverError", async () => {
295+
const sessionKey = "agent:main:subagent:cli-unknown";
296+
const homeDir = path.join(tmpDir, "home-unknown");
297+
process.env.HOME = homeDir;
298+
const projectsDir2 = path.join(homeDir, ".claude", "projects", "demo-workspace");
299+
await fs.mkdir(projectsDir2, { recursive: true });
300+
await fs.writeFile(
301+
path.join(projectsDir2, "stale-cli-unknown-session.jsonl"),
302+
`${JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "old" }] } })}\n`,
303+
"utf-8",
304+
);
305+
const sessionEntry: SessionEntry = {
306+
sessionId: "session-cli-unknown",
307+
updatedAt: Date.now(),
308+
cliSessionIds: { "claude-cli": "stale-cli-unknown-session" },
309+
};
310+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
311+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
312+
313+
runCliAgentMock
314+
.mockRejectedValueOnce(
315+
new FailoverError("unknown cli failure", {
316+
reason: "unknown",
317+
provider: "claude-cli",
318+
model: "opus",
319+
}),
320+
)
321+
.mockResolvedValueOnce(makeCliResult("recovered after unknown"));
322+
323+
await runAgentAttempt({
324+
providerOverride: "claude-cli",
325+
originalProvider: "claude-cli",
326+
modelOverride: "opus",
327+
cfg: {} as OpenClawConfig,
328+
sessionEntry,
329+
sessionId: sessionEntry.sessionId,
330+
sessionKey,
331+
sessionAgentId: "main",
332+
sessionFile: path.join(tmpDir, "session.jsonl"),
333+
workspaceDir: tmpDir,
334+
body: "retry after unknown",
335+
isFallbackRetry: false,
336+
resolvedThinkLevel: "medium",
337+
timeoutMs: 1_000,
338+
runId: "run-cli-unknown",
339+
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
340+
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
341+
spawnedBy: undefined,
342+
messageChannel: undefined,
343+
skillsSnapshot: undefined,
344+
resolvedVerboseLevel: undefined,
345+
agentDir: tmpDir,
346+
onAgentEvent: vi.fn(),
347+
authProfileProvider: "claude-cli",
348+
sessionStore,
349+
storePath,
350+
sessionHasHistory: false,
351+
});
352+
353+
expect(runCliAgentMock).toHaveBeenCalledTimes(2);
354+
expect(runCliAgentMock.mock.calls[0]?.[0]?.cliSessionId).toBe("stale-cli-unknown-session");
355+
expect(runCliAgentMock.mock.calls[1]?.[0]?.cliSessionId).toBeUndefined();
356+
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
357+
});
358+
229359
it("does not pass --resume when the stored Claude CLI transcript is missing", async () => {
230360
const sessionKey = "agent:main:direct:claude-missing-transcript";
231361
const homeDir = path.join(tmpDir, "home");

src/agents/command/attempt-execution.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,14 +510,16 @@ export function runAgentAttempt(params: {
510510
} catch (err) {
511511
if (
512512
err instanceof FailoverError &&
513-
err.reason === "session_expired" &&
513+
(err.reason === "session_expired" ||
514+
err.reason === "timeout" ||
515+
err.reason === "unknown") &&
514516
activeCliSessionBinding?.sessionId &&
515517
params.sessionKey &&
516518
params.sessionStore &&
517519
params.storePath
518520
) {
519521
log.warn(
520-
`CLI session expired, clearing from session store: provider=${sanitizeForLog(cliExecutionProvider)} sessionKey=${params.sessionKey}`,
522+
`CLI session failed (reason=${err.reason}), clearing from session store: provider=${sanitizeForLog(cliExecutionProvider)} sessionKey=${params.sessionKey}`,
521523
);
522524

523525
params.sessionEntry =

0 commit comments

Comments
 (0)