From 94067dc51d1ebfc03b6a33dfe92f325df4db4062 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 20 Jun 2026 12:14:30 -0700 Subject: [PATCH] Make Claude effort probe fall back conservatively on throw A thrown capability probe (sandbox connection error, ENOENT spawning the binary) previously propagated past the unguarded call sites in execute.ts and test.ts, killing the run instead of degrading. Resolve the probe to null and drop the cache entry so the flag is passed through unchanged and the next lease retries. Add a regression test for the throw path. --- .../src/server/cli-capabilities.ts | 7 ++- .../adapters/claude-local/src/server/index.ts | 5 ++- .../__tests__/claude-local-execute.test.ts | 43 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) 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",