From 8e05717072708c523bb0cb2e77d0ccb093ce62b4 Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Mon, 29 Jun 2026 12:50:43 +0000 Subject: [PATCH] fix(grok): discard resume cursor when a thread's workspace changes Resuming a grok session against a different working directory than the one the cursor was recorded in produces a stale/invalid resume, so a thread that moves to a new worktree must start a fresh session instead of replaying the old cursor. - ProviderCommandReactor: when the preferred provider is grok and the cwd changed, pass an explicit `null` resume cursor (a "discard" sentinel, distinct from `undefined` which means "use the persisted cursor") when restarting the session, and log `shouldDiscardResumeCursorForCwdChange`. - ProviderService: honor the `null` sentinel by dropping the cursor, and independently refuse to reuse a persisted cursor when an explicit `cwd` differs from the persisted one. Persisted cwd lookup is hoisted so both the cursor-reuse guard and `effectiveCwd` share it. Adds reactor- and service-level regression tests covering the worktree/cwd-change paths. Co-Authored-By: Claude Opus 4.8 --- .../Layers/ProviderCommandReactor.test.ts | 69 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 11 +-- .../provider/Layers/ProviderService.test.ts | 37 ++++++++++ .../src/provider/Layers/ProviderService.ts | 21 +++--- 4 files changed, 125 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..e9383ce204a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +/* oxlint-disable t3code/no-manual-effect-runtime-in-tests */ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -1195,6 +1196,74 @@ describe("ProviderCommandReactor", () => { }); }); + it("discards grok resume cursors when the thread workspace changes", async () => { + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-grok-workspace-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-grok-workspace-1"), + role: "user", + text: "first in project root", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-grok-worktree-change"), + threadId: ThreadId.make("thread-1"), + worktreePath: "/tmp/provider-project-grok-worktree", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-grok-workspace-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-grok-workspace-2"), + role: "user", + text: "second in worktree", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project-grok-worktree", + resumeCursor: null, + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + runtimeMode: "approval-required", + }); + }); + it("restarts claude sessions when claude effort changes", async () => { const harness = await createHarness({ threadModelSelection: { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..6a71a4735d8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -472,7 +472,7 @@ const make = Effect.gen(function* () { }); const startProviderSession = (input?: { - readonly resumeCursor?: unknown; + readonly resumeCursor?: unknown | null; readonly provider?: ProviderDriverKind; }) => providerService.startSession(threadId, { @@ -541,9 +541,11 @@ const make = Effect.gen(function* () { return existingSessionThreadId; } - const resumeCursor = shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const shouldDiscardResumeCursorForCwdChange = cwdChanged && preferredProvider === "grok"; + const resumeCursor = + shouldRestartForModelChange || shouldDiscardResumeCursorForCwdChange + ? null + : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, @@ -561,6 +563,7 @@ const make = Effect.gen(function* () { instanceChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, + shouldDiscardResumeCursorForCwdChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f..2b4cc103103 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1020,6 +1020,43 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("does not reuse a persisted resume cursor when an explicit cwd changes", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-cwd-changed"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-cwd-changed"), + cwd: "/tmp/project-before-cwd-change", + runtimeMode: "full-access", + }); + + yield* provider.stopSession({ threadId: initial.threadId }); + routing.codex.startSession.mockClear(); + + yield* provider.startSession(initial.threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: initial.threadId, + cwd: "/tmp/project-after-cwd-change", + runtimeMode: "full-access", + }); + + assert.equal(routing.codex.startSession.mock.calls.length, 1); + const startInput = routing.codex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof startInput === "object" && startInput !== null, true); + if (startInput && typeof startInput === "object") { + const startPayload = startInput as { + cwd?: string; + resumeCursor?: unknown; + }; + assert.equal(startPayload.cwd, "/tmp/project-after-cwd-change"); + assert.equal("resumeCursor" in startPayload, false); + } + }), + ); + it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..4f9070c435a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -560,16 +560,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); - const effectiveResumeCursor = - input.resumeCursor ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId - ? persistedBinding.resumeCursor - : undefined); - const effectiveCwd = - input.cwd ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId + const persistedCwd = + persistedBinding?.providerInstanceId === resolvedInstanceId ? readPersistedCwd(persistedBinding.runtimePayload) - : undefined); + : undefined; + const canUsePersistedResumeCursor = + persistedBinding?.providerInstanceId === resolvedInstanceId && + (input.cwd === undefined || persistedCwd === undefined || input.cwd === persistedCwd); + const effectiveResumeCursor = + input.resumeCursor === null + ? undefined + : (input.resumeCursor ?? + (canUsePersistedResumeCursor ? persistedBinding.resumeCursor : undefined)); + const effectiveCwd = input.cwd ?? persistedCwd; yield* Effect.annotateCurrentSpan({ "provider.kind": resolvedProvider, "provider.resume_cursor.source":