Skip to content

Commit defac24

Browse files
committed
Preserve Claude sessions and roll back failed updates
- Enable Claude session persistence and surface transcript persistence warnings - Restore the previous Claude executable when an update or verification fails - Update workspace dependency metadata and lockfile
1 parent b21965d commit defac24

5 files changed

Lines changed: 244 additions & 49 deletions

File tree

apps/server/src/provider/Drivers/ClaudeDriver.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,40 @@ try {
166166
167167
$installDir = Split-Path -Parent $installPath
168168
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
169-
Move-Item -LiteralPath $downloadPath -Destination $installPath -Force
170-
171-
$installedVersion = & $installPath --version
172-
if ($LASTEXITCODE -ne 0 -or $installedVersion -notmatch [regex]::Escape($version)) {
173-
Write-Error "Claude Code was replaced, but version verification failed: $installedVersion"
174-
exit 1
169+
$backupPath = Join-Path $downloadDir "claude-backup-$PID-$([guid]::NewGuid().ToString('N')).exe"
170+
$preservedExistingExecutable = Test-Path -LiteralPath $installPath
171+
if ($preservedExistingExecutable) {
172+
Copy-Item -LiteralPath $installPath -Destination $backupPath -Force
175173
}
176174
177-
Write-Output "Installed Claude Code $installedVersion at $installPath"
175+
try {
176+
Move-Item -LiteralPath $downloadPath -Destination $installPath -Force
177+
178+
$installedVersion = & $installPath --version
179+
if ($LASTEXITCODE -ne 0 -or $installedVersion -notmatch [regex]::Escape($version)) {
180+
throw "Claude Code was replaced, but version verification failed: $installedVersion"
181+
}
182+
183+
if (Test-Path -LiteralPath $backupPath) {
184+
Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue
185+
}
186+
Write-Output "Installed Claude Code $installedVersion at $installPath"
187+
} catch {
188+
$updateFailure = $_
189+
if ($preservedExistingExecutable -and (Test-Path -LiteralPath $backupPath)) {
190+
try {
191+
Copy-Item -LiteralPath $backupPath -Destination $installPath -Force
192+
Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue
193+
Write-Warning "Claude update failed; restored the previous executable at $installPath."
194+
} catch {
195+
Write-Error "Claude update failed and Threadlines could not restore the previous executable. The backup remains at $backupPath."
196+
throw
197+
}
198+
} elseif (Test-Path -LiteralPath $installPath) {
199+
Remove-Item -LiteralPath $installPath -Force -ErrorAction SilentlyContinue
200+
}
201+
throw $updateFailure
202+
}
178203
} finally {
179204
if (Test-Path -LiteralPath $downloadPath) {
180205
Remove-Item -LiteralPath $downloadPath -Force

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ describe("ClaudeAdapterLive", () => {
549549
assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]);
550550
assert.equal(createInput?.options.enableFileCheckpointing, true);
551551
assert.equal(createInput?.options.promptSuggestions, true);
552+
assert.equal(createInput?.options.persistSession, true);
552553
assert.equal(createInput?.options.permissionMode, undefined);
553554
assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined);
554555
}).pipe(
@@ -4681,6 +4682,90 @@ describe("ClaudeAdapterLive", () => {
46814682
);
46824683
});
46834684

4685+
it.effect("warns once when the inherited environment disables session persistence", () => {
4686+
const harness = makeHarness({
4687+
environment: {
4688+
...missingClaudeConfigEnvironment(),
4689+
CLAUDE_CODE_SKIP_PROMPT_HISTORY: "1",
4690+
},
4691+
});
4692+
return Effect.gen(function* () {
4693+
const adapter = yield* ClaudeAdapter;
4694+
4695+
const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe(
4696+
Stream.runCollect,
4697+
Effect.forkChild,
4698+
);
4699+
4700+
yield* adapter.startSession({
4701+
threadId: THREAD_ID,
4702+
provider: ProviderDriverKind.make("claudeAgent"),
4703+
runtimeMode: "full-access",
4704+
});
4705+
4706+
harness.query.emit({
4707+
type: "system",
4708+
subtype: "informational",
4709+
content:
4710+
"CLAUDE_CODE_SKIP_PROMPT_HISTORY is set --resume will not find this session; if unintended, unset it and restart",
4711+
level: "warning",
4712+
session_id: "sdk-session-persistence-disabled",
4713+
uuid: "persistence-disabled-1",
4714+
} as unknown as SDKMessage);
4715+
harness.query.finish();
4716+
4717+
const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
4718+
const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");
4719+
assert.equal(warnings.length, 1);
4720+
assert.include(warnings[0]?.payload.message ?? "", "CLAUDE_CODE_SKIP_PROMPT_HISTORY");
4721+
assert.equal(warnings[0]?.payload.warningKind, "session-persistence");
4722+
}).pipe(
4723+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
4724+
Effect.provide(harness.layer),
4725+
);
4726+
});
4727+
4728+
it.effect("surfaces Claude transcript-write informational warnings", () => {
4729+
const harness = makeHarness();
4730+
return Effect.gen(function* () {
4731+
const adapter = yield* ClaudeAdapter;
4732+
4733+
const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe(
4734+
Stream.runCollect,
4735+
Effect.forkChild,
4736+
);
4737+
4738+
yield* adapter.startSession({
4739+
threadId: THREAD_ID,
4740+
provider: ProviderDriverKind.make("claudeAgent"),
4741+
runtimeMode: "full-access",
4742+
});
4743+
4744+
harness.query.emit({
4745+
type: "system",
4746+
subtype: "informational",
4747+
content:
4748+
"Transcript writes are failing (disk full); recent messages may not be saved for resume",
4749+
level: "warning",
4750+
session_id: "sdk-session-transcript-write-failed",
4751+
uuid: "transcript-write-failed-1",
4752+
} as unknown as SDKMessage);
4753+
harness.query.finish();
4754+
4755+
const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
4756+
const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");
4757+
assert.equal(warnings.length, 1);
4758+
assert.equal(
4759+
warnings[0]?.payload.message,
4760+
"Transcript writes are failing (disk full); recent messages may not be saved for resume",
4761+
);
4762+
assert.equal(warnings[0]?.payload.warningKind, "session-persistence");
4763+
}).pipe(
4764+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
4765+
Effect.provide(harness.layer),
4766+
);
4767+
});
4768+
46844769
it.effect(
46854770
"consumes command_lifecycle and unknown SDK message types without runtime warnings",
46864771
() => {

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,54 @@ const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJ
110110
const PROVIDER = ProviderDriverKind.make("claudeAgent");
111111
const COMPACT_CONTEXT_COMMAND = "/compact";
112112
const MAX_CLAUDE_FALLBACK_MODELS = 3;
113+
const CLAUDE_SKIP_PROMPT_HISTORY_ENV = "CLAUDE_CODE_SKIP_PROMPT_HISTORY";
114+
const CLAUDE_FORCE_SESSION_PERSISTENCE_ENV = "CLAUDE_CODE_FORCE_SESSION_PERSISTENCE";
115+
const CLAUDE_SESSION_PERSISTENCE_WARNING_KIND = "session-persistence";
113116
type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;
114117
type ClaudeToolResultStreamKind = Extract<
115118
RuntimeContentStreamKind,
116119
"command_output" | "file_change_output"
117120
>;
118121
type ClaudeSdkEffort = NonNullable<ClaudeQueryOptions["effort"]>;
119122

123+
function isEnabledEnvironmentFlag(value: string | undefined): boolean {
124+
switch (value?.trim().toLowerCase()) {
125+
case "1":
126+
case "true":
127+
case "yes":
128+
case "on":
129+
return true;
130+
default:
131+
return false;
132+
}
133+
}
134+
135+
function claudeSessionPersistenceEnvironmentWarning(
136+
environment: NodeJS.ProcessEnv,
137+
): string | undefined {
138+
if (
139+
!isEnabledEnvironmentFlag(environment[CLAUDE_SKIP_PROMPT_HISTORY_ENV]) ||
140+
isEnabledEnvironmentFlag(environment[CLAUDE_FORCE_SESSION_PERSISTENCE_ENV])
141+
) {
142+
return undefined;
143+
}
144+
return (
145+
`Claude session persistence is disabled by ${CLAUDE_SKIP_PROMPT_HISTORY_ENV}. ` +
146+
"Threadlines will retain its activity history, but Claude cannot resume the saved model context. " +
147+
`Unset ${CLAUDE_SKIP_PROMPT_HISTORY_ENV} or set ${CLAUDE_FORCE_SESSION_PERSISTENCE_ENV}=1, then restart Threadlines.`
148+
);
149+
}
150+
151+
function isClaudeSessionPersistenceWarning(message: string): boolean {
152+
return /transcript|session persistence|saved for resume|--resume will not find/i.test(message);
153+
}
154+
155+
function isClaudeDisabledSessionPersistenceWarning(message: string): boolean {
156+
return /session persistence.*(?:disabled|off)|--resume will not find|keep future transcripts/i.test(
157+
message,
158+
);
159+
}
160+
120161
function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
121162
const result = encodeUnknownJsonStringExit(input);
122163
return Exit.isSuccess(result) ? result.value : undefined;
@@ -314,6 +355,7 @@ interface ClaudeSessionContext {
314355
// turn and cleared once consumed. Set only when a session starts from a
315356
// context seed (no native resume).
316357
pendingContextSeedText: string | undefined;
358+
sessionPersistenceDisabledWarningEmitted: boolean;
317359
stopped: boolean;
318360
}
319361

@@ -4446,6 +4488,38 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
44464488
yield* emitRuntimeError(context, `Claude workspace mirror error: ${detail}`, message);
44474489
return;
44484490
}
4491+
case "informational": {
4492+
const content = nonEmptyString(message.content);
4493+
if (!content || message.level !== "warning") {
4494+
yield* Effect.logDebug("claude.sdk.informational", {
4495+
threadId: context.session.threadId,
4496+
level: message.level,
4497+
content: message.content,
4498+
});
4499+
return;
4500+
}
4501+
const isPersistenceWarning = isClaudeSessionPersistenceWarning(content);
4502+
if (
4503+
isPersistenceWarning &&
4504+
isClaudeDisabledSessionPersistenceWarning(content) &&
4505+
context.sessionPersistenceDisabledWarningEmitted
4506+
) {
4507+
yield* Effect.logInfo("claude.session-persistence.warning-duplicate", {
4508+
threadId: context.session.threadId,
4509+
content,
4510+
});
4511+
return;
4512+
}
4513+
if (isClaudeDisabledSessionPersistenceWarning(content)) {
4514+
context.sessionPersistenceDisabledWarningEmitted = true;
4515+
}
4516+
yield* emitRuntimeWarning(context, content, message, {
4517+
warningKind: isPersistenceWarning
4518+
? CLAUDE_SESSION_PERSISTENCE_WARNING_KIND
4519+
: "claude-informational",
4520+
});
4521+
return;
4522+
}
44494523
case "api_retry": {
44504524
// First-attempt retries are routine stream hiccups the SDK absorbs on
44514525
// its own; keep those in server diagnostics only. Cascades that reach
@@ -5373,6 +5447,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
53735447
includePartialMessages: true,
53745448
enableFileCheckpointing: true,
53755449
promptSuggestions: true,
5450+
persistSession: true,
53765451
// Subagent conversations arrive as messages tagged with
53775452
// parent_tool_use_id; routing keeps them out of the main transcript
53785453
// and streams them into the collab tool item instead. Travels via the
@@ -5494,11 +5569,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
54945569
input.contextSeed !== undefined && resumeState === undefined
54955570
? renderThreadContextSeed(input.contextSeed)
54965571
: undefined,
5572+
sessionPersistenceDisabledWarningEmitted: false,
54975573
stopped: false,
54985574
};
54995575
yield* Ref.set(contextRef, context);
55005576
sessions.set(threadId, context);
55015577

5578+
const sessionPersistenceWarning =
5579+
claudeSessionPersistenceEnvironmentWarning(claudeEnvironment);
5580+
if (sessionPersistenceWarning !== undefined) {
5581+
context.sessionPersistenceDisabledWarningEmitted = true;
5582+
yield* emitRuntimeWarning(context, sessionPersistenceWarning, undefined, {
5583+
warningKind: CLAUDE_SESSION_PERSISTENCE_WARNING_KIND,
5584+
});
5585+
}
5586+
55025587
const sessionStartedStamp = yield* makeEventStamp();
55035588
yield* offerRuntimeEvent({
55045589
type: "session.started",

0 commit comments

Comments
 (0)