History

Fix Claude session cwd resume drift (#2292)

Julius Marminge committed 6 months agov0.0.21v0.0.21-nightly.20260423.105

Commit 188df6d1 parent 0d55a426 files · +327 −4Browse files

Unified · Split

6 files
  1. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +76 −0
  2. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +6 −0
  3. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +90 −0
  4. apps/server/src/provider/Layers/ClaudeAdapter.ts +40 −0
  5. apps/server/src/provider/Layers/ProviderService.test.ts +92 −3
  6. apps/server/src/provider/Layers/ProviderService.ts +23 −1
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+76−0
@@ -138,6 +138,12 @@ describe("ProviderCommandReactor", () => { · 137 unmodified lines
138138 (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")
139139 ? input.runtimeMode
140140 : "full-access",
141+ ...(typeof input === "object" &&
142+ input !== null &&
143+ "cwd" in input &&
144+ typeof input.cwd === "string"
145+ ? { cwd: input.cwd }
146+ : {}),
141147 ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}),
142148 threadId,
143149 resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
@@ -864,6 +870,76 @@ describe("ProviderCommandReactor", () => { · 720 unmodified lines
864870 expect(harness.stopSession.mock.calls.length).toBe(0);
865871 });
866872
873+ it("restarts the provider session when the thread workspace changes", async () => {
874+ const harness = await createHarness({
875+ threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
876+ });
877+ const now = new Date().toISOString();
878+
879+ await Effect.runPromise(
880+ harness.engine.dispatch({
881+ type: "thread.turn.start",
882+ commandId: CommandId.make("cmd-turn-start-workspace-1"),
883+ threadId: ThreadId.make("thread-1"),
884+ message: {
885+ messageId: asMessageId("user-message-workspace-1"),
886+ role: "user",
887+ text: "first in project root",
888+ attachments: [],
889+ },
890+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
891+ runtimeMode: "approval-required",
892+ createdAt: now,
893+ }),
894+ );
895+
896+ await waitFor(() => harness.startSession.mock.calls.length === 1);
897+ await waitFor(() => harness.sendTurn.mock.calls.length === 1);
898+ expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
899+ cwd: "/tmp/provider-project",
900+ });
901+
902+ await Effect.runPromise(
903+ harness.engine.dispatch({
904+ type: "thread.meta.update",
905+ commandId: CommandId.make("cmd-thread-worktree-change"),
906+ threadId: ThreadId.make("thread-1"),
907+ worktreePath: "/tmp/provider-project-worktree",
908+ }),
909+ );
910+
911+ await Effect.runPromise(
912+ harness.engine.dispatch({
913+ type: "thread.turn.start",
914+ commandId: CommandId.make("cmd-turn-start-workspace-2"),
915+ threadId: ThreadId.make("thread-1"),
916+ message: {
917+ messageId: asMessageId("user-message-workspace-2"),
918+ role: "user",
919+ text: "second in worktree",
920+ attachments: [],
921+ },
922+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
923+ runtimeMode: "approval-required",
924+ createdAt: now,
925+ }),
926+ );
927+
928+ await waitFor(() => harness.startSession.mock.calls.length === 2);
929+ await waitFor(() => harness.sendTurn.mock.calls.length === 2);
930+ expect(harness.stopSession.mock.calls.length).toBe(0);
931+ expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
932+ threadId: ThreadId.make("thread-1"),
933+ cwd: "/tmp/provider-project-worktree",
934+ resumeCursor: { opaque: "resume-1" },
935+ modelSelection: {
936+ provider: "claudeAgent",
937+ model: "claude-sonnet-4-6",
938+ },
939+ runtimeMode: "approval-required",
940+ });
941+ });
942+
867943 it("restarts claude sessions when claude effort changes", async () => {
868944 const harness = await createHarness({
869945 threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+6−0
@@ -334,6 +334,7 @@ const make = Effect.gen(function* () { · 333 unmodified lines
334334 thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null;
335335 if (existingSessionThreadId) {
336336 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
337+ const cwdChanged = effectiveCwd !== activeSession?.cwd;
337338 const sessionModelSwitch =
338339 currentProvider === undefined
339340 ? "in-session"
@@ -350,6 +351,7 @@ const make = Effect.gen(function* () { · 10 unmodified lines
350351
351352 if (
352353 !runtimeModeChanged &&
354+ !cwdChanged &&
353355 !shouldRestartForModelChange &&
354356 !shouldRestartForModelSelectionChange
355357 ) {
@@ -367,6 +369,9 @@ const make = Effect.gen(function* () { · 11 unmodified lines
367369 currentRuntimeMode: thread.session?.runtimeMode,
368370 desiredRuntimeMode: thread.runtimeMode,
369371 runtimeModeChanged,
372+ previousCwd: activeSession?.cwd,
373+ desiredCwd: effectiveCwd,
374+ cwdChanged,
370375 modelChanged,
371376 shouldRestartForModelChange,
372377 shouldRestartForModelSelectionChange,
@@ -381,6 +386,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines
381386 restartedSessionThreadId: restartedSession.threadId,
382387 provider: restartedSession.provider,
383388 runtimeMode: restartedSession.runtimeMode,
389+ cwd: restartedSession.cwd,
384390 });
385391 yield* bindSessionToThread(restartedSession);
386392 return restartedSession.threadId;
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+90−0
@@ -2561,6 +2561,96 @@ describe("ClaudeAdapterLive", () => { · 2560 unmodified lines
25612561 );
25622562 });
25632563
2564+ it.effect("preserves durable resume ids across Claude resume hooks", () => {
2565+ const harness = makeHarness();
2566+ return Effect.gen(function* () {
2567+ const adapter = yield* ClaudeAdapter;
2568+ const durableSessionId = "550e8400-e29b-41d4-a716-446655440000";
2569+ const transientHookSessionId = "7368d0c7-40a3-4d8a-bcc1-ac80c49f2719";
2570+
2571+ const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
2572+ Stream.runCollect,
2573+ Effect.forkChild,
2574+ );
2575+
2576+ yield* adapter.startSession({
2577+ threadId: RESUME_THREAD_ID,
2578+ provider: "claudeAgent",
2579+ resumeCursor: {
2580+ threadId: RESUME_THREAD_ID,
2581+ resume: durableSessionId,
2582+ resumeSessionAt: "assistant-99",
2583+ turnCount: 3,
2584+ },
2585+ runtimeMode: "full-access",
2586+ });
2587+
2588+ harness.query.emit({
2589+ type: "system",
2590+ subtype: "hook_started",
2591+ hook_id: "resume-hook-1",
2592+ hook_name: "SessionStart:resume",
2593+ hook_event: "SessionStart",
2594+ session_id: transientHookSessionId,
2595+ uuid: "resume-hook-started",
2596+ } as unknown as SDKMessage);
2597+
2598+ harness.query.emit({
2599+ type: "system",
2600+ subtype: "hook_response",
2601+ hook_id: "resume-hook-1",
2602+ hook_name: "SessionStart:resume",
2603+ hook_event: "SessionStart",
2604+ output: "",
2605+ stdout: "",
2606+ stderr: "",
2607+ outcome: "success",
2608+ session_id: transientHookSessionId,
2609+ uuid: "resume-hook-response",
2610+ } as unknown as SDKMessage);
2611+
2612+ harness.query.emit({
2613+ type: "system",
2614+ subtype: "init",
2615+ apiKeySource: "none",
2616+ claude_code_version: "test",
2617+ cwd: "/tmp/claude-adapter-test",
2618+ tools: [],
2619+ mcp_servers: [],
2620+ model: "claude-sonnet-4-5",
2621+ permissionMode: "bypassPermissions",
2622+ slash_commands: [],
2623+ output_style: "default",
2624+ skills: [],
2625+ plugins: [],
2626+ session_id: durableSessionId,
2627+ uuid: "resume-init",
2628+ } as unknown as SDKMessage);
2629+
2630+ const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
2631+ const threadStartedEvents = runtimeEvents.filter((event) => event.type === "thread.started");
2632+ assert.equal(threadStartedEvents.length, 1);
2633+ const threadStarted = threadStartedEvents[0];
2634+ assert.equal(threadStarted?.type, "thread.started");
2635+ if (threadStarted?.type === "thread.started") {
2636+ assert.deepEqual(threadStarted.payload, {
2637+ providerThreadId: durableSessionId,
2638+ });
2639+ }
2640+
2641+ const activeSessions = yield* adapter.listSessions();
2642+ const resumeCursor = activeSessions[0]?.resumeCursor as
2643+ | {
2644+ readonly resume?: string;
2645+ }
2646+ | undefined;
2647+ assert.equal(resumeCursor?.resume, durableSessionId);
2648+ }).pipe(
2649+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
2650+ Effect.provide(harness.layer),
2651+ );
2652+ });
2653+
25642654 it.effect("uses an app-generated Claude session id for fresh sessions", () => {
25652655 const harness = makeHarness();
25662656 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+40−0
@@ -198,6 +198,18 @@ function isSyntheticClaudeThreadId(value: string): boolean { · 197 unmodified lines
198198 return value.startsWith("claude-thread-");
199199 }
200200
201+function hasDurableClaudeSessionId(message: SDKMessage): boolean {
202+ if (message.type !== "system") {
203+ return true;
204+ }
205+
206+ return (
207+ message.subtype !== "hook_started" &&
208+ message.subtype !== "hook_progress" &&
209+ message.subtype !== "hook_response"
210+ );
211+}
212+
201213 function toMessage(cause: unknown, fallback: string): string {
202214 if (cause instanceof Error && cause.message.length > 0) {
203215 return cause.message;
@@ -1249,6 +1261,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 1045 unmodified lines
12491261 if (typeof message.session_id !== "string" || message.session_id.length === 0) {
12501262 return;
12511263 }
1264+ if (!hasDurableClaudeSessionId(message)) {
1265+ return;
1266+ }
12521267 const nextThreadId = message.session_id;
12531268 context.resumeSessionId = message.session_id;
12541269 yield* updateResumeCursor(context);
@@ -2875,6 +2890,31 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 1620 unmodified lines
28752890 ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
28762891 };
28772892
2893+ yield* Effect.annotateCurrentSpan({
2894+ "provider.kind": PROVIDER,
2895+ "provider.thread_id": threadId,
2896+ "provider.runtime_mode": input.runtimeMode,
2897+ "claude.resume.source":
2898+ existingResumeSessionId !== undefined ? "resume-session" : "generated-session",
2899+ "claude.resume.thread_id": resumeState?.threadId ?? "",
2900+ "claude.resume.session_id": existingResumeSessionId ?? "",
2901+ "claude.resume.session_at": resumeState?.resumeSessionAt ?? "",
2902+ "claude.resume.turn_count": resumeState?.turnCount ?? -1,
2903+ "claude.query.cwd": input.cwd ?? "",
2904+ "claude.query.model": apiModelId ?? "",
2905+ "claude.query.effort": effectiveEffort ?? "",
2906+ "claude.query.permission_mode": permissionMode ?? "",
2907+ "claude.query.allow_dangerously_skip_permissions": permissionMode === "bypassPermissions",
2908+ "claude.query.resume": existingResumeSessionId ?? "",
2909+ "claude.query.session_id": newSessionId ?? "",
2910+ "claude.query.include_partial_messages": true,
2911+ "claude.query.additional_directories": input.cwd ? [input.cwd] : [],
2912+ "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES],
2913+ "claude.query.settings_json": JSON.stringify(settings),
2914+ "claude.query.extra_args_json": JSON.stringify(extraArgs),
2915+ "claude.query.path_to_executable": claudeBinaryPath,
2916+ });
2917+
28782918 const queryRuntime = yield* Effect.try({
28792919 try: () =>
28802920 createQuery({
apps/server/src/provider/Layers/ProviderService.test.ts+92−3
@@ -929,9 +929,10 @@ routing.layer("ProviderServiceLive routing", (it) => { · 928 unmodified lines
929929 const provider = yield* ProviderService;
930930 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
931931
932− const session = yield* provider.startSession(asThreadId("thread-1"), {
932+ const threadId = asThreadId("thread-runtime-status");
933+ const session = yield* provider.startSession(threadId, {
933934 provider: "codex",
934− threadId: asThreadId("thread-1"),
935+ threadId,
935936 runtimeMode: "full-access",
936937 });
937938 yield* provider.sendTurn({
@@ -957,7 +958,7 @@ routing.layer("ProviderServiceLive routing", (it) => { · 19 unmodified lines
957958 lastError: string | null;
958959 lastRuntimeEvent: string | null;
959960 };
960− assert.equal(runtimePayload.cwd, process.cwd());
961+ assert.equal(runtimePayload.cwd, session.cwd);
961962 assert.equal(runtimePayload.model, null);
962963 assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`);
963964 assert.equal(runtimePayload.lastError, null);
@@ -1058,6 +1059,94 @@ routing.layer("ProviderServiceLive routing", (it) => { · 94 unmodified lines
10581059 fs.rmSync(tempDir, { recursive: true, force: true });
10591060 }).pipe(Effect.provide(NodeServices.layer)),
10601061 );
1062+
1063+ it.effect(
1064+ "reuses persisted cwd when startSession resumes a claude session without cwd input",
1065+ () =>
1066+ Effect.gen(function* () {
1067+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-"));
1068+ const dbPath = path.join(tempDir, "orchestration.sqlite");
1069+ const persistenceLayer = makeSqlitePersistenceLive(dbPath);
1070+ const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
1071+ Layer.provide(persistenceLayer),
1072+ );
1073+
1074+ const firstClaude = makeFakeCodexAdapter("claudeAgent");
1075+ const firstRegistry: typeof ProviderAdapterRegistry.Service = {
1076+ getByProvider: (provider) =>
1077+ provider === "claudeAgent"
1078+ ? Effect.succeed(firstClaude.adapter)
1079+ : Effect.fail(new ProviderUnsupportedError({ provider })),
1080+ listProviders: () => Effect.succeed(["claudeAgent"]),
1081+ };
1082+ const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
1083+ Layer.provide(runtimeRepositoryLayer),
1084+ );
1085+ const firstProviderLayer = makeProviderServiceLive().pipe(
1086+ Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
1087+ Layer.provide(firstDirectoryLayer),
1088+ Layer.provide(defaultServerSettingsLayer),
1089+ Layer.provide(AnalyticsService.layerTest),
1090+ );
1091+
1092+ const initial = yield* Effect.gen(function* () {
1093+ const provider = yield* ProviderService;
1094+ return yield* provider.startSession(asThreadId("thread-claude-cwd"), {
1095+ provider: "claudeAgent",
1096+ threadId: asThreadId("thread-claude-cwd"),
1097+ cwd: "/tmp/project-claude-cwd",
1098+ runtimeMode: "full-access",
1099+ });
1100+ }).pipe(Effect.provide(firstProviderLayer));
1101+
1102+ const secondClaude = makeFakeCodexAdapter("claudeAgent");
1103+ const secondRegistry: typeof ProviderAdapterRegistry.Service = {
1104+ getByProvider: (provider) =>
1105+ provider === "claudeAgent"
1106+ ? Effect.succeed(secondClaude.adapter)
1107+ : Effect.fail(new ProviderUnsupportedError({ provider })),
1108+ listProviders: () => Effect.succeed(["claudeAgent"]),
1109+ };
1110+ const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
1111+ Layer.provide(runtimeRepositoryLayer),
1112+ );
1113+ const secondProviderLayer = makeProviderServiceLive().pipe(
1114+ Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
1115+ Layer.provide(secondDirectoryLayer),
1116+ Layer.provide(defaultServerSettingsLayer),
1117+ Layer.provide(AnalyticsService.layerTest),
1118+ );
1119+
1120+ secondClaude.startSession.mockClear();
1121+
1122+ yield* Effect.gen(function* () {
1123+ const provider = yield* ProviderService;
1124+ yield* provider.startSession(initial.threadId, {
1125+ provider: "claudeAgent",
1126+ threadId: initial.threadId,
1127+ runtimeMode: "full-access",
1128+ });
1129+ }).pipe(Effect.provide(secondProviderLayer));
1130+
1131+ assert.equal(secondClaude.startSession.mock.calls.length, 1);
1132+ const resumedStartInput = secondClaude.startSession.mock.calls[0]?.[0];
1133+ assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true);
1134+ if (resumedStartInput && typeof resumedStartInput === "object") {
1135+ const startPayload = resumedStartInput as {
1136+ provider?: string;
1137+ cwd?: string;
1138+ resumeCursor?: unknown;
1139+ threadId?: string;
1140+ };
1141+ assert.equal(startPayload.provider, "claudeAgent");
1142+ assert.equal(startPayload.cwd, "/tmp/project-claude-cwd");
1143+ assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
1144+ assert.equal(startPayload.threadId, initial.threadId);
1145+ }
1146+
1147+ fs.rmSync(tempDir, { recursive: true, force: true });
1148+ }).pipe(Effect.provide(NodeServices.layer)),
1149+ );
10611150 });
10621151
10631152 const fanout = makeProviderServiceLayer();
apps/server/src/provider/Layers/ProviderService.ts+23−1
@@ -374,9 +374,31 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 373 unmodified lines
374374 (persistedBinding?.provider === input.provider
375375 ? persistedBinding.resumeCursor
376376 : undefined);
377+ const effectiveCwd =
378+ input.cwd ??
379+ (persistedBinding?.provider === input.provider
380+ ? readPersistedCwd(persistedBinding.runtimePayload)
381+ : undefined);
382+ yield* Effect.annotateCurrentSpan({
383+ "provider.resume_cursor.source":
384+ input.resumeCursor !== undefined
385+ ? "request"
386+ : effectiveResumeCursor !== undefined && persistedBinding?.provider === input.provider
387+ ? "persisted"
388+ : "none",
389+ "provider.resume_cursor.present": effectiveResumeCursor !== undefined,
390+ "provider.cwd.source":
391+ input.cwd !== undefined
392+ ? "request"
393+ : effectiveCwd !== undefined && persistedBinding?.provider === input.provider
394+ ? "persisted"
395+ : "none",
396+ "provider.cwd.effective": effectiveCwd ?? "",
397+ });
377398 const adapter = yield* registry.getByProvider(input.provider);
378399 const session = yield* adapter.startSession({
379400 ...input,
401+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
380402 ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}),
381403 });
382404
@@ -398,7 +420,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 15 unmodified lines
398420 provider: session.provider,
399421 runtimeMode: input.runtimeMode,
400422 hasResumeCursor: session.resumeCursor !== undefined,
401− hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0,
423+ hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0,
402424 hasModel:
403425 typeof input.modelSelection?.model === "string" &&
404426 input.modelSelection.model.trim().length > 0,