This commit is contained in:
Sergio-LPA 2026-09-13 09:00:10 +01:00 committed by GitHub
commit 98cce8724c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 258 additions and 2 deletions

View File

@ -71,6 +71,7 @@ import {
isClaudeRefusalResult,
isClaudeTransientUpstreamError,
isClaudeUnknownSessionError,
isClaudeUnknownSessionErrorFromStreams,
isClaudePoisonedPreviousMessageIdError,
isClaudeImageProcessingError,
isClaudeModelNotFoundError,
@ -1308,6 +1309,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
const initial = await runAttempt(sessionId ?? null);
// Silent-recovery variant: CLI could not resume the requested session for
// this cwd (e.g. the session file lives under a different project-encoded
// directory because the run's resolved workspace flipped since the session
// was persisted), emitted `No conversation found with session ID: <uuid>`
// on stderr, and silently started a fresh conversation. Exit code is 0
// and parsed result looks successful, so the classic parsed-only detector
// (below) does not fire. Log an observability line so operators can
// quantify the churn without waiting for the retry-fresh path to trigger.
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) === 0 &&
!(initial.parsed && initial.parsed["is_error"] === true) &&
isClaudeUnknownSessionErrorFromStreams({ parsed: null, stderr: initial.proc.stderr ?? "" })
) {
await onLog(
"stdout",
`[paperclip] Claude CLI could not resume session "${sessionId}" for the current cwd; ` +
`initial call silently fell back to a fresh conversation. Persisted session state will ` +
`refresh to the new session id.\n`,
);
}
const sessionErrorKind =
sessionId &&
!initial.proc.timedOut &&

View File

@ -9,6 +9,7 @@ import {
isClaudePoisonedPreviousMessageIdError,
isClaudeRefusalResult,
isClaudeUnknownSessionError,
isClaudeUnknownSessionErrorFromStreams,
isClaudeImageProcessingError,
isClaudeModelNotFoundError,
} from "./parse.js";
@ -422,6 +423,50 @@ describe("isClaudeUnknownSessionError", () => {
});
});
describe("isClaudeUnknownSessionErrorFromStreams", () => {
it("detects the silent-recovery stderr warning when parsed result looks successful", () => {
expect(
isClaudeUnknownSessionErrorFromStreams({
parsed: { subtype: "success", is_error: false, result: "ok" },
stderr: "No conversation found with session ID: 4da9bae3-bb6f-4e5e-b639-b24a3742724a",
}),
).toBe(true);
});
it("delegates to isClaudeUnknownSessionError when the parsed payload carries the message", () => {
expect(
isClaudeUnknownSessionErrorFromStreams({
parsed: { result: "Error: No conversation found with session id 1234" },
stderr: "",
}),
).toBe(true);
});
it("is case-insensitive on stderr", () => {
expect(
isClaudeUnknownSessionErrorFromStreams({
parsed: null,
stderr: "no conversation found with session id abc",
}),
).toBe(true);
});
it("returns false for unrelated stderr noise", () => {
expect(
isClaudeUnknownSessionErrorFromStreams({
parsed: null,
stderr: "Warning: shell exited with signal SIGTERM",
}),
).toBe(false);
});
it("returns false when both parsed and stderr are empty", () => {
expect(
isClaudeUnknownSessionErrorFromStreams({ parsed: null, stderr: "" }),
).toBe(false);
});
});
describe("isClaudeImageProcessingError", () => {
it("detects the 'Could not process image' 400 error in the result field", () => {
expect(

View File

@ -320,6 +320,26 @@ export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): bo
);
}
/**
* Extended detector that also inspects stderr for the CLI's silent-recovery
* variant: when Claude Code CLI cannot resolve `--resume <uuid>` in the current
* cwd (e.g. the session file lives under a different project-encoded directory
* because cwd changed between runs), it prints `No conversation found with
* session ID: <uuid>` on stderr, silently starts a fresh conversation, and
* still exits with code 0 and `is_error:false`. The plain
* `isClaudeUnknownSessionError` only inspects parsed result/errors, which are
* empty in that silent-recovery path.
*/
export function isClaudeUnknownSessionErrorFromStreams(input: {
parsed?: Record<string, unknown> | null;
stderr?: string | null;
}): boolean {
if (input.parsed && isClaudeUnknownSessionError(input.parsed)) return true;
const stderr = (input.stderr ?? "").trim();
if (!stderr) return false;
return /no conversation found with session id/i.test(stderr);
}
export function isClaudePoisonedPreviousMessageIdError(parsed: Record<string, unknown>): boolean {
const resultText = asString(parsed.result, "").trim();
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]

View File

@ -1258,6 +1258,124 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => {
});
expect(result.warning).toBeNull();
});
it("drops persisted session when a project-workspace session is being resumed from agent home", () => {
const agentId = "agent-123";
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
previousSessionParams: {
sessionId: "stale-project-session",
cwd: "/tmp/some-project-cwd",
workspaceId: "workspace-1",
},
resolvedWorkspace: buildResolvedWorkspace({
cwd: fallbackCwd,
source: "agent_home",
projectId: null,
workspaceId: null,
}),
});
expect(result.sessionParams).toBeNull();
expect(result.warning).toContain("stale-project-session");
expect(result.warning).toContain("fresh session");
});
it("preserves persisted session when resuming into agent home from the same fallback cwd", () => {
const agentId = "agent-123";
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
previousSessionParams: {
sessionId: "session-1",
cwd: fallbackCwd,
workspaceId: null,
},
resolvedWorkspace: buildResolvedWorkspace({
cwd: fallbackCwd,
source: "agent_home",
projectId: null,
workspaceId: null,
}),
});
expect(result.sessionParams).toEqual({
sessionId: "session-1",
cwd: fallbackCwd,
workspaceId: null,
});
expect(result.warning).toBeNull();
});
it("preserves a task_session when the resolved cwd matches the persisted cwd", async () => {
const agentId = "agent-123";
// Use a real on-disk cwd so realpath resolution succeeds; the interesting
// case is that the task_session's cwd is neither project_primary nor the
// fallback agent home. The previous implementation dropped it wholesale.
const taskSessionCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pcp-tasksess-"));
try {
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
previousSessionParams: {
sessionId: "task-session-abc",
cwd: taskSessionCwd,
workspaceId: "workspace-9",
},
resolvedWorkspace: buildResolvedWorkspace({
cwd: taskSessionCwd,
source: "task_session",
projectId: null,
workspaceId: "workspace-9",
}),
});
expect(result.sessionParams).toEqual({
sessionId: "task-session-abc",
cwd: taskSessionCwd,
workspaceId: "workspace-9",
});
expect(result.warning).toBeNull();
} finally {
await fs.rm(taskSessionCwd, { recursive: true, force: true });
}
});
it("preserves a fallback-home session that was persisted via a symlink to the real fallback dir", async () => {
const agentId = "agent-symlink";
const realFallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
await fs.mkdir(realFallbackCwd, { recursive: true });
const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "pcp-symlink-"));
const symlinkPath = path.join(symlinkDir, "fallback-alias");
await fs.symlink(realFallbackCwd, symlinkPath);
try {
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
previousSessionParams: {
sessionId: "session-symlink",
cwd: symlinkPath, // persisted through symlinked path
workspaceId: null,
},
resolvedWorkspace: buildResolvedWorkspace({
cwd: realFallbackCwd, // resolver hands back the real fallback path
source: "agent_home",
projectId: null,
workspaceId: null,
}),
});
expect(result.sessionParams).toEqual({
sessionId: "session-symlink",
cwd: symlinkPath,
workspaceId: null,
});
expect(result.warning).toBeNull();
} finally {
await fs.rm(symlinkDir, { recursive: true, force: true });
}
});
});
describe("applyPersistedExecutionWorkspaceConfig", () => {

View File

@ -47,6 +47,7 @@ import {
import { agentService } from "./agents.js";
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import fs from "node:fs/promises";
import { realpathSync } from "node:fs";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
@ -5316,6 +5317,21 @@ export function parseSessionCompactionPolicy(
.policy;
}
// Canonicalize a cwd for equality comparison across symlinked paths. The CLI
// encodes session files under the physical directory it saw, so a persisted
// cwd captured through a symlink must compare equal to the same physical
// directory expressed as its real path. Falls back to `path.resolve` when the
// path does not exist on disk (which is legitimate in unit tests and for
// prospective/planned cwds).
function canonicalizeCwdForComparison(target: string): string {
const normalized = path.resolve(target);
try {
return realpathSync(normalized);
} catch {
return normalized;
}
}
export function resolveRuntimeSessionParamsForWorkspace(input: {
agentId: string;
previousSessionParams: Record<string, unknown> | null;
@ -5332,7 +5348,40 @@ export function resolveRuntimeSessionParamsForWorkspace(input: {
warning: null as string | null,
};
}
const canonicalPreviousCwd = canonicalizeCwdForComparison(previousCwd);
const canonicalResolvedCwd = canonicalizeCwdForComparison(resolvedWorkspace.cwd);
// Inverse-of-migration case: the persisted session was captured in a
// project workspace but the current run resolves to the agent's home
// workspace (e.g. a timer/heartbeat wake with no active issue while the
// agent's last useful run happened inside a project). The CLI encodes the
// session file under the cwd it was created in, so a `--resume <uuid>` in
// agent_home cannot find the jsonl and silently starts a fresh session.
// Drop the persisted session id here so the run starts fresh explicitly
// and the state stays coherent (rather than accumulating stderr warnings
// while the CLI silently self-heals).
if (resolvedWorkspace.source !== "project_primary") {
// Safe to resume when the resolved cwd matches the persisted cwd — the
// session jsonl exists under the same directory. Covers the `task_session`
// source where `resolveWorkspaceForRun` deliberately reuses the persisted
// session's cwd, and any `agent_home` resolution that lands on the exact
// same directory that hosted the session.
if (canonicalPreviousCwd === canonicalResolvedCwd) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId);
const canonicalFallbackCwd = canonicalizeCwdForComparison(fallbackAgentHomeCwd);
if (canonicalPreviousCwd !== canonicalFallbackCwd) {
return {
sessionParams: null,
warning:
`Persisted session "${previousSessionId}" was captured in workspace "${previousCwd}" ` +
`but the current run resolves to "${resolvedWorkspace.cwd}" (source=${resolvedWorkspace.source}). ` +
`Starting a fresh session to avoid a stale --resume against a project-encoded cwd.`,
};
}
return {
sessionParams: previousSessionParams,
warning: null as string | null,
@ -5346,13 +5395,14 @@ export function resolveRuntimeSessionParamsForWorkspace(input: {
};
}
const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId);
if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) {
const canonicalFallbackCwd = canonicalizeCwdForComparison(fallbackAgentHomeCwd);
if (canonicalPreviousCwd !== canonicalFallbackCwd) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
if (path.resolve(projectCwd) === path.resolve(previousCwd)) {
if (canonicalizeCwdForComparison(projectCwd) === canonicalPreviousCwd) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,