Merge a3e00b9de5 into c9e3bb7ca4
This commit is contained in:
commit
98cce8724c
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue