fix(claude-local): resume sessions across agent file working copies (#15437)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Claude CLI adapter resumes task sessions between runs.
> - Each run now receives a private copy of the agent files.
> - The adapter put that copy's path in the cached system instructions.
> - The new run ID changed the prompt bundle even when all instructions
and skills stayed the same.
> - This pull request sends the current file location with each run's
prompt and keeps it out of the stable bundle.
> - Agents can resume unchanged task sessions and use the current
working copy.

## Linked Issues or Issue Description

Fixes: #15373

Refs #14420, which introduced the per-run agent directory copies.
Related PRs checked: #5699 changes the fingerprint algorithm, and #12034
handles saved sessions without a bundle key. Neither fixes this
working-copy path regression.

## What Changed

- Keep instruction and skill contents in the cached system prompt.
Supply the current instruction path and relative-file base in every run
prompt, including resumed turns and fresh retries.
- Report a cwd or execution-target mismatch only when that value
differs. A bundle mismatch no longer produces a false cwd warning.
- Add a four-run regression: initial run, relocated copy, changed
instructions, and changed skill contents. Check the CLI arguments,
bundle keys, current file guidance, and reset logs.
- Cover and explain remote-to-local execution resets, even when the
working directory matches.
- Update the agent-file documentation and existing resume/fallback
assertions.

## Verification

- **Red:** With only the new regression test added, the second run fails
because the CLI arguments do not contain `--resume`.
- **Green:** All 64 tests pass in the command below. This uses a fake
Claude subprocess and real adapter execution, file caching, and session
serialization; it does not call a paid model.

```sh
pnpm exec vitest run server/src/__tests__/claude-local-execute.test.ts packages/adapters/claude-local/src/server/execute.remote.test.ts packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts server/src/__tests__/adapter-session-codecs.test.ts
```

- Repository-wide `pnpm -r typecheck` and `pnpm build`: passed. The
adapter typecheck, build, and all 64 focused tests also pass after the
review fix.
- Local `pnpm test:run` was stopped after it reported a failure in the
unchanged native-session recovery database orchestration test. That test
passes in isolation with PostgreSQL enabled (1 passed, 47 filtered out).
The full local run did not complete; this is not a clean local
full-suite result. All CI checks pass on
`a71d23a39f1cc874d23a8715cf29bdea6edbb8ff`, including the full test
shards.

## Risks

- A session saved with the old path-bearing bundle starts fresh once
after upgrade. Later runs resume when instruction and skill contents
stay unchanged.
- The current location now travels in the run prompt. Stable system
guidance directs relative file references to that location, and each
turn explicitly replaces earlier locations.
- No database, API, authentication, permission, or UI change.

## Model Used

OpenAI Codex (GPT-6), with reasoning, repository inspection, code
execution, and test tools. The exact deployment model ID and context
window are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-07 06:48:19 -05:00
1 parent d0db8820db
commit 1ead554bd1
4 files changed
+137 -26

No files matched your search

+6
View File
@@ -37,6 +37,12 @@ workspace: their independent agent copy therefore lives under the excluded
`.paperclip-runtime/agent-files/<agent>/<run>/` area. It is not included in task
workspace sync, Git staging, or task deliverables.
Claude CLI runs keep the working-copy location in each run's prompt, separate
from the cached system instructions. A new copy path alone does not reset the
task session. Each turn names the current copy for relative file references;
instruction or enabled skill content changes still invalidate the prompt bundle.
Sessions saved with the older path-bearing bundle start fresh once after upgrade.
Regular files (including binary bytes) and directories are supported, up to
100,000 entries (files and folders), 256 MiB per file and 2 GiB total. Symlinks,
hardlinks, and special
@@ -285,6 +285,34 @@ describe("claude remote execution", () => {
expect(call?.[2]).not.toContain("--resume");
});
it("explains a remote-to-local session reset even when the cwd matches", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-local-reset-"));
cleanupDirs.push(rootDir);
vi.stubEnv("PAPERCLIP_HOME", rootDir);
const onLog = vi.fn(async () => {});
await execute({
runId: "run-local-reset",
agent: { id: "agent-1", companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", adapterConfig: {} },
runtime: {
sessionId: "12345678-1234-4abc-9def-123456789012",
sessionParams: {
cwd: rootDir,
remoteExecution: { transport: "ssh", host: "remote.test", port: 22, username: "fixture", remoteCwd: rootDir },
},
sessionDisplayId: null,
taskKey: null,
},
config: { engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] },
context: {},
onLog,
});
expect(runChildProcess.mock.calls[0]?.[2]).not.toContain("--resume");
expect(onLog).toHaveBeenCalledWith("stdout", expect.stringContaining("does not match the current execution target"));
expect(onLog).not.toHaveBeenCalledWith("stdout", expect.stringContaining("was saved for cwd"));
});
it("resumes saved Claude sessions for remote SSH execution when the remote identity matches", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-match-"));
cleanupDirs.push(rootDir);
@@ -499,19 +499,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveClaudeBillingType(effectiveEnv);
const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries));
// When instructionsFilePath is configured, build a stable content-addressed
// file that includes both the file content and the path directive, so we only
// need --append-system-prompt-file (Claude CLI forbids using both flags together).
// Keep per-run working-copy paths out of the content-addressed system prompt.
// Resumed sessions retain that prompt, so refresh the location on every turn.
let combinedInstructionsContents: string | null = null;
let instructionsPathDirective = "";
if (instructionsFilePath) {
try {
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
const pathDirective =
`\nThe above agent instructions were loaded from ${instructionsFilePath}. ` +
instructionsPathDirective =
`Agent instructions for this run were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}. ` +
`This base directory is authoritative for sibling instruction files such as ` +
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`;
combinedInstructionsContents = instructionsContent + pathDirective;
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory. ` +
`This location replaces any instruction file location from earlier turns.`;
combinedInstructionsContents = instructionsContent +
"\nUse the agent instruction file location supplied in the current run prompt to resolve relative file references.";
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
@@ -778,18 +780,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? runtimeMcpServers.length === 0
: runtimeMcpServerIdentity === runtimeMcpIdentity;
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(runtimeSessionId);
const hasMatchingSessionCwd = claudeSessionCwdMatchesExecutionTarget({
runtimeSessionCwd,
effectiveExecutionCwd,
executionTargetIsRemote,
});
const hasMatchingExecutionTarget = adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const canResumeSession =
runtimeSessionId.length > 0 &&
isValidUuid &&
hasMatchingPromptBundle &&
// Each CLI invocation loads --mcp-config, including --resume invocations.
// Refreshing tools does not invalidate the saved conversation.
claudeSessionCwdMatchesExecutionTarget({
runtimeSessionCwd,
effectiveExecutionCwd,
executionTargetIsRemote,
}) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
hasMatchingSessionCwd &&
hasMatchingExecutionTarget;
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !isValidUuid) {
await onLog(
@@ -798,26 +802,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
if (
executionTargetIsRemote &&
runtimeSessionId &&
isValidUuid &&
!canResumeSession
!hasMatchingExecutionTarget
) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
`[paperclip] Claude session "${runtimeSessionId}" does not match the current execution target and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh session.\n`,
);
} else if (
runtimeSessionId &&
isValidUuid &&
runtimeSessionCwd.length > 0 &&
path.resolve(runtimeSessionCwd) !== path.resolve(effectiveExecutionCwd)
!hasMatchingSessionCwd
) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
);
} else if (runtimeSessionId && isValidUuid && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
@@ -916,6 +914,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPathDirective,
renderedBootstrapPrompt,
selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }),
wakePrompt,
@@ -944,7 +943,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
if (attemptInstructionsFilePath && !resumeSessionId) {
commandNotes.push(
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with current file location in the run prompt)`,
);
}
if (runtimeMcpServers.length > 0) {
@@ -685,8 +685,9 @@ describe("claude execute", () => {
expect(captured[1]?.appendedSystemPromptFilePath).toContain("agent-instructions.md");
expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile);
expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions");
expect(captured[1]?.appendedSystemPromptFileContents).toContain(
`The above agent instructions were loaded from ${instructionsFile}. ` +
expect(captured[1]?.appendedSystemPromptFileContents).not.toContain(instructionsFile);
for (const prompt of prompts) expect(prompt).toContain(
`Agent instructions for this run were loaded from ${instructionsFile}. ` +
`Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` +
`This base directory is authoritative for sibling instruction files such as ` +
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`,
@@ -1338,7 +1339,9 @@ describe("claude execute", () => {
expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true);
expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true);
expect(capture1.instructionsContents).toContain("You are managed instructions.");
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
expect(capture1.instructionsContents).not.toContain(instructionsPath);
expect(capture1.prompt).toContain(`Agent instructions for this run were loaded from ${instructionsPath}.`);
expect(capture2.prompt).toContain(`Agent instructions for this run were loaded from ${instructionsPath}.`);
expect(capture1.skillEntries).toContain("paperclip");
expect(capture2.argv).toContain("--resume");
expect(getFreshSessionHandoff).not.toHaveBeenCalled();
@@ -1362,6 +1365,81 @@ describe("claude execute", () => {
}
});
it("resumes across per-run instruction copies and refreshes their location", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-working-copy-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "claude");
const capturePath = path.join(root, "capture.json");
const skillDir = path.join(root, "skill");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "First skill version.\n");
await writeFakeClaudeCommand(commandPath);
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "paperclip-home"));
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
try {
let sessionParams: Record<string, unknown> | null = null;
let previousBundleKey: unknown;
let previousInstructionsPath = "";
for (const [index, change] of ["initial", "location", "instructions", "skill"].entries()) {
const runId = `run-${index}`;
const instructionsPath = path.join(root, "companies", "company-1", "agents", "agent-1",
"file-sync", "runs", runId, "live", "AGENTS.md");
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
await fs.writeFile(instructionsPath, index < 2 ? "First instructions.\n" : "Changed instructions.\n");
if (change === "skill") {
await fs.writeFile(path.join(skillDir, "SKILL.md"), "Changed skill version.\n");
}
const logs: string[] = [];
const result = await execute({
runId,
agent: { id: "agent-1", companyId: "company-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams, sessionDisplayId: null, taskKey: "issue-1" },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
paperclipRuntimeSkills: [{ key: "test-skill", runtimeName: "test-skill", source: skillDir }],
paperclipSkillSync: { desiredSkills: ["test-skill"] },
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath, CLAUDE_CONFIG_DIR: path.join(root, "claude-config") },
},
context: { issueId: "issue-1" },
onLog: async (_stream, chunk) => { logs.push(chunk); },
});
expect(result.exitCode).toBe(0);
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
if (change === "location") {
expect(capture.argv).toContain("--resume");
expect(capture.argv).toContain("11111111-1111-4111-8111-111111111111");
expect(capture.instructionsFilePath).toBeNull();
expect(result.sessionParams?.promptBundleKey).toBe(previousBundleKey);
} else {
expect(capture.argv).not.toContain("--resume");
expect(capture.instructionsContents).toContain(index < 2 ? "First instructions." : "Changed instructions.");
expect(result.sessionParams?.promptBundleKey).not.toBe(previousBundleKey);
if (index > 0) {
expect(capture.instructionsContents).not.toContain(instructionsPath);
expect(logs.join("")).toContain("was saved for prompt bundle");
expect(logs.join("")).not.toContain("was saved for cwd");
}
}
if (index > 0) {
expect(capture.prompt).toContain(instructionsPath);
expect(capture.prompt).toContain(`Resolve any relative file references from ${path.dirname(instructionsPath)}/.`);
}
if (previousInstructionsPath) expect(capture.prompt).not.toContain(previousInstructionsPath);
sessionParams = sessionCodec.deserialize(sessionCodec.serialize(result.sessionParams ?? null));
previousBundleKey = result.sessionParams?.promptBundleKey;
previousInstructionsPath = instructionsPath;
}
} finally {
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
}
});
it("starts a fresh Claude session when the stable prompt bundle changes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-"));
const workspace = path.join(root, "workspace");