fix(desktop): reconcile missed cloud handoff completion

Reject previews overlapped by a local turn without blocking that turn. Release canceled previews and display structured cloud failures with delivery guidance.
This commit is contained in:
John Choi
2026-10-20 20:55:14 -07:00
parent a8b0a9a693
commit 64805d0ab9
10 changed files with 300 additions and 37 deletions
@@ -49,6 +49,7 @@ import {
import { createDesktopExtensionContext } from "./client-context";
import {
beginActiveSessionSend,
handleDiscardHandoff,
handleHandoff,
handlePrepareHandoff,
} from "./cloud-handoff";
@@ -2500,6 +2501,7 @@ const ACTION_HANDLERS: Record<
attach: handleAttach,
send: handleSend,
prepare_handoff: handlePrepareHandoff,
discard_handoff: handleDiscardHandoff,
handoff: (ctx, request) =>
handleHandoff(ctx, request, async (messages, config) => {
const connection = buildSessionConnectionUpdate(
@@ -2674,7 +2676,9 @@ export async function handleChatSessionCommand(
"local",
);
if (
(request.action === "prepare_handoff" || request.action === "handoff") &&
(request.action === "prepare_handoff" ||
request.action === "discard_handoff" ||
request.action === "handoff") &&
getSessionRuntimeBinding(ctx).kind !== "local"
) {
throw new Error("Cloud handoff is only supported for local sessions.");
@@ -314,10 +314,12 @@ describe("cloud handoff Git preparation", () => {
it("captures file contents before remote inspection while local edits continue", async () => {
writeFileSync(join(repo, "tracked.txt"), "captured with conversation\n");
const captured = vi.fn();
transport.beforeFetch = () => {
expect(captured).toHaveBeenCalledOnce();
writeFileSync(join(repo, "tracked.txt"), "later local turn\n");
};
const plan = await inspectHandoffGit(repo);
const plan = await inspectHandoffGit(repo, captured);
await applyHandoffGit(plan);
expect(
git("--git-dir", transport.remote, "show", `${plan.branch}:tracked.txt`),
@@ -88,7 +88,10 @@ function isSensitivePath(path: string): boolean {
);
}
export async function inspectHandoffGit(cwd: string): Promise<HandoffGitPlan> {
export async function inspectHandoffGit(
cwd: string,
onCaptured?: () => void,
): Promise<HandoffGitPlan> {
const root = await git(cwd, ["rev-parse", "--show-toplevel"]);
const workspaceRelativePath = (
await git(cwd, ["rev-parse", "--show-prefix"])
@@ -174,6 +177,7 @@ export async function inspectHandoffGit(cwd: string): Promise<HandoffGitPlan> {
"The repository changed while preparing the preview. Run /cloud again.",
);
}
onCaptured?.();
const remote =
(await optionalConfig(root, `branch.${sourceBranch}.remote`)) || "origin";
if (remote === "." || remote.startsWith("-"))
@@ -2,11 +2,17 @@ import { summarizeConversation } from "@cline/core";
import { loadCloudModels } from "@cline/core/cloud";
import { DEFAULT_MAX_IMAGE_DECODED_BYTES } from "@cline/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleHandoff, handlePrepareHandoff } from "./cloud-handoff";
import {
beginActiveSessionSend,
handleDiscardHandoff,
handleHandoff,
handlePrepareHandoff,
} from "./cloud-handoff";
import * as gitPreparation from "./cloud-handoff-git";
import {
CloudCreationUnconfirmedError,
type CloudSessionApi,
CloudSessionError,
CloudSessionManager,
resetCloudSessionManager,
} from "./cloud-sessions";
@@ -145,12 +151,13 @@ function fixture() {
handoffAttemptId: "attempt-1",
gitPreparationId: plan.id,
};
const prepare = async () => {
const prepare = async (completedHandoffAttemptId?: string) => {
const preview = await handlePrepareHandoff(ctx, {
...request,
action: "prepare_handoff",
completedHandoffAttemptId,
});
expect(preview.gitPreparation.id).toBe(plan.id);
expect(preview).toMatchObject({ gitPreparation: { id: plan.id } });
};
return {
ctx,
@@ -174,6 +181,105 @@ function fixture() {
}
describe("summary cloud handoff", () => {
it("returns a missed completion before preparing another cloud task", async () => {
const f = fixture();
await f.prepare();
const result = await f.handoff();
const retry = await handlePrepareHandoff(f.ctx, f.request);
expect(retry).toEqual({
previousHandoff: { handoffAttemptId: "attempt-1", result },
});
expect(f.create).toHaveBeenCalledTimes(1);
expect(f.send).toHaveBeenCalledTimes(1);
const acknowledged = {
...f.request,
completedHandoffAttemptId: "attempt-1",
};
await expect(
handlePrepareHandoff(f.ctx, acknowledged),
).resolves.toMatchObject({ gitPreparation: { id: "plan" } });
await handleDiscardHandoff(f.ctx, acknowledged);
await expect(handlePrepareHandoff(f.ctx, f.request)).resolves.toEqual(
retry,
);
await f.prepare("attempt-1");
f.summarize.mockRejectedValueOnce(new Error("Summary unavailable"));
await expect(
f.handoff({ ...f.request, handoffAttemptId: "attempt-2" }),
).rejects.toThrow("Summary unavailable");
await f.prepare();
});
it("does not return another account's completed handoff", async () => {
const f = fixture();
await f.prepare();
await f.handoff();
f.ctx.cloudSessionManager = fixture().ctx.cloudSessionManager;
await f.prepare();
});
it("discards only the canceled preview, not a newer preview", async () => {
const f = fixture();
await f.prepare();
await handleDiscardHandoff(f.ctx, {
...f.request,
gitPreparationId: "older-plan",
});
await expect(f.handoff()).resolves.toMatchObject({
sessionId: "ses-target",
});
await expect(
handlePrepareHandoff(f.ctx, {
...f.request,
completedHandoffAttemptId: "attempt-1",
}),
).resolves.toMatchObject({ gitPreparation: { id: "plan" } });
await handleDiscardHandoff(f.ctx, f.request);
await expect(f.handoff()).rejects.toThrow("review the preparation plan");
});
it.each([
false,
true,
])("invalidates only turns overlapping capture (captured=%s)", async (captured) => {
const f = fixture();
const ctx = getEnvironmentContext(f.ctx, "local");
vi.mocked(gitPreparation.inspectHandoffGit).mockImplementation(
async (_cwd, onCaptured?: () => void) => {
if (captured) onCaptured?.();
const release = beginActiveSessionSend(ctx, f.sourceSessionId);
f.messages.push({ role: "user", content: "Keep this turn local." });
release();
if (!captured) onCaptured?.();
return f.plan;
},
);
if (!captured) {
await expect(handlePrepareHandoff(ctx, f.request)).rejects.toThrow(
"conversation changed",
);
await expect(handleHandoff(ctx, f.request, f.summarize)).rejects.toThrow(
"review the preparation plan",
);
} else {
await handlePrepareHandoff(ctx, f.request);
await expect(
handleHandoff(ctx, f.request, f.summarize),
).resolves.toMatchObject({
sessionId: "ses-target",
});
expect(f.summarize.mock.calls[0]?.[0]).toHaveLength(2);
}
});
it("shows a provisioning failure reason without losing the unsent draft", async () => {
const f = fixture();
await f.prepare();
f.send.mockRejectedValue(
new CloudSessionError("session_failed", "Repository clone failed."),
);
await expect(f.handoff()).resolves.toMatchObject({
warning: expect.stringContaining("Repository clone failed."),
warningKind: "unqueued",
unsentPrompt: expect.stringContaining("Search form: implemented."),
});
});
it.each([
"preview",
"summary",
@@ -356,7 +462,7 @@ describe("summary cloud handoff", () => {
"If no next action is clear, ask",
);
expect(f.send.mock.calls[0]?.[4]).toEqual([image]);
await f.prepare();
await f.prepare("attempt-1");
await f.handoff({ ...f.request, handoffAttemptId: "attempt-2" });
expect(f.create.mock.calls.map(([input]) => input.requestId)).toEqual([
"attempt-1",
@@ -1,6 +1,10 @@
import { randomUUID } from "node:crypto";
import { buildCloudHandoffDashboardUrl, type ClineCore } from "@cline/core";
import { type CloudCreationOptions, loadCloudModels } from "@cline/core/cloud";
import {
type CloudCreationOptions,
CloudSessionError,
loadCloudModels,
} from "@cline/core/cloud";
import type { MessageWithMetadata } from "@cline/llms";
import {
type AgentMode,
@@ -47,10 +51,25 @@ type HandoffSnapshot = {
};
const gitPlans = new WeakMap<SidecarContext, Map<string, HandoffSnapshot>>();
const gitPreparations = new WeakMap<SidecarContext, Set<string>>();
const handoffRequests = new WeakMap<SidecarContext, Set<string>>();
const gitPreparations = new WeakMap<
SidecarContext,
Map<string, "capturing" | "changed" | "captured">
>();
type HandoffRequest = {
id: string;
cloud: CloudSessionManager;
} & ({ status: "running" } | { status: "complete"; result: HandoffResult });
const handoffRequests = new WeakMap<
SidecarContext,
Map<string, HandoffRequest>
>();
const activeSendRequests = new WeakMap<SidecarContext, Map<string, number>>();
export type HandoffResult = Awaited<ReturnType<typeof handleHandoffOnce>>;
export type PrepareHandoffResult =
| { gitPreparation: HandoffGitPreview }
| { previousHandoff: { handoffAttemptId: string; result: HandoffResult } };
function assertCloudHandoffAvailable(): void {
if (!isCloudAgentsEnabled())
throw new Error("Cloud sessions are not available for this account.");
@@ -148,17 +167,30 @@ async function resolveCloudModel(
export async function handlePrepareHandoff(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
): Promise<{ gitPreparation: HandoffGitPreview }> {
): Promise<PrepareHandoffResult> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
if (isCloudHandoffInProgress(ctx, sessionId))
throw new Error("Cloud preparation is already in progress.");
const previous = handoffRequests.get(ctx)?.get(sessionId);
if (
previous?.status === "complete" &&
previous.cloud === getSidecarContextOwner(ctx).cloudSessionManager &&
previous.id !== request.completedHandoffAttemptId
) {
return {
previousHandoff: {
handoffAttemptId: previous.id,
result: previous.result,
},
};
}
let active = gitPreparations.get(ctx);
if (!active) {
active = new Set();
active = new Map();
gitPreparations.set(ctx, active);
}
active.add(sessionId);
active.set(sessionId, "capturing");
try {
const cloud = getCloudSessionManager(ctx);
const source = await prepareCloudHandoff(ctx, request);
@@ -168,7 +200,13 @@ export async function handlePrepareHandoff(
);
if (!messages.length)
throw new Error("Start a conversation before using /cloud.");
const plan = await inspectHandoffGit(source.cwd);
const plan = await inspectHandoffGit(source.cwd, () => {
if (active.get(sessionId) === "changed")
throw new Error(
"The local conversation changed during capture. Run /cloud again when the turn finishes.",
);
active.set(sessionId, "captured");
});
await resolveCloudModel(ctx, cloud, config, plan.repoUrl);
assertHandoffAccount(ctx, cloud);
let plans = gitPlans.get(ctx);
@@ -183,6 +221,16 @@ export async function handlePrepareHandoff(
}
}
export async function handleDiscardHandoff(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
): Promise<void> {
const plans = gitPlans.get(ctx);
const id = request.sessionId?.trim();
if (id && plans && plans.get(id)?.plan.id === request.gitPreparationId)
plans.delete(id);
}
type Summarize = (
messages: MessageWithMetadata[],
config: JsonRecord,
@@ -192,7 +240,8 @@ async function handleHandoffOnce(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
summarize: Summarize,
): Promise<unknown> {
approved: HandoffSnapshot,
) {
if (request.attachments?.userFiles?.length)
throw new Error("Only image attachments can be sent with /cloud.");
const images = request.attachments?.userImages ?? [];
@@ -210,9 +259,6 @@ async function handleHandoffOnce(
assertCloudHandoffAvailable();
const sourceSessionId = request.sessionId?.trim();
if (!sourceSessionId) throw new Error("sessionId is required");
const approved = gitPlans.get(ctx)?.get(sourceSessionId);
if (!approved || approved.plan.id !== request.gitPreparationId)
throw new Error("Run /cloud again to review the preparation plan.");
gitPlans.get(ctx)?.delete(sourceSessionId);
const { cloud, config, messages, plan } = approved;
assertHandoffAccount(ctx, cloud);
@@ -316,12 +362,14 @@ async function handleHandoffOnce(
warning =
"The cloud connection was interrupted. Check the conversation before resending.";
}
} catch {
} catch (error) {
warningKind = dispatched ? "unconfirmed" : "unqueued";
warning =
warningKind === "unconfirmed"
? "The cloud session was created, but delivery is unconfirmed. Check the conversation before resending."
: "The cloud workspace was created, but the prompt was not sent. Keep Cline open to retry; closing it loses the unsent draft and may require a new cloud task.";
if (error instanceof CloudSessionError)
warning = `${error.detail} ${warning}`;
}
const result = {
sourceSessionId,
@@ -343,23 +391,36 @@ export async function handleHandoff(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
summarize: Summarize,
): Promise<unknown> {
): Promise<HandoffResult> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
if (gitPreparations.get(ctx)?.has(sessionId))
throw new Error("Wait for Git preparation to finish before continuing.");
let requests = handoffRequests.get(ctx);
if (!requests) {
requests = new Set();
requests = new Map();
handoffRequests.set(ctx, requests);
}
if (requests.has(sessionId))
if (requests.get(sessionId)?.status === "running")
throw new Error("Cloud preparation is already in progress.");
requests.add(sessionId);
const approved = gitPlans.get(ctx)?.get(sessionId);
if (!approved || approved.plan.id !== request.gitPreparationId)
throw new Error("Run /cloud again to review the preparation plan.");
const id = request.handoffAttemptId?.trim() || randomUUID();
const { cloud } = approved;
requests.set(sessionId, { id, cloud, status: "running" });
try {
return await handleHandoffOnce(ctx, request, summarize);
} finally {
const result = await handleHandoffOnce(
ctx,
{ ...request, handoffAttemptId: id },
summarize,
approved,
);
requests.set(sessionId, { id, cloud, status: "complete", result });
return result;
} catch (error) {
requests.delete(sessionId);
throw error;
}
}
@@ -368,6 +429,9 @@ export function beginActiveSessionSend(
sessionId: string,
): () => void {
ctx = getEnvironmentContext(ctx, ctx.activeEnvironmentId ?? "local");
const preparation = gitPreparations.get(ctx);
if (preparation?.get(sessionId) === "capturing")
preparation.set(sessionId, "changed");
let sends = activeSendRequests.get(ctx);
if (!sends) {
sends = new Map();
@@ -394,7 +458,7 @@ function isCloudHandoffInProgress(
sessionId: string,
): boolean {
return Boolean(
handoffRequests.get(ctx)?.has(sessionId) ||
handoffRequests.get(ctx)?.get(sessionId)?.status === "running" ||
gitPreparations.get(ctx)?.has(sessionId),
);
}
@@ -27,6 +27,7 @@ export type ChatSessionCommandRequest = {
| "attach"
| "send"
| "prepare_handoff"
| "discard_handoff"
| "handoff"
| "stop"
| "abort"
@@ -47,6 +48,7 @@ export type ChatSessionCommandRequest = {
attachments?: ChatTurnAttachments;
/** Opaque webview correlation token echoed on handoff progress events. */
handoffAttemptId?: string;
completedHandoffAttemptId?: string;
gitPreparationId?: string;
/** Optional instruction appended to the locally generated summary. */
nextCommand?: string;
+27 -3
View File
@@ -179,6 +179,7 @@ import {
workspacePathsFromSessions,
writeWorkspaceSelectionToWindow,
} from "@/lib/workspace-paths";
import type { PrepareHandoffResult } from "../../sidecar/cloud-handoff";
import type { HandoffGitPreview } from "../../sidecar/cloud-handoff-git";
// Lazily loaded views: none of these are needed for the first paint of the
@@ -2234,20 +2235,43 @@ function ChatThreadPane({
sourceSessionId,
});
const attachments = await serializeAttachments(sourceAttachments);
const { gitPreparation } = await desktopClient.invoke<{
gitPreparation: HandoffGitPreview;
}>(
const preparation = await desktopClient.invoke<PrepareHandoffResult>(
"chat_session_command",
{
request: {
action: "prepare_handoff",
sessionId: sourceSessionId,
completedHandoffAttemptId:
handoffLifecycle.getCompletedAttemptId(sourceSessionId),
config,
},
},
{ timeoutMs: HANDOFF_STAGE_TIMEOUT_MS },
);
if ("previousHandoff" in preparation) {
handoffLifecycle.onPreparationRejected(sourceSessionId, {
error: new Error(
"Your earlier cloud task completed. Check it before starting another task.",
),
nextCommand,
sourceAttachments,
silent: true,
});
handoffLifecycle.onRecovered(
sourceSessionId,
preparation.previousHandoff,
);
return;
}
const { gitPreparation } = preparation;
if (!(await confirmHandoffGit(gitPreparation))) {
await desktopClient.invoke("chat_session_command", {
request: {
action: "discard_handoff",
sessionId: sourceSessionId,
gitPreparationId: gitPreparation.id,
},
});
handoffLifecycle.onPreparationRejected(sourceSessionId, {
error: new Error("Cloud preparation cancelled."),
nextCommand,
@@ -35,6 +35,44 @@ function harness() {
}
describe("summary handoff lifecycle", () => {
it.each([
false,
true,
])("recovers A without substituting B's files (reload=%s)", async (reload) => {
const h = harness();
const aFiles = [new File(["a"], "a.png")];
const bFiles = [new File(["b"], "b.png")];
const id = reload ? "previous-window" : h.start(aFiles);
if (!reload)
await h.lifecycle.onRpcRejected("local", {
handoffAttemptId: id,
error: new Error("Connection closed"),
nextCommand: "A",
sourceAttachments: aFiles,
});
expect(h.lifecycle.getCompletedAttemptId("local")).toBeUndefined();
expect(h.lifecycle.onPreparationStarted("local")).toBe(true);
h.lifecycle.onPreparationRejected("local", {
error: new Error("Check the existing task"),
nextCommand: "B",
sourceAttachments: bFiles,
silent: true,
});
h.lifecycle.onRecovered("local", {
handoffAttemptId: id,
result: { ...result, warningKind: "unqueued", unsentPrompt: "Summary A" },
});
expect(h.state().local).toMatchObject({
status: "complete",
sessionId: "cloud",
unsentDraft: { text: "Summary A", attachments: reload ? [] : aFiles },
preparationFailure: { draft: { text: "B", attachments: bFiles } },
});
if (reload)
expect(h.state().local.message).toContain("Reattach any images");
expect(h.lifecycle.getCompletedAttemptId("local")).toBe(id);
expect(h.lifecycle.onPreparationStarted("local")).toBe(true);
});
it.each([
"before",
"after",
@@ -84,6 +84,32 @@ export function createHandoffLifecycle(effects: HandoffLifecycleEffects) {
if (warning) effects.toast(warning);
};
return {
getCompletedAttemptId(sourceSessionId: string): string | undefined {
const attempt = sources.get(sourceSessionId)?.attempt;
return attempt?.completed ? attempt.id : undefined;
},
onRecovered(
sourceSessionId: string,
recovered: { handoffAttemptId: string; result: HandoffResult },
): void {
let attempt = current(sourceSessionId, recovered.handoffAttemptId);
let result = recovered.result;
if (!attempt) {
attempt = {
id: recovered.handoffAttemptId,
completed: false,
attachments: [],
};
sources.set(sourceSessionId, { attempt, preparing: false });
if (result.unsentPrompt)
result = {
...result,
warning:
`${result.warning ?? ""} Reattach any images from the previous window before sending.`.trim(),
};
}
complete(sourceSessionId, attempt, result);
},
onPreparationStarted(sourceSessionId: string): boolean {
const source = sources.get(sourceSessionId);
if (source?.preparing) return false;
@@ -7,14 +7,7 @@ type ParsedHandoffCommand = {
nextCommand: string;
};
export type HandoffResult = {
sourceSessionId: string;
sessionId: string;
dashboardUrl: string;
warning?: string;
warningKind?: "unqueued" | "unconfirmed";
unsentPrompt?: string;
};
export type { HandoffResult } from "../../sidecar/cloud-handoff";
export type HandoffProgressPhase =
| "checking"