From 4ac231d9d6dd67c206892e661628232d0f26154a Mon Sep 17 00:00:00 2001 From: Daniel Gordon Date: Tue, 25 Aug 2026 14:29:10 +0000 Subject: [PATCH] fix(claude): explain missing-transcript resume failures; add `t3 session audit|reset` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending in a Claude thread whose persisted resume cursor points at a transcript that no longer exists on this host (imported from another machine, host reprovisioned) failed with the generic "Claude runtime stream failed." The actual cause — the CLI's `No conversation found with session ID: ` — was emitted one event earlier and then overwritten, so the thread gave no hint at what was missing or how to recover. Adapter: recognise the CLI's missing-transcript result, emit a runtime error that names the session id and the expected `~/.claude/projects//.jsonl` path plus the two ways out (restore the file, or `t3 session reset`), and keep that message through the subsequent stream exit instead of replacing it. The resume cursor is deliberately left intact so the transcript can still be restored from wherever the conversation ran. CLI: `t3 session audit` lists every Claude thread whose transcript is missing (or relocated) with the path to restore; `t3 session reset ` prints the current cursor and, only with `--yes`, clears it so the thread starts a fresh Claude session. Both run offline against the state DB and are safe while the server is serving (WAL; bindings are re-read on every session start). The offline CLI runtime layer previously private to `t3 import` is shared. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 12 + README.md | 1 + apps/server/src/bin.ts | 2 + apps/server/src/cli/import.ts | 32 +- apps/server/src/cli/offlineRuntime.ts | 34 ++ apps/server/src/cli/session.test.ts | 85 ++++ apps/server/src/cli/session.ts | 396 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.test.ts | 80 ++++ .../src/provider/Layers/ClaudeAdapter.ts | 47 ++- .../provider/claudeSessionTranscript.test.ts | 69 +++ .../src/provider/claudeSessionTranscript.ts | 89 ++++ 11 files changed, 812 insertions(+), 35 deletions(-) create mode 100644 apps/server/src/cli/offlineRuntime.ts create mode 100644 apps/server/src/cli/session.test.ts create mode 100644 apps/server/src/cli/session.ts create mode 100644 apps/server/src/provider/claudeSessionTranscript.test.ts create mode 100644 apps/server/src/provider/claudeSessionTranscript.ts 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, + }, + }; +}