Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
376 changes: 374 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Large diffs are not rendered by default.

82 changes: 77 additions & 5 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ import {
type ClaudeExactCursor,
type ClaudeIdleHistory,
} from "../claudeConversationHistory.ts";
import { readClaudeNativeHistoryFile } from "../claudeNativeHistoryFile.ts";
import { claudeTranscriptExists, readClaudeNativeHistoryFile } from "../claudeNativeHistoryFile.ts";
import { spawnAndCollect } from "../providerSnapshot.ts";
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
Expand Down Expand Up @@ -402,6 +402,8 @@ interface ClaudeTaskAgentState {
const PENDING_TASK_ENTRY_CAP = 256;
/** How long Stop waits for Claude to abort a turn before killing the process. */
const CLAUDE_INTERRUPT_GRACE = "3 seconds";
/** Bounds the check for an unsaved session; a slow check keeps the resume. */
const CLAUDE_UNSAVED_RESUME_PROBE_TIMEOUT = "10 seconds";

/**
* Buffers a value that a later task_started reads by tool_use_id (a racing
Expand Down Expand Up @@ -1992,7 +1994,12 @@ function toSessionError(
cause: unknown,
): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
const normalized = toMessage(cause, "").toLowerCase();
if (normalized.includes("unknown session") || normalized.includes("not found")) {
if (
normalized.includes("unknown session") ||
normalized.includes("not found") ||
// Claude Code's reply when resuming a session id it never saved.
normalized.includes("no conversation found")
) {
return new ProviderAdapterSessionNotFoundError({
provider: PROVIDER,
threadId,
Expand Down Expand Up @@ -2322,9 +2329,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
),
),
);
const readHistory = (historySessionId: string) => {
// The SDK scopes a lookup to `dir` when given; "all" searches every project
// the way Claude Code's resume does.
const readHistory = (historySessionId: string, scope: "project" | "all" = "project") => {
const readOptions = {
...(context.session.cwd ? { dir: context.session.cwd } : {}),
...(scope === "project" && context.session.cwd ? { dir: context.session.cwd } : {}),
includeSystemMessages: true,
};
const read = options?.getSessionMessages ?? getSessionMessages;
Expand Down Expand Up @@ -2370,6 +2379,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
return { readHistory, forkHistory };
});

// Proves Claude never saved a session: neither the SDK's all-project lookup
// nor a scan of every project directory finds it. Any read failure fails
// the check so the caller keeps resuming.
const isUnsavedClaudeSession = Effect.fn("isUnsavedClaudeSession")(function* (
session: Pick<ProviderSession, "cwd" | "threadId">,
sessionId: string,
) {
const { readHistory } = yield* makeHistoryAccess({ session });
if ((yield* readHistory(sessionId, "all")).length > 0) return false;
return !(yield* claudeTranscriptExists({ sessionId, environment: claudeEnvironment }).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
));
});

const exactUnavailable = () =>
new ProviderAdapterRequestError({
provider: PROVIDER,
Expand Down Expand Up @@ -4857,8 +4881,54 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}

const startedAt = yield* nowIso;
const resumeState = readClaudeResumeState(input.resumeCursor);
const persistedResumeState = readClaudeResumeState(input.resumeCursor);
const threadId = input.threadId;
// Pylon generates the Claude session id and records it, along with each
// turn's prompt id, before Claude has written a transcript. A restart
// during the first turn can leave a cursor for a conversation Claude never
// saved, and resuming it fails every turn with "No conversation found".
// Turn counters are recorded before the prompt reaches Claude, so they
// prove nothing; only an observed assistant message does. Without one,
// check the history and start fresh only when it is confirmed empty. A
// failed or slow check keeps the resume.
// Imported cursors carry no turn count, so they are never checked. Any
// cursor the adapter has rewritten has one, so the count only narrows the
// check; the transcript scan is what keeps a saved session resuming.
const unprovenResumeSessionId =
exactStart === undefined &&
persistedResumeState?.resume !== undefined &&
persistedResumeState.turnCount !== undefined &&
persistedResumeState.resumeSessionAt === undefined
? persistedResumeState.resume
: undefined;
const resumeUnsaved =
unprovenResumeSessionId !== undefined &&
(yield* isUnsavedClaudeSession(input, unprovenResumeSessionId).pipe(
Effect.timeout(CLAUDE_UNSAVED_RESUME_PROBE_TIMEOUT),
Effect.matchEffect({
onFailure: (cause) =>
Effect.logWarning("claude.session.resume-probe-failed", {
threadId,
sessionId: unprovenResumeSessionId,
cause,
}).pipe(Effect.as(false)),
onSuccess: (unsaved) =>
unsaved
? Effect.logInfo("claude.session.resume-unsaved", {
threadId,
sessionId: unprovenResumeSessionId,
}).pipe(Effect.as(true))
: Effect.succeed(false),
}),
));
const unsavedResumeSessionId = resumeUnsaved ? unprovenResumeSessionId : undefined;
// Claude starts a new session for an unsaved one. The recorded turn ids
// stay so Pylon's turn count and rollback boundaries still line up.
let resumeState = persistedResumeState;
if (unsavedResumeSessionId !== undefined && persistedResumeState !== undefined) {
const { resume: _unsaved, ...carried } = persistedResumeState;
resumeState = carried;
}
const existingResumeSessionId = resumeState?.resume;
const newSessionId = existingResumeSessionId === undefined ? yield* randomUUIDv4 : undefined;
const sessionId = existingResumeSessionId ?? newSessionId;
Expand Down Expand Up @@ -5445,6 +5515,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
existingResumeSessionId !== undefined ? "resume-session" : "generated-session",
"claude.resume.thread_id": resumeState?.threadId ?? "",
"claude.resume.session_id": existingResumeSessionId ?? "",
"claude.resume.unsaved_session_id": unsavedResumeSessionId ?? "",
"claude.resume.session_at": resumeState?.resumeSessionAt ?? "",
"claude.resume.turn_count": resumeState?.turnCount ?? -1,
"claude.query.cwd": input.cwd ?? "",
Expand Down Expand Up @@ -5491,6 +5562,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(input.cwd ? { cwd: input.cwd } : {}),
...(modelSelection?.model ? { model: modelSelection.model } : {}),
...(threadId ? { threadId } : {}),
...(unsavedResumeSessionId !== undefined ? { conversationReset: true } : {}),
resumeCursor: {
...(threadId ? { threadId } : {}),
...(sessionId ? { resume: sessionId } : {}),
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,53 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
}),
);

it.effect("lets a commit guard read the binding and patch only the given fields", () =>
Effect.gen(function* () {
const directory = yield* ProviderSessionDirectory;
const threadId = ThreadId.make("thread-guarded-patch");
yield* directory.upsert({
provider: ProviderDriverKind.make("claudeAgent"),
providerInstanceId: ProviderInstanceId.make("claudeAgent"),
threadId,
status: "running",
resumeCursor: { resume: "current-session" },
runtimePayload: { sessionIncarnationId: "current", continueAfterServerUpdate: "turn-1" },
});
// Startup recovery checks ownership inside the write; the guard's read
// must not wait on the directory's own mutation permit.
const ownedBy = (incarnation: string) =>
directory.getBinding(threadId).pipe(
Effect.map(
(binding) =>
Option.isSome(binding) &&
(binding.value.runtimePayload as Record<string, unknown>).sessionIncarnationId ===
incarnation,
),
Effect.orElseSucceed(() => false),
);
const clearMarker = (incarnation: string) =>
directory.upsert(
{
provider: ProviderDriverKind.make("claudeAgent"),
threadId,
runtimePayload: { continueAfterServerUpdate: null },
},
{ commitGuard: ownedBy(incarnation) },
);

yield* clearMarker("retired");
expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({
runtimePayload: { continueAfterServerUpdate: "turn-1" },
});
yield* clearMarker("current");
expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({
status: "running",
resumeCursor: { resume: "current-session" },
runtimePayload: { sessionIncarnationId: "current", continueAfterServerUpdate: null },
});
}),
);

it.effect("skips a session binding when its private commit guard retires", () =>
Effect.gen(function* () {
const directory = yield* ProviderSessionDirectory;
Expand Down
35 changes: 34 additions & 1 deletion apps/server/src/provider/claudeNativeHistoryFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,42 @@ import * as Option from "effect/Option";
import * as Path from "effect/Path";

import { claudeProjectDirectoryName } from "./claudeConversationHistory.ts";
import { readClaudeNativeHistoryFile } from "./claudeNativeHistoryFile.ts";
import { claudeTranscriptExists, readClaudeNativeHistoryFile } from "./claudeNativeHistoryFile.ts";

it.layer(NodeServices.layer)("bounded Claude native history file", (it) => {
it.effect("finds a transcript in any project and fails when projects cannot be read", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "pylon-claude-transcript-" });
const config = path.join(root, "config");
const id = "550e8400-e29b-41d4-a716-446655440010";
const exists = claudeTranscriptExists({
sessionId: id,
environment: { CLAUDE_CONFIG_DIR: config },
});
// No projects directory: Claude has saved nothing.
assert.equal(yield* exists, false);
yield* fs.makeDirectory(path.join(config, "projects", "-current"), { recursive: true });
assert.equal(yield* exists, false);
yield* fs.makeDirectory(path.join(config, "projects", "-elsewhere"));
yield* fs.writeFileString(path.join(config, "projects", "-elsewhere", `${id}.jsonl`), "{}");
assert.equal(yield* exists, true);

const unreadable: FileSystem.FileSystem = {
...fs,
readDirectory: () => fs.readDirectory(path.join(root, "missing")),
};
assert.equal(
(yield* exists.pipe(
Effect.provideService(FileSystem.FileSystem, unreadable),
Effect.result,
))._tag,
"Failure",
);
}),
);

it.effect("rejects replacement, symlinks, unknown file identity, and oversized content", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
44 changes: 36 additions & 8 deletions apps/server/src/provider/claudeNativeHistoryFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,39 @@ export class ClaudeNativeHistoryUnavailable extends Schema.TaggedError<ClaudeNat
{},
) {}
const MAX_NATIVE_HISTORY_BYTES = 64 * 1024 * 1024;
const MAX_TRANSCRIPT_SCAN_PROJECTS = 10_000;

function claudeProjectsDirectory(path: Path.Path, environment: NodeJS.ProcessEnv) {
const configDir =
environment.CLAUDE_CONFIG_DIR ??
path.join(environment.HOME ?? environment.USERPROFILE ?? NodeOS.homedir(), ".claude");
return path.isAbsolute(configDir) ? path.join(configDir.normalize("NFC"), "projects") : undefined;
}

/**
* Whether any project holds a transcript for this session. Claude Code resumes
* a session id from any project, so only a complete scan proves a session was
* never saved. A directory that cannot be read fails rather than answering no.
*/
export const claudeTranscriptExists = Effect.fn("claudeTranscriptExists")(function* (input: {
readonly sessionId: string;
readonly environment: NodeJS.ProcessEnv;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
if (!/^[0-9a-f-]{36}$/i.test(input.sessionId)) return yield* new ClaudeNativeHistoryUnavailable();
const projectsDir = claudeProjectsDirectory(path, input.environment);
if (projectsDir === undefined) return yield* new ClaudeNativeHistoryUnavailable();
if (!(yield* fs.exists(projectsDir))) return false;
const projects = yield* fs.readDirectory(projectsDir);
// Bound the scan; an unexpectedly large tree answers "unknown", not "absent".
if (projects.length > MAX_TRANSCRIPT_SCAN_PROJECTS)
return yield* new ClaudeNativeHistoryUnavailable();
for (const project of projects) {
if (yield* fs.exists(path.join(projectsDir, project, `${input.sessionId}.jsonl`))) return true;
}
return false;
});

/** Read one current, regular provider-owned transcript through a bounded scoped handle. */
export const readClaudeNativeHistoryFile = Effect.fn("readClaudeNativeHistoryFile")(
Expand All @@ -27,14 +60,9 @@ export const readClaudeNativeHistoryFile = Effect.fn("readClaudeNativeHistoryFil
const path = yield* Path.Path;
if (!/^[0-9a-f-]{36}$/i.test(input.sessionId))
return yield* new ClaudeNativeHistoryUnavailable();
const configDir =
input.environment.CLAUDE_CONFIG_DIR ??
path.join(
input.environment.HOME ?? input.environment.USERPROFILE ?? NodeOS.homedir(),
".claude",
);
if (!path.isAbsolute(configDir)) return yield* new ClaudeNativeHistoryUnavailable();
const root = yield* fs.realPath(path.join(configDir.normalize("NFC"), "projects"));
const projectsDir = claudeProjectsDirectory(path, input.environment);
if (projectsDir === undefined) return yield* new ClaudeNativeHistoryUnavailable();
const root = yield* fs.realPath(projectsDir);
const keys = new Set([claudeProjectDirectoryName(input.canonicalCwd)]);
const override = input.environment.CLAUDE_CONFIG_DIR
? validClaudeProjectDirectoryOverride(input.environment.CLAUDE_CODE_PROJECT_DIR_NAME)
Expand Down
Loading
Loading