fix(cloud): resume saved sessions without transferring history

This commit is contained in:
John Choi
2026-10-23 17:19:15 -07:00
parent fb63f4dc96
commit 76d47f178a
3 changed files with 105 additions and 17 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ Design rules:
- `@cline/core/cloud` owns remote cloud-session state and emits immutable snapshots and events. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even if they missed the run start. Desktop uses `HubCloudSessionClient` over its existing shared Hub connection. `HubCloudSessionService` in the daemon owns its account-scoped controller, refresh-aware credentials, organization lookup, provisioning, sandbox connections, queues, approvals, and transcript reconciliation. Desktop projects the snapshots into its UI; feature gating and host persistence remain in the app. Direct controller hosts (including handoff flows) can still supply authentication. Importing this subpath does not initialize a local agent.
- `cloud/models` owns cloud model eligibility; `services/cloud-handoff` owns Git preflight, fingerprints, and transcript verification. Hosts own transfer orchestration, source locks, persistence, feature gating, and draft recovery.
- Cloud RPC validates an explicit operation allowlist and binds requests to the viewer’s observed hub instance and account-scope generation before dispatch. Handoff and prompt dispatch lifecycle callbacks stay with direct controller hosts and are excluded from the serializable RPC arguments. Viewer disconnects do not stop cloud work or close daemon-owned sandbox connections. Account changes retire the old controller and invalidate viewer caches; daemon shutdown disposes it. Cloud events carry a hub-instance id, account-scope generation, and revision; reconnecting subscriptions receive a complete snapshot set, including an empty set, and remove sessions and approvals absent from it. Scope checks run at command/auth boundaries and periodically, avoiding provider-settings reads per streamed event. Token refresh updates credential-based scope identity without retiring the same account’s controller. Discovery caches, pending listing requests, and newly created fallback rows are scoped to their organization; switching the sidebar preserves other organizations’ task state without displaying their rows. The discovery deadline covers organization resolution and listing together; an unresolved organization yields no cloud rows at the deadline. After an organization switch, desktop attempts a cloud-enabled organization refresh without making switch success or viewer cleanup depend on it. Fresh requests invalidate the old cache before fetching; a returning viewer also invalidates it to cover switches made while the Hub was unreachable. Cloud events are excluded from the durable local event log so old-account transcripts cannot be replayed after a scope change. Dispatched cloud mutations are never automatically resent after a lost reply.
- The cloud controller resumes suspended sessions and restores saved tasks. The Hub also exposes `session.resume` to initialize a runtime from stored history. Same-ID starts coordinate through initialization, before the first prompt runs; resume rejects a resident runtime while ordinary create retains replacement for configuration rebuilds.
- The cloud controller resumes suspended sessions and restores saved tasks with `session.resume`, falling back to uploading history only when an older Hub returns `unsupported_command`. The Hub initializes the runtime from stored history. Same-ID starts coordinate through initialization, before the first prompt runs; resume rejects a resident runtime while ordinary create retains replacement for configuration rebuilds.
- The hub retains pending first-task creation options with its controller across desktop viewer disconnects and reconnects. The controller consumes that intent when an inner task exists or is created. Account changes dispose the old scope and its intent.
- hub-related modules live under `packages/core/src/hub/`, grouped by service:
- `client/` contains host-facing hub clients and browser connection helpers
+77 -2
View File
@@ -4,6 +4,7 @@ import type {
MessageWithMetadata,
} from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import { HubCommandError } from "../hub/client/index";
import { toHubSessionRecord } from "../hub/server/hub-session-records";
import {
CloudSessionError,
@@ -116,6 +117,12 @@ function fixture(options: Partial<CloudSessionControllerOptions> = {}) {
]
: [],
};
if (name === "session.resume")
throw new HubCommandError(
name,
"unsupported_command",
"Unsupported command",
);
if (name === "session.create") {
hasInner = true;
result = { sessionId: "inner" };
@@ -686,7 +693,75 @@ describe("CloudSessionController neutral host contract", () => {
await f.controller.dispose();
});
it("attaches when the runtime reports a concurrent restore conflict", async () => {
it("resumes saved tasks without transferring the seed transcript", async () => {
const f = resumableFixture();
const messages: MessageWithMetadata[] = [
{ role: "user", content: "Saved work" },
];
f.setMessages(messages);
const original = f.command.getMockImplementation()!;
f.command.mockImplementation(async (...args) => {
if (args[0] === "session.update_connection")
throw Object.assign(new Error("Missing runtime"), {
code: "session_not_found",
});
if (args[0] === "session.resume")
return {
version: "v1",
ok: true,
payload: { session: { sessionId: "inner" } },
};
return original(...args);
});
await f.controller.attach(record.id);
const calls = f.command.mock.calls;
const resumeIndex = calls.findIndex(([name]) => name === "session.resume");
expect(resumeIndex).toBeGreaterThan(-1);
expect(calls[resumeIndex]?.[1]).toMatchObject({
sessionConfig: { sessionId: "inner" },
});
expect(calls[resumeIndex]?.[1]).not.toHaveProperty("initialMessages");
expect(
calls.slice(0, resumeIndex).some(([name]) => name === "session.messages"),
).toBe(false);
expect(calls.some(([name]) => name === "session.create")).toBe(false);
expect(await f.controller.readMessages(record.id)).toEqual(messages);
await f.controller.dispose();
});
it.each([
"hub_command_timeout",
"forbidden",
"command_failed",
])("does not replay history after resume fails with %s", async (code) => {
const f = resumableFixture();
const original = f.command.getMockImplementation()!;
const failure = new HubCommandError(
"session.resume",
code,
"Resume failed",
);
f.command.mockImplementation(async (...args) => {
if (args[0] === "session.update_connection")
throw Object.assign(new Error("Missing runtime"), {
code: "session_not_found",
});
if (args[0] === "session.resume") throw failure;
return original(...args);
});
await expect(f.controller.attach(record.id)).rejects.toBe(failure);
expect(
f.command.mock.calls.some(
([name]) => name === "session.messages" || name === "session.create",
),
).toBe(false);
await f.controller.dispose();
});
it.each([
"session.resume",
"session.create",
])("attaches after a concurrent restore conflict from %s", async (command) => {
const f = resumableFixture();
f.setMessages([{ role: "user", content: "Saved work" }]);
const original = f.command.getMockImplementation()!;
@@ -697,7 +772,7 @@ describe("CloudSessionController neutral host contract", () => {
code: "session_not_found",
});
}
if (args[0] === "session.create") {
if (args[0] === command) {
throw Object.assign(new Error("Already restored"), {
code: "session_already_exists",
});
+27 -14
View File
@@ -6,7 +6,7 @@ import type {
MessageWithMetadata,
ToolApprovalResult,
} from "@cline/shared";
import { NodeHubClient } from "../hub/client/index";
import { HubCommandError, NodeHubClient } from "../hub/client/index";
import { isSessionNotFoundError } from "../runtime/host/runtime-host";
import {
CloudHandoffTranscriptMismatchError,
@@ -2651,16 +2651,6 @@ export class CloudSessionController {
if (!missing) throw error;
}
this.assertSessionActive(connection.remote.id, connection);
const history = await connection.client.command(
"session.messages",
{},
sessionId,
);
this.assertSessionActive(connection.remote.id, connection);
const initialMessages = history.payload?.messages;
if (!Array.isArray(initialMessages) || initialMessages.length === 0) {
throw new Error("The saved conversation is empty or unavailable.");
}
const metadata = (saved.metadata ?? {}) as JsonRecord;
const runtimeOptions = (saved.runtimeOptions ?? {}) as JsonRecord;
const config = this.sessions.get(connection.remote.id)?.config;
@@ -2677,10 +2667,9 @@ export class CloudSessionController {
const cwd = saved.cwd ?? saved.workspaceRoot ?? CLOUD_WORKSPACE_ROOT;
const workspaceRoot = saved.workspaceRoot ?? CLOUD_WORKSPACE_ROOT;
try {
const reply = await connection.client.command("session.create", {
const payload = {
workspaceRoot,
cwd,
initialMessages,
metadata: { ...metadata, interactive: true },
sessionConfig: {
sessionId,
@@ -2704,7 +2693,31 @@ export class CloudSessionController {
...(typeof autoApproveTools === "boolean"
? { toolPolicies: { "*": { autoApprove: autoApproveTools } } }
: {}),
});
};
const reply = await connection.client
.command("session.resume", payload)
.catch(async (error: unknown) => {
if (
!(error instanceof HubCommandError) ||
error.code !== "unsupported_command"
)
throw error;
this.assertSessionActive(connection.remote.id, connection);
const history = await connection.client.command(
"session.messages",
{},
sessionId,
);
this.assertSessionActive(connection.remote.id, connection);
const initialMessages = history.payload?.messages;
if (!Array.isArray(initialMessages) || initialMessages.length === 0) {
throw new Error("The saved conversation is empty or unavailable.");
}
return connection.client.command("session.create", {
...payload,
initialMessages,
});
});
this.assertSessionActive(connection.remote.id, connection);
const restored = reply.payload?.session as JsonRecord | undefined;
if ((restored?.sessionId ?? reply.payload?.sessionId) !== sessionId) {