diff --git a/apps/examples/desktop-app/sidecar/chat-session.test.ts b/apps/examples/desktop-app/sidecar/chat-session.test.ts index 03ba83fe4b..0cef83b15b 100644 --- a/apps/examples/desktop-app/sidecar/chat-session.test.ts +++ b/apps/examples/desktop-app/sidecar/chat-session.test.ts @@ -1679,7 +1679,7 @@ describe("first-send connection updates", () => { }; } - it("preserves an observed idle session when attach reports a resident running process", async () => { + it("preserves a newer Hub idle event over a running attach reply", async () => { const { ctx, sessionId } = createContext({ attachedViaHub: true }); const binding = ctx.runtimeBindings.get("local"); if (!binding) throw new Error("missing binding"); @@ -1687,8 +1687,15 @@ describe("first-send connection updates", () => { handleHubLiveEvent(ctx, { event: "session.attached", sessionId, + sequence: 1, payload: { session: { status: "running" } }, }); + handleHubLiveEvent(ctx, { + event: "session.updated", + sessionId, + sequence: 2, + payload: { session: { status: "idle" } }, + }); return { version: "v1", ok: true, diff --git a/apps/examples/desktop-app/sidecar/chat-session.ts b/apps/examples/desktop-app/sidecar/chat-session.ts index 80cc84553d..aaf2223d83 100644 --- a/apps/examples/desktop-app/sidecar/chat-session.ts +++ b/apps/examples/desktop-app/sidecar/chat-session.ts @@ -52,6 +52,7 @@ import { } from "./attachments"; import { createDesktopExtensionContext } from "./client-context"; import { + assertCloudHandoffCreationResolved, beginActiveSessionSend, handleHandoff, handlePrepareHandoff, @@ -368,7 +369,8 @@ async function withWorkspaceRestoreLock( const sessionWorkspace = workspacePathKey(session.config); if ( sessionWorkspace && - (hasActiveWorkspaceTurn(session) || hasActiveSessionSend(ctx, sessionId)) && + (hasActiveWorkspaceTurn(session) || + hasActiveSessionSend(ctx, sessionId)) && workspacePathsOverlap(sessionWorkspace, key, environment) ) { throw new Error(WORKSPACE_RESTORE_BUSY_ERROR); @@ -1713,9 +1715,9 @@ async function handleSendOnce( readEnvironmentId(request.config), ); const manager = binding.sessionManager; - const handoff = readCloudHandoffMetadata( - await readHandoffSessionMetadata(binding, sessionId), - ); + const metadata = await readHandoffSessionMetadata(binding, sessionId); + assertCloudHandoffCreationResolved(metadata); + const handoff = readCloudHandoffMetadata(metadata); if (handoff?.status === "pending") { throw new Error( `Cloud handoff is still pending. Retry /cloud or continue here: ${handoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, handoff.toCloudSessionId)}`, @@ -2126,11 +2128,13 @@ async function handleFork( throw new Error(WORKSPACE_RESTORE_BUSY_ERROR); } const sourceSession = await manager.get(sourceSessionId); - const sourceHandoff = readCloudHandoffMetadata( + const sourceMetadata = binding.kind === "local" ? (sourceSession?.metadata ?? readSessionMetadata(sourceSessionId)) - : undefined, - ); + : undefined; + if (forkBeforeRunCount !== undefined) + assertCloudHandoffCreationResolved(sourceMetadata); + const sourceHandoff = readCloudHandoffMetadata(sourceMetadata); if (sourceHandoff?.status === "pending") { throw new Error( `Cloud handoff is still pending. Retry /cloud or continue here: ${sourceHandoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, sourceHandoff.toCloudSessionId)}`, @@ -2429,11 +2433,12 @@ async function handleRestoreCheckpoint( const manager = binding.sessionManager; const persisted = binding.kind === "local" ? await manager.get(sourceSessionId) : undefined; - const completedHandoff = readCloudHandoffMetadata( + const handoffMetadata = binding.kind === "local" ? (persisted?.metadata ?? readSessionMetadata(sourceSessionId)) - : undefined, - ); + : undefined; + const completedHandoff = readCloudHandoffMetadata(handoffMetadata); + assertCloudHandoffCreationResolved(handoffMetadata); if (completedHandoff?.status === "pending") { throw new Error( `Cloud handoff is still pending. Retry /cloud or continue here: ${completedHandoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, completedHandoff.toCloudSessionId)}`, diff --git a/apps/examples/desktop-app/sidecar/cloud-handoff.test.ts b/apps/examples/desktop-app/sidecar/cloud-handoff.test.ts index 7456db4af0..c0d1efadc9 100644 --- a/apps/examples/desktop-app/sidecar/cloud-handoff.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-handoff.test.ts @@ -777,10 +777,13 @@ describe("cloud handoff gates", () => { await expect(handingOff).rejects.toThrow(); }); - it("trusts an authoritative idle live session over a legacy running record", async () => { + it.each([ + "running", + "pending", + ])("rejects handoff when Hub reports %s despite an idle Desktop cache", async (persistedStatus) => { const { ctx, sessionId } = createHandoffGateContext({ busy: false, - persistedStatus: "running", + persistedStatus, }); await expect( @@ -788,7 +791,8 @@ describe("cloud handoff gates", () => { action: "prepare_handoff", sessionId, }), - ).rejects.not.toThrow("Stop the current run"); + ).rejects.toThrow("Stop the current run"); + expect(ctx.cloudSessionManager).toBeFalsy(); }); it("rejects an empty source before provisioning", async () => { @@ -1288,6 +1292,21 @@ describe("cloud handoff transaction", () => { ); expect(first.getPersistedMetadata()).toHaveProperty("cloudHandoffIntent"); const restarted = createHandoffFixture(true, first.getPersistedMetadata()); + const restore = vi.fn(); + Object.assign(localSessionManager(restarted.ctx), { restore }); + for (const action of ["restore_checkpoint", "send", "fork"] as const) { + await expect( + handleChatSessionCommand(restarted.ctx, { + action, + sessionId: request.sessionId, + prompt: "Change this workspace", + checkpointRunCount: 1, + forkBeforeRunCount: 1, + config: { cwd: "/workspace/project" }, + }), + ).rejects.toThrow("Cloud handoff creation is still unconfirmed"); + } + expect(restore).not.toHaveBeenCalled(); await expect( assertSessionDeleteAllowedDuringHandoff( restarted.ctx, diff --git a/apps/examples/desktop-app/sidecar/cloud-handoff.ts b/apps/examples/desktop-app/sidecar/cloud-handoff.ts index 21495c8320..c1d877d404 100644 --- a/apps/examples/desktop-app/sidecar/cloud-handoff.ts +++ b/apps/examples/desktop-app/sidecar/cloud-handoff.ts @@ -134,8 +134,7 @@ async function assertHandoffIdle( live?.status === "running" || live?.status === "stopping"; const persistedIsBusy = - !live && - (persisted?.status === "running" || persisted?.status === "pending"); + persisted?.status === "running" || persisted?.status === "pending"; if (liveIsBusy || persistedIsBusy) { throw new Error("Stop the current run before handing off to cloud."); } @@ -1094,6 +1093,20 @@ export function beginSessionMetadataUpdate( ); } +export function assertCloudHandoffCreationResolved(metadata: unknown): void { + if ( + !readCloudHandoffMetadata(metadata) && + metadata && + typeof metadata === "object" && + "cloudHandoffIntent" in metadata && + metadata.cloudHandoffIntent + ) { + throw new Error( + "Cloud handoff creation is still unconfirmed. Retry /cloud to recover it before changing this session.", + ); + } +} + /** * Deleting a source session while its cloud handoff is in flight can remove * the only durable recovery record for an already-created sandbox. Keep this @@ -1130,11 +1143,7 @@ export async function assertSessionDeleteAllowedDuringHandoff( } } const handoff = readCloudHandoffMetadata(metadata); - if (!handoff && metadata?.cloudHandoffIntent) { - throw new Error( - "Cloud handoff creation is still unconfirmed. Retry /cloud to recover it before deleting this session.", - ); - } + assertCloudHandoffCreationResolved(metadata); if (handoff?.status === "pending") { throw new Error( `Cloud handoff is still pending. Retry /cloud or continue here: ${handoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, handoff.toCloudSessionId)}`, diff --git a/apps/examples/desktop-app/sidecar/context.test.ts b/apps/examples/desktop-app/sidecar/context.test.ts index 75d8b44f25..a5e6d5513f 100644 --- a/apps/examples/desktop-app/sidecar/context.test.ts +++ b/apps/examples/desktop-app/sidecar/context.test.ts @@ -923,57 +923,6 @@ describe("Code sidecar runtime capabilities", () => { expect(readEvents(ctx)).toEqual([]); }); - it.each([ - "session.attached", - "session.updated", - ])("keeps a resident process idle until run.started after %s", async (event) => { - const { createSidecarContext, handleHubLiveEvent } = await import( - "./context" - ); - const ctx = createSidecarContext("/workspace/project"); - ctx.wsClients.add({ send: vi.fn() }); - ctx.liveSessions.set("idle-session", { - config: {}, - messages: [], - promptsInQueue: [], - busy: false, - startedAt: Date.now(), - status: "idle", - attachedViaHub: true, - }); - handleHubLiveEvent(ctx, { - event, - sessionId: "idle-session", - payload: { session: { status: "running" } }, - }); - expect(ctx.liveSessions.get("idle-session")).toMatchObject({ - status: "idle", - busy: false, - }); - expect(readEvents(ctx)).toEqual([]); - handleHubLiveEvent(ctx, { - event: "run.started", - sessionId: "idle-session", - }); - expect(ctx.liveSessions.get("idle-session")).toMatchObject({ - status: "running", - busy: true, - }); - expect(readEvents(ctx)).toEqual([ - { - type: "event", - event: { - name: "chat_session_status", - payload: { - environmentId: "local", - sessionId: "idle-session", - status: "running", - }, - }, - }, - ]); - }); - it("resolves askQuestion through the websocket request/response protocol", async () => { const { createSidecarContext, initializeSessionManager } = await import( "./context" diff --git a/apps/examples/desktop-app/sidecar/context.ts b/apps/examples/desktop-app/sidecar/context.ts index f256ef8b0d..04289d340b 100644 --- a/apps/examples/desktop-app/sidecar/context.ts +++ b/apps/examples/desktop-app/sidecar/context.ts @@ -1149,15 +1149,7 @@ export function handleHubLiveEvent( ); return; } - case "run.started": { - const statusChanged = session.status !== "running"; - session.status = "running"; - session.busy = true; - if (statusChanged) { - sendEvent(ctx, "chat_session_status", { sessionId, status: "running" }); - } - return; - } + case "run.started": case "session.attached": case "session.updated": { const payloadSession = @@ -1169,7 +1161,9 @@ export function handleHubLiveEvent( const runtimeStatus = typeof payloadSession?.status === "string" ? payloadSession.status - : session.status; + : event.event === "run.started" + ? "running" + : session.status; // Hub "pending" is still active; map it to running so follow-ups stay queued. const status = runtimeStatus === "pending" ? "running" : runtimeStatus; if ( @@ -1180,14 +1174,6 @@ export function handleHubLiveEvent( ) { return; } - // A resident interactive process can be idle; run.started marks an active turn. - if ( - runtimeStatus === "running" && - session.status !== "running" && - session.config.executionTarget !== "cloud" - ) { - return; - } // Pods emit periodic session.updated snapshots; re-broadcasting an // unchanged status marks the session unread in the sidebar every time. const statusChanged = session.status !== status; diff --git a/apps/examples/desktop-app/webview/lib/desktop-app-state.test.ts b/apps/examples/desktop-app/webview/lib/desktop-app-state.test.ts index b54accac81..b594de714c 100644 --- a/apps/examples/desktop-app/webview/lib/desktop-app-state.test.ts +++ b/apps/examples/desktop-app/webview/lib/desktop-app-state.test.ts @@ -203,6 +203,9 @@ describe("desktopAppReducer", () => { }); const reopened = desktopAppReducer(state, open); expect(reopened.navigation.current.view).toBe("chat"); + expect(reopened.navigation.back).toHaveLength( + state.navigation.back.length + (view === "chat" ? 0 : 1), + ); expect( reopened.threads.find((thread) => thread.id === threadId), ).toMatchObject({ diff --git a/apps/examples/desktop-app/webview/lib/desktop-app-state.ts b/apps/examples/desktop-app/webview/lib/desktop-app-state.ts index 6c796ec1ff..68c17ff485 100644 --- a/apps/examples/desktop-app/webview/lib/desktop-app-state.ts +++ b/apps/examples/desktop-app/webview/lib/desktop-app-state.ts @@ -225,7 +225,11 @@ export function desktopAppReducer( return { threads, navigation: navigationHistoryReducer(state.navigation, { - type: "navigate", + type: + state.navigation.current.view === "chat" && + state.navigation.current.activeThreadId === threadId + ? "replace" + : "navigate", destination: { ...state.navigation.current, activeThreadId: threadId,