diff --git a/AGENTS.md b/AGENTS.md index e671c369928f..1758d10f7a2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,18 @@ sessions T3 itself spawned are skipped (`skipped-owned` — session id found in worktrees dir; `skipped-copy` — a forkSession copy whose message uuids largely already live on another thread), and deleted imported threads stay deleted via the event-log tombstone. +### Threads that fail with "No conversation found with session ID" + +Every send in a Claude thread passes `--resume ` from the thread's +`provider_session_runtime.resume_cursor_json`. If that transcript +(`~/.claude/projects//.jsonl`) is missing +on the host — imported threads whose transcript never lived here, a reprovisioned host — Claude Code +refuses to resume and the thread surfaces a runtime error naming the expected path +(`apps/server/src/provider/claudeSessionTranscript.ts`). The cursor is deliberately left intact so +the file can be restored from wherever the conversation ran. `t3 session audit` (offline, safe while +serving) lists every affected thread; `t3 session reset --yes` clears one cursor so the +thread starts a fresh Claude session (`apps/server/src/cli/session.ts`). + ## Upstream sync PRs: NEVER squash-merge Sync PRs (`merge: upstream through `) must land as a **merge commit** or a diff --git a/README.md b/README.md index 9f4c43f406b6..8277dbcee832 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ A **Settings → Features** page with auto/show/hide toggles for the chat header ### And more - **Resumable Claude Code conversation import** — `t3 import claude ` turns an existing Claude Code transcript into a resumable T3 thread, forking to a new transcript so your original session is untouched. +- **Missing-transcript diagnostics** — when a Claude thread can no longer be resumed because its `~/.claude/projects/…/.jsonl` is gone (host reprovisioned, thread imported from another machine), the thread shows exactly which file is missing instead of a generic stream failure. `t3 session audit` lists every affected thread with the path to restore; `t3 session reset --yes` is the explicit opt-in to start that thread over with a fresh Claude session. - **Android app** — a full native Android build (Ghostty terminal, Shiki-highlighted code blocks, themed native chrome, self-signed-TLS trust, sideload APK publish script). - **File explorer collapsed by default**, and a fix so the **left edge of message lines is selectable**. diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 9e3ef41aa05e..ad7473f9c6b9 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -15,6 +15,7 @@ import { importCommand } from "./cli/import.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { sessionCommand } from "./cli/session.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -50,6 +51,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, importCommand, + sessionCommand, serviceCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/cli/import.ts b/apps/server/src/cli/import.ts index 1560a07dd7cb..f07178f0d2fe 100644 --- a/apps/server/src/cli/import.ts +++ b/apps/server/src/cli/import.ts @@ -27,7 +27,6 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import { isNonConversationalTitle, @@ -43,16 +42,11 @@ import { } from "../import/syncPlan.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; -import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; -import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; -import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as ServerSettings from "../serverSettings.ts"; import { expandHomePath } from "../os-jank.ts"; -import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { OfflineCliRuntimeLive } from "./offlineRuntime.ts"; const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CLAUDE_ADAPTER_KEY = "claudeAgent"; @@ -63,24 +57,6 @@ class ImportCommandError extends Data.TaggedError("ImportCommandError")<{ readonly message: string; }> {} -/** - * Offline runtime for `t3 import`. Mirrors `ProjectCliRuntimeLive` - * (orchestration engine + projection snapshot + sqlite + workspace paths) - * and additionally provides the provider session directory (so we can seed - * the resume binding) and server settings (so we can resolve the Claude - * provider instance). `FileSystem`, `Path`, and `Crypto` are satisfied by the - * ambient CLI runtime layer (NodeServices) provided in `bin.ts`. - */ -const ImportCliRuntimeLive = Layer.mergeAll( - WorkspacePaths.layer, - ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)), - ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntimeRepositoryLive)), - OrchestrationLayerLive, -).pipe( - Layer.provideMerge(RepositoryIdentityResolver.layer), - Layer.provideMerge(SqlitePersistenceLayerLive), -); - const claudeModel = DEFAULT_MODEL_BY_PROVIDER[CLAUDE_DRIVER_KIND] ?? "claude-sonnet-5"; const claudeUuid = Crypto.Crypto.pipe( @@ -616,7 +592,7 @@ const importClaudeCommand = Command.make("claude", { }); }).pipe( Effect.provide( - ImportCliRuntimeLive.pipe( + OfflineCliRuntimeLive.pipe( Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ), @@ -896,7 +872,7 @@ const importSyncCommand = Command.make("sync", { } }).pipe( Effect.provide( - ImportCliRuntimeLive.pipe( + OfflineCliRuntimeLive.pipe( Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ), @@ -1020,7 +996,7 @@ const importRetitleCommand = Command.make("retitle", { } }).pipe( Effect.provide( - ImportCliRuntimeLive.pipe( + OfflineCliRuntimeLive.pipe( Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ), diff --git a/apps/server/src/cli/offlineRuntime.ts b/apps/server/src/cli/offlineRuntime.ts new file mode 100644 index 000000000000..0be9535abf35 --- /dev/null +++ b/apps/server/src/cli/offlineRuntime.ts @@ -0,0 +1,34 @@ +import * as Layer from "effect/Layer"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; + +/** + * Offline runtime for CLI subcommands that operate directly on the T3 state + * database without a running server (`t3 import`, `t3 session`). Mirrors + * `ProjectCliRuntimeLive` (orchestration engine + projection snapshot + sqlite + * + workspace paths) and additionally provides the provider session directory + * (resume bindings) and server settings (provider instance resolution). + * `FileSystem`, `Path`, and `Crypto` are satisfied by the ambient CLI runtime + * layer (NodeServices) provided in `bin.ts`. Callers still supply + * `ServerConfig` and `MinimumLogLevel`. + * + * SQLite runs in WAL mode, so these commands are safe to run while the server + * is serving; the server re-reads resume bindings from the database on every + * session start. + */ +export const OfflineCliRuntimeLive = Layer.mergeAll( + WorkspacePaths.layer, + ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)), + ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntimeRepositoryLive)), + OrchestrationLayerLive, +).pipe( + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(SqlitePersistenceLayerLive), +); diff --git a/apps/server/src/cli/session.test.ts b/apps/server/src/cli/session.test.ts new file mode 100644 index 000000000000..475fa49f0ca7 --- /dev/null +++ b/apps/server/src/cli/session.test.ts @@ -0,0 +1,85 @@ +import { assert, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +import type { ProviderRuntimeBinding } from "../provider/Services/ProviderSessionDirectory.ts"; +import { formatAuditRow, readClaudeResumeTarget, threadLifecycle } from "./session.ts"; + +const THREAD_ID = ThreadId.make("thread-1"); +const SESSION_ID = "9af5bb2c-886f-474a-9caa-af43d15fed38"; + +function claudeBinding(overrides: Partial = {}): ProviderRuntimeBinding { + return { + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + resumeCursor: { threadId: THREAD_ID, resume: SESSION_ID, turnCount: 0 }, + runtimePayload: { cwd: "/home/me/app", model: "claude-fable-5" }, + ...overrides, + }; +} + +it("reads the resume target from a Claude binding", () => { + assert.deepStrictEqual(readClaudeResumeTarget(claudeBinding()), { + threadId: THREAD_ID, + sessionId: SESSION_ID, + cwd: "/home/me/app", + }); +}); + +it("keeps fork cursors (imported threads) and tolerates a missing cwd", () => { + const target = readClaudeResumeTarget( + claudeBinding({ + resumeCursor: { threadId: THREAD_ID, resume: SESSION_ID, forkSession: true }, + runtimePayload: null, + }), + ); + assert.deepStrictEqual(target, { threadId: THREAD_ID, sessionId: SESSION_ID, cwd: undefined }); +}); + +it("ignores non-Claude bindings and bindings without a resume id", () => { + assert.strictEqual( + readClaudeResumeTarget(claudeBinding({ provider: ProviderDriverKind.make("codex") })), + undefined, + ); + assert.strictEqual(readClaudeResumeTarget(claudeBinding({ resumeCursor: null })), undefined); + assert.strictEqual( + readClaudeResumeTarget(claudeBinding({ resumeCursor: { threadId: THREAD_ID, turnCount: 2 } })), + undefined, + ); + assert.strictEqual(readClaudeResumeTarget(claudeBinding({ resumeCursor: "junk" })), undefined); +}); + +it("classifies thread lifecycle from the projection", () => { + const base = { archivedAt: null, deletedAt: null }; + assert.strictEqual(threadLifecycle(undefined), "unknown"); + assert.strictEqual(threadLifecycle(base as never), "active"); + assert.strictEqual( + threadLifecycle({ ...base, archivedAt: "2026-01-01T00:00:00.000Z" } as never), + "archived", + ); + assert.strictEqual( + threadLifecycle({ + archivedAt: "2026-01-01T00:00:00.000Z", + deletedAt: "2026-02-01T00:00:00.000Z", + } as never), + "deleted", + ); +}); + +it("formats a missing row with the path to restore", () => { + const line = formatAuditRow({ + threadId: THREAD_ID, + title: 'Second "Brain"', + lifecycle: "active", + sessionId: SESSION_ID, + cwd: "/home/me/app", + location: { + kind: "missing", + expectedPath: `/home/me/.claude/projects/-home-me-app/${SESSION_ID}.jsonl`, + }, + }); + assert.strictEqual( + line, + `thread=thread-1 lifecycle=active title="Second \\"Brain\\"" session=${SESSION_ID} cwd=/home/me/app expected=/home/me/.claude/projects/-home-me-app/${SESSION_ID}.jsonl`, + ); +}); diff --git a/apps/server/src/cli/session.ts b/apps/server/src/cli/session.ts new file mode 100644 index 000000000000..b951d1e1baaf --- /dev/null +++ b/apps/server/src/cli/session.ts @@ -0,0 +1,396 @@ +import { type OrchestrationThread, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; + +import * as ServerConfig from "../config.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { expandHomePath } from "../os-jank.ts"; +import { claudeTranscriptRelativePath } from "../provider/claudeSessionTranscript.ts"; +import { + ProviderSessionDirectory, + type ProviderRuntimeBinding, +} from "../provider/Services/ProviderSessionDirectory.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { OfflineCliRuntimeLive } from "./offlineRuntime.ts"; + +const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); +const encodeJsonString = Schema.encodeSync(Schema.UnknownFromJsonString); + +class SessionCommandError extends Data.TaggedError("SessionCommandError")<{ + readonly message: string; +}> {} + +/** + * A Claude thread whose persisted binding will make the next send pass + * `--resume ` to Claude Code. + */ +export interface ClaudeResumeTarget { + readonly threadId: ThreadId; + readonly sessionId: string; + readonly cwd: string | undefined; +} + +function readStringField(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = (value as Record)[key]; + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Extract the resume target from a binding, or `undefined` for non-Claude + * bindings and bindings without a resume session id (a fresh session will be + * generated for those, so there is nothing to audit). + */ +export function readClaudeResumeTarget( + binding: ProviderRuntimeBinding, +): ClaudeResumeTarget | undefined { + if (binding.provider !== CLAUDE_DRIVER_KIND) return undefined; + const sessionId = readStringField(binding.resumeCursor, "resume"); + if (sessionId === undefined) return undefined; + return { + threadId: binding.threadId, + sessionId, + cwd: readStringField(binding.runtimePayload, "cwd"), + }; +} + +export type ThreadLifecycle = "active" | "archived" | "deleted" | "unknown"; + +export function threadLifecycle(thread: OrchestrationThread | undefined): ThreadLifecycle { + if (thread === undefined) return "unknown"; + if (thread.deletedAt !== null) return "deleted"; + if (thread.archivedAt !== null) return "archived"; + return "active"; +} + +export type TranscriptLocation = + | { readonly kind: "present"; readonly path: string } + | { readonly kind: "relocated"; readonly expectedPath: string | undefined; readonly path: string } + | { readonly kind: "missing"; readonly expectedPath: string | undefined }; + +export interface SessionAuditRow { + readonly threadId: ThreadId; + readonly title: string | undefined; + readonly lifecycle: ThreadLifecycle; + readonly sessionId: string; + readonly cwd: string | undefined; + readonly location: TranscriptLocation; +} + +export function formatAuditRow(row: SessionAuditRow): string { + const parts = [ + `thread=${row.threadId}`, + `lifecycle=${row.lifecycle}`, + `title=${encodeJsonString(row.title ?? "")}`, + `session=${row.sessionId}`, + `cwd=${row.cwd ?? "?"}`, + ]; + switch (row.location.kind) { + case "missing": + parts.push(`expected=${row.location.expectedPath ?? "?"}`); + break; + case "relocated": + parts.push(`expected=${row.location.expectedPath ?? "?"}`, `found=${row.location.path}`); + break; + case "present": + parts.push(`path=${row.location.path}`); + break; + } + return parts.join(" "); +} + +/** + * Where Claude Code will look for the transcript. The expected location is + * derived from the persisted cwd; as a fallback every project folder is + * searched, because a transcript that moved (cwd renamed, worktree path + * changed) is recoverable without copying anything. + */ +const locateTranscript = Effect.fn("locateTranscript")(function* ( + projectsRoot: string, + projectDirs: ReadonlyArray, + target: ClaudeResumeTarget, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const expectedPath = + target.cwd !== undefined + ? path.join(projectsRoot, claudeTranscriptRelativePath(target.cwd, target.sessionId)) + : undefined; + if (expectedPath !== undefined) { + const exists = yield* fs.exists(expectedPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return { kind: "present", path: expectedPath } satisfies TranscriptLocation; + } + } + const fileName = `${target.sessionId}.jsonl`; + for (const dir of projectDirs) { + const candidate = path.join(projectsRoot, dir, fileName); + if (candidate === expectedPath) continue; + const exists = yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return { kind: "relocated", expectedPath, path: candidate } satisfies TranscriptLocation; + } + } + return { kind: "missing", expectedPath } satisfies TranscriptLocation; +}); + +const projectsDirFlag = Flag.string("projects-dir").pipe( + Flag.withDescription( + "Directory containing Claude project transcript folders (defaults to ~/.claude/projects).", + ), + Flag.optional, +); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Print the audit as JSON, one object per line per reported thread."), +); + +const includeDeletedFlag = Flag.boolean("include-deleted").pipe( + Flag.withDescription("Also report threads that were deleted in T3."), +); + +const allFlag = Flag.boolean("all").pipe( + Flag.withDescription( + "Report every Claude thread with a resume cursor, including those whose transcript is present.", + ), +); + +const sessionAuditCommand = Command.make("audit", { + ...projectLocationFlags, + projectsDir: projectsDirFlag, + json: jsonFlag, + includeDeleted: includeDeletedFlag, + all: allFlag, +}).pipe( + Command.withDescription( + "List Claude threads whose resume cursor points at a transcript that is missing from this machine (sending in them fails until the .jsonl is restored or the thread is reset).", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig({ baseDir: flags.baseDir }, logLevel); + const minimumLogLevel = config.logLevel; + + const fs = yield* FileSystem.FileSystem; + const projectsRoot = Option.isSome(flags.projectsDir) + ? flags.projectsDir.value + : yield* expandHomePath("~/.claude/projects"); + const projectDirs = yield* fs + .readDirectory(projectsRoot) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + + const rows = yield* Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const snapshotQuery = yield* ProjectionSnapshotQuery; + const snapshot = yield* snapshotQuery.getSnapshot().pipe( + Effect.mapError( + (cause) => + new SessionCommandError({ + message: `Failed to read orchestration snapshot: ${String(cause)}.`, + }), + ), + ); + const threadsById = new Map(snapshot.threads.map((thread) => [thread.id, thread])); + const bindings = yield* directory.listBindings().pipe( + Effect.mapError( + (cause) => + new SessionCommandError({ + message: `Failed to read provider session bindings: ${String(cause)}.`, + }), + ), + ); + + const collected: Array = []; + for (const binding of bindings) { + const target = readClaudeResumeTarget(binding); + if (target === undefined) continue; + const thread = threadsById.get(target.threadId); + const lifecycle = threadLifecycle(thread); + if (lifecycle === "deleted" && !flags.includeDeleted) continue; + const location = yield* locateTranscript(projectsRoot, projectDirs, target); + collected.push({ + threadId: target.threadId, + title: thread?.title, + lifecycle, + sessionId: target.sessionId, + cwd: target.cwd, + location, + }); + } + return collected; + }).pipe( + Effect.provide( + OfflineCliRuntimeLive.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); + + const missing = rows.filter((row) => row.location.kind === "missing"); + const relocated = rows.filter((row) => row.location.kind === "relocated"); + const present = rows.filter((row) => row.location.kind === "present"); + const lifecycleOrder: Record = { + active: 0, + archived: 1, + unknown: 2, + deleted: 3, + }; + const byLifecycle = (a: SessionAuditRow, b: SessionAuditRow) => + lifecycleOrder[a.lifecycle] - lifecycleOrder[b.lifecycle] || + a.threadId.localeCompare(b.threadId); + const reported = (flags.all ? rows : [...missing, ...relocated]).sort(byLifecycle); + + if (flags.json) { + for (const row of reported) { + yield* Console.log(encodeJsonString(row)); + } + return; + } + + yield* Console.log( + `Claude threads with a resume cursor: ${rows.length} ` + + `(${present.length} transcript present, ${relocated.length} relocated, ${missing.length} missing)` + + (flags.includeDeleted ? "" : "; deleted threads not included") + + `. Projects root: ${projectsRoot}`, + ); + for (const row of reported) { + yield* Console.log(formatAuditRow(row)); + } + if (missing.length > 0) { + yield* Console.log( + "\nMissing transcripts: sending in these threads fails with " + + '"No conversation found with session ID". Copy each .jsonl back to the ' + + "`expected=` path (e.g. from the machine where the conversation ran) and simply " + + "send again, or run `t3 session reset ` to start a fresh Claude session " + + "without the earlier context. Nothing is changed by this audit.", + ); + } + if (relocated.length > 0) { + yield* Console.log( + "\nRelocated transcripts exist under a different project folder than the thread's " + + "cwd; Claude Code usually resolves these on its own. If sending still fails, copy the " + + "`found=` file to the `expected=` path.", + ); + } + }), + ), +); + +const yesFlag = Flag.boolean("yes").pipe( + Flag.withDescription( + "Actually clear the resume cursor. Without this flag the command only reports what it would do.", + ), +); + +const sessionResetCommand = Command.make("reset", { + ...projectLocationFlags, + yes: yesFlag, + threadId: Argument.string("threadId").pipe( + Argument.withDescription("T3 thread id whose provider resume cursor should be cleared."), + ), +}).pipe( + Command.withDescription( + "Clear a thread's persisted resume cursor so its next message starts a fresh provider session (use after `t3 session audit`; the thread's T3 history is kept, the provider-side context is not).", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig({ baseDir: flags.baseDir }, logLevel); + const minimumLogLevel = config.logLevel; + const trimmed = flags.threadId.trim(); + if (trimmed.length === 0) { + return yield* new SessionCommandError({ message: "threadId cannot be empty." }); + } + const threadId = ThreadId.make(trimmed); + + yield* Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const binding = Option.getOrUndefined( + yield* directory.getBinding(threadId).pipe( + Effect.mapError( + (cause) => + new SessionCommandError({ + message: `Failed to read the provider session binding: ${String(cause)}.`, + }), + ), + ), + ); + if (binding === undefined) { + return yield* new SessionCommandError({ + message: `Thread ${threadId} has no provider session binding; nothing to reset.`, + }); + } + if (binding.resumeCursor === null || binding.resumeCursor === undefined) { + yield* Console.log( + `Thread ${threadId} (${binding.provider}) has no resume cursor; its next message already starts a fresh session.`, + ); + return; + } + + const cursorJson = encodeJsonString(binding.resumeCursor); + yield* Console.log( + [ + `Thread ${threadId} (${binding.provider}${binding.providerInstanceId ? `, instance ${binding.providerInstanceId}` : ""})`, + ` current resume cursor: ${cursorJson}`, + ` keep this line if you may want to restore the cursor by hand later.`, + ].join("\n"), + ); + + if (!flags.yes) { + yield* Console.log( + "Dry run: no changes made. Re-run with --yes to clear the cursor. " + + "If the transcript can still be recovered from another machine, restore it instead of resetting.", + ); + return; + } + + yield* directory + .upsert({ + threadId, + provider: binding.provider, + ...(binding.providerInstanceId !== undefined + ? { providerInstanceId: binding.providerInstanceId } + : {}), + resumeCursor: null, + status: "stopped", + }) + .pipe( + Effect.mapError( + (cause) => + new SessionCommandError({ + message: `Failed to clear the resume cursor: ${String(cause)}.`, + }), + ), + ); + yield* Console.log( + `Cleared the resume cursor for thread ${threadId}. Its next message starts a fresh ${binding.provider} session ` + + "(T3 history stays; provider-side context does not). Takes effect on the next session start — " + + "if the server currently holds a live provider process for this thread, stop that thread first.", + ); + }).pipe( + Effect.provide( + OfflineCliRuntimeLive.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); + }), + ), +); + +export const sessionCommand = Command.make("session").pipe( + Command.withDescription("Inspect and repair provider session bindings (resume cursors)."), + Command.withSubcommands([sessionAuditCommand, sessionResetCommand]), +); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ce3ed7f0f7a4..14a52811a608 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2959,6 +2959,86 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "explains a resume whose transcript is missing and keeps that explanation on stream exit", + () => { + const harness = makeHarness(); + const missingSessionId = "9af5bb2c-886f-474a-9caa-af43d15fed38"; + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + cwd: "/home/me/projects/voice-ai-integration", + resumeCursor: { + threadId: RESUME_THREAD_ID, + resume: missingSessionId, + turnCount: 0, + }, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: RESUME_THREAD_ID, + input: "hello again", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: [`No conversation found with session ID: ${missingSessionId}`], + session_id: missingSessionId, + uuid: "result-missing-transcript", + } as unknown as SDKMessage); + harness.query.fail(new Error("Claude Code process exited")); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + runtimeEventsFiber.interruptUnsafe(); + + const runtimeErrors = runtimeEvents.filter((event) => event.type === "runtime.error"); + assert.equal(runtimeErrors.length, 1); + const runtimeError = runtimeErrors[0]; + assert.equal(runtimeError?.type, "runtime.error"); + if (runtimeError?.type === "runtime.error") { + assert.include( + runtimeError.payload.message, + `Claude could not resume session ${missingSessionId}`, + ); + assert.include( + runtimeError.payload.message, + `~/.claude/projects/-home-me-projects-voice-ai-integration/${missingSessionId}.jsonl`, + ); + assert.include(runtimeError.payload.message, `t3 session reset ${RESUME_THREAD_ID}`); + assert.deepEqual(runtimeError.payload.detail, { + reason: "resume_transcript_missing", + sessionId: missingSessionId, + expectedTranscriptPath: `~/.claude/projects/-home-me-projects-voice-ai-integration/${missingSessionId}.jsonl`, + }); + } + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "failed"); + assert.include(completed.payload.errorMessage ?? "", "Claude could not resume session"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("passes Claude resume ids without pinning a stale assistant checkpoint", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index bd746a500c9d..31a8172af2f8 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -72,6 +72,10 @@ import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { + describeMissingResumeTranscript, + readMissingResumeSessionId, +} from "../claudeSessionTranscript.ts"; import { getClaudeModelCapabilities, isClaudeUltracodeEffort, @@ -187,7 +191,12 @@ interface ClaudeSessionContext { readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; currentApiModelId: string | undefined; + readonly cwd: string | undefined; resumeSessionId: string | undefined; + // Set when the CLI refused to resume because the transcript is missing on + // this machine. `handleStreamExit` reuses it so the generic stream-failure + // text does not overwrite the actionable explanation already emitted. + resumeFailureMessage: string | undefined; // When true, the persisted resume cursor still requests a fork of // `forkOriginSessionId`. Cleared once the SDK reports the forked session's // new id, so later turns continue the fork instead of re-forking the origin. @@ -2581,10 +2590,28 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const status = turnStatusFromResult(message); - const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + const rawErrorMessage = message.subtype === "success" ? undefined : message.errors[0]; + const missingResumeSessionId = + status === "failed" ? readMissingResumeSessionId(rawErrorMessage) : undefined; + let errorMessage = rawErrorMessage; if (status === "failed") { - yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); + if (missingResumeSessionId !== undefined) { + // The persisted resume cursor points at a transcript Claude Code can + // no longer find. Surface what happened and how to recover instead of + // the bare CLI text; the cursor itself is left untouched on purpose so + // the transcript can still be restored (see claudeSessionTranscript.ts). + const described = describeMissingResumeTranscript({ + threadId: context.session.threadId, + sessionId: missingResumeSessionId, + cwd: context.cwd, + }); + errorMessage = described.message; + context.resumeFailureMessage = described.message; + yield* emitRuntimeError(context, described.message, described.detail); + } else { + yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); + } } yield* completeTurn(context, status, errorMessage, message); @@ -3037,11 +3064,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const failures = exit.cause.reasons.flatMap((reason) => Cause.isFailReason(reason) ? [reason.error] : [], ); - const message = failures[0]?.detail ?? "Claude runtime stream failed."; - yield* emitRuntimeError(context, message, { - failureCount: failures.length, - failureTags: failures.map((failure) => failure._tag), - }); + const resumeFailureMessage = context.resumeFailureMessage; + const message = + resumeFailureMessage ?? failures[0]?.detail ?? "Claude runtime stream failed."; + if (resumeFailureMessage === undefined) { + yield* emitRuntimeError(context, message, { + failureCount: failures.length, + failureTags: failures.map((failure) => failure._tag), + }); + } yield* completeTurn(context, "failed", message); } } else if (context.turnState) { @@ -3670,7 +3701,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( startedAt, basePermissionMode: permissionMode, currentApiModelId: apiModelId, + cwd: input.cwd, resumeSessionId: sessionId, + resumeFailureMessage: undefined, forkSession, forkOriginSessionId: forkSession ? existingResumeSessionId : undefined, pendingApprovals, diff --git a/apps/server/src/provider/claudeSessionTranscript.test.ts b/apps/server/src/provider/claudeSessionTranscript.test.ts new file mode 100644 index 000000000000..f2a2e3b47b88 --- /dev/null +++ b/apps/server/src/provider/claudeSessionTranscript.test.ts @@ -0,0 +1,69 @@ +import { assert, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; + +import { + claudeTranscriptRelativePath, + describeMissingResumeTranscript, + encodeClaudeProjectDirName, + readMissingResumeSessionId, +} from "./claudeSessionTranscript.ts"; + +const SESSION_ID = "9af5bb2c-886f-474a-9caa-af43d15fed38"; + +it("encodes a working directory the way Claude Code names project folders", () => { + assert.strictEqual( + encodeClaudeProjectDirName("/home/dgordon/projects/voice-ai-integration"), + "-home-dgordon-projects-voice-ai-integration", + ); + assert.strictEqual(encodeClaudeProjectDirName("/tmp/a.b_c d"), "-tmp-a-b-c-d"); + assert.strictEqual( + claudeTranscriptRelativePath("/home/me/app", SESSION_ID), + `-home-me-app/${SESSION_ID}.jsonl`, + ); +}); + +it("recognises the CLI's missing-transcript resume failure", () => { + assert.strictEqual( + readMissingResumeSessionId(`No conversation found with session ID: ${SESSION_ID}`), + SESSION_ID, + ); + assert.strictEqual( + readMissingResumeSessionId( + `Error: No conversation found with session ID ${SESSION_ID.toUpperCase()}`, + ), + SESSION_ID, + ); + assert.strictEqual(readMissingResumeSessionId("Error: Request was aborted."), undefined); + assert.strictEqual( + readMissingResumeSessionId("No conversation found with session ID: nope"), + undefined, + ); + assert.strictEqual(readMissingResumeSessionId(undefined), undefined); +}); + +it("explains the missing transcript with the expected path and the explicit reset command", () => { + const threadId = ThreadId.make("thread-1"); + const described = describeMissingResumeTranscript({ + threadId, + sessionId: SESSION_ID, + cwd: "/home/dgordon/projects/voice-ai-integration", + }); + assert.include( + described.message, + `~/.claude/projects/-home-dgordon-projects-voice-ai-integration/${SESSION_ID}.jsonl`, + ); + assert.include(described.message, "t3 session reset thread-1"); + assert.deepStrictEqual(described.detail, { + reason: "resume_transcript_missing", + sessionId: SESSION_ID, + expectedTranscriptPath: `~/.claude/projects/-home-dgordon-projects-voice-ai-integration/${SESSION_ID}.jsonl`, + }); + + const withoutCwd = describeMissingResumeTranscript({ + threadId, + sessionId: SESSION_ID, + cwd: undefined, + }); + assert.include(withoutCwd.message, "expected under ~/.claude/projects"); + assert.strictEqual(withoutCwd.detail.expectedTranscriptPath, undefined); +}); diff --git a/apps/server/src/provider/claudeSessionTranscript.ts b/apps/server/src/provider/claudeSessionTranscript.ts new file mode 100644 index 000000000000..4de0e58f1099 --- /dev/null +++ b/apps/server/src/provider/claudeSessionTranscript.ts @@ -0,0 +1,89 @@ +import type { ThreadId } from "@t3tools/contracts"; + +/** + * Helpers for reasoning about where Claude Code keeps a session's transcript + * and for recognising the CLI's "transcript is gone" resume failure. + * + * Claude Code persists each session as + * `~/.claude/projects//.jsonl`, where the encoding + * replaces every character outside `[A-Za-z0-9]` with `-`. When T3 resumes a + * thread it passes `--resume `; if that file no longer exists on + * this machine (the thread was imported from elsewhere, the host was + * reprovisioned, the file was pruned) the CLI answers with a `result` error + * whose text starts with `No conversation found with session ID:`. Without + * special handling that message is immediately buried under the generic + * "Claude runtime stream failed." that follows when the process exits, so the + * user never learns what actually happened or how to fix it. + */ + +export const CLAUDE_PROJECTS_DIR_DISPLAY = "~/.claude/projects"; + +const MISSING_RESUME_SESSION_PATTERN = + /No conversation found with session ID:?\s*([0-9a-fA-F-]{36})/; + +/** + * Mirror Claude Code's project-directory encoding of a working directory. + */ +export function encodeClaudeProjectDirName(cwd: string): string { + return cwd.replace(/[^a-zA-Z0-9]/g, "-"); +} + +/** + * Relative transcript location (`/.jsonl`) beneath a + * Claude projects root. Callers join it onto the real root for filesystem + * checks or onto the `~/.claude/projects` display form for messages. + */ +export function claudeTranscriptRelativePath(cwd: string, sessionId: string): string { + return `${encodeClaudeProjectDirName(cwd)}/${sessionId}.jsonl`; +} + +/** + * Extract the session id from the CLI's missing-transcript resume failure, or + * `undefined` when the error text is anything else. + */ +export function readMissingResumeSessionId(errorText: string | undefined): string | undefined { + if (!errorText) return undefined; + const match = MISSING_RESUME_SESSION_PATTERN.exec(errorText); + return match?.[1]?.toLowerCase(); +} + +export interface MissingResumeTranscriptDetail { + readonly reason: "resume_transcript_missing"; + readonly sessionId: string; + readonly expectedTranscriptPath: string | undefined; +} + +/** + * Build the user-facing explanation for a resume that failed because the + * transcript is missing. Deliberately does NOT clear or rewrite the resume + * cursor: the transcript may still be recoverable from another machine, and + * discarding the pointer would make that impossible. `t3 session reset` is + * the explicit opt-in for starting over. + */ +export function describeMissingResumeTranscript(input: { + readonly threadId: ThreadId; + readonly sessionId: string; + readonly cwd: string | undefined; +}): { readonly message: string; readonly detail: MissingResumeTranscriptDetail } { + const expectedTranscriptPath = + input.cwd !== undefined + ? `${CLAUDE_PROJECTS_DIR_DISPLAY}/${claudeTranscriptRelativePath(input.cwd, input.sessionId)}` + : undefined; + const location = + expectedTranscriptPath !== undefined + ? `expected at ${expectedTranscriptPath}` + : `expected under ${CLAUDE_PROJECTS_DIR_DISPLAY}`; + const message = + `Claude could not resume session ${input.sessionId}: its transcript is missing on this machine (${location}). ` + + `The thread's history in T3 is intact, but Claude cannot continue it until the transcript is restored. ` + + `Copy the .jsonl file back from the machine where the conversation ran (then just send again), ` + + `or run \`t3 session reset ${input.threadId}\` to start a fresh Claude session without the earlier context.`; + return { + message, + detail: { + reason: "resume_transcript_missing", + sessionId: input.sessionId, + expectedTranscriptPath, + }, + }; +}