diff --git a/packages/adapters/claude-local/src/server/cli-capabilities.ts b/packages/adapters/claude-local/src/server/cli-capabilities.ts index 92964823498..fb345b30103 100644 --- a/packages/adapters/claude-local/src/server/cli-capabilities.ts +++ b/packages/adapters/claude-local/src/server/cli-capabilities.ts @@ -78,9 +78,12 @@ export async function claudeCommandSupportsEffortFlag(input: { const cached = effortFlagSupportCache.get(key); if (cached) return cached; - const probe = probeClaudeCommandSupportsEffortFlag(input).catch((error) => { + // A thrown probe (e.g. sandbox connection error, ENOENT spawning the binary) + // must degrade to the conservative fallback rather than killing the run, so we + // resolve to null and drop the cache entry to retry on the next lease. + const probe = probeClaudeCommandSupportsEffortFlag(input).catch(() => { effortFlagSupportCache.delete(key); - throw error; + return null; }); effortFlagSupportCache.set(key, probe); return probe; diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 57a64c6bd65..31ba501f4fc 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -2,7 +2,10 @@ export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from export { listClaudeSkills, syncClaudeSkills } from "./skills.js"; export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js"; export { testEnvironment } from "./test.js"; -export { resetClaudeCliCapabilitiesCacheForTests } from "./cli-capabilities.js"; +export { + claudeCommandSupportsEffortFlag, + resetClaudeCliCapabilitiesCacheForTests, +} from "./cli-capabilities.js"; export { parseClaudeStreamJson, describeClaudeFailure, diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 0ee4c86177b..281c8527221 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import { + claudeCommandSupportsEffortFlag, claudeSessionCwdMatchesExecutionTarget, execute, resetClaudeCliCapabilitiesCacheForTests, @@ -947,6 +948,48 @@ describe("claude execute", () => { } }, 10_000); + it("degrades to the conservative fallback (returns null) when the sandbox probe throws, and retries on the next lease", async () => { + let calls = 0; + const throwingRunner = { + execute: async () => { + calls += 1; + throw new Error("sandbox connection error"); + }, + }; + const target = { + kind: "remote" as const, + transport: "sandbox" as const, + providerKey: "daytona", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: "/remote/workspace", + timeoutMs: 30_000, + runner: throwingRunner, + }; + const probeInput = { + runId: "run-probe-throws", + command: "/usr/local/bin/claude", + cwd: "/host/workspace", + env: {}, + timeoutSec: 20, + graceSec: 5, + }; + + // A thrown probe must resolve to null (unknown) rather than reject and kill the run. + await expect( + claudeCommandSupportsEffortFlag({ ...probeInput, target }), + ).resolves.toBeNull(); + + // The failed result is not cached: a second lease re-probes instead of reusing the rejection. + await expect( + claudeCommandSupportsEffortFlag({ + ...probeInput, + target: { ...target, leaseId: "lease-2" }, + }), + ).resolves.toBeNull(); + expect(calls).toBe(2); + }); + it("allows remote session resumes when saved cwd is the host workspace", () => { expect(claudeSessionCwdMatchesExecutionTarget({ runtimeSessionCwd: "/host/workspace",