fix(core): preserve cloud discovery caching across Hub RPC

This commit is contained in:
John Choi
2026-10-23 10:26:12 -07:00
parent a76eddf715
commit 975af0624c
6 changed files with 90 additions and 6 deletions
+1 -1
View File
@@ -138,7 +138,7 @@ Design rules:
- `session/fork-metadata` owns fork ancestry and removal of inherited handoff markers; hosts retain title, transcript, and workspace-restore policies.
- `@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. 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.
- 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. 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. Both paths invalidate cloud discovery freshness, and lists started before invalidation cannot mark their results fresh. 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 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:
+1 -1
View File
@@ -141,6 +141,7 @@ a refresh. Pending refreshes retain their existing `timeoutMs` budget behavior.
Cached discovery still projects current live state and locally created sessions.
Desktop uses a 30-second window for combined history lists; local discovery,
cloud search, and session-open reads keep their existing refresh behavior.
Organization refreshes and returning Hub viewers invalidate discovery freshness.
### Hub-owned cloud viewers
@@ -194,7 +195,6 @@ depend on this optional refresh; cloud-disabled hosts skip it. A new viewer's
watch also invalidates the cache, covering switches made while the Hub was
unreachable without interrupting existing cloud work.
## Experimental cloud handoff
`loadCloudModels` and `CloudSessionController.listModels()` provide eligible cloud
@@ -259,6 +259,45 @@ describe("CloudSessionController neutral host contract", () => {
}
});
it("does not cache an old list that completes after organization invalidation", async () => {
const f = fixture();
let complete!: (rows: CloudSessionRecord[]) => void;
f.api.list.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = resolve;
}),
);
const old = f.controller.list();
await vi.waitFor(() => expect(f.api.list).toHaveBeenCalledOnce());
await f.controller.refreshOrganization();
f.api.list.mockResolvedValue([
{ ...record, id: "ses-current", title: "Current organization" },
]);
await f.controller.list();
let finishStatus!: (result: { status: string }) => void;
f.api.status.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishStatus = resolve;
}),
);
complete([
{ ...record, status: "provisioning", title: "Previous organization" },
]);
await vi.waitFor(() => expect(f.api.status).toHaveBeenCalledOnce());
expect(
(await f.controller.listForDiscovery({ maxAgeMs: 30_000 }))[0]?.metadata,
).toMatchObject({ title: "Current organization" });
finishStatus({ status: "ready" });
await old;
expect(
(await f.controller.listForDiscovery({ maxAgeMs: 30_000 }))[0]?.metadata,
).toMatchObject({ title: "Current organization" });
expect(f.api.list).toHaveBeenCalledTimes(4);
await f.controller.dispose();
});
it("joins a pending discovery refresh even while the previous cache is fresh", async () => {
const f = fixture();
await f.controller.listForDiscovery();
+11 -1
View File
@@ -439,6 +439,7 @@ export class CloudSessionController {
private readonly pendingInitialTasks: Map<string, CloudCreationOptions>;
private lastListedSessions: CloudSessionRecord[] = [];
private lastListedAt?: number;
private discoveryGeneration = 0;
private discoveryRefresh?: Promise<CloudSessionRecord[]>;
private readonly unconfirmedInnerCreates = new Set<string>();
private readonly createRequests = new Map<
@@ -702,6 +703,7 @@ export class CloudSessionController {
async list(): Promise<CloudSessionRecord[]> {
if (this.disposed) throw new Error("Cloud session controller was disposed");
const generation = this.discoveryGeneration;
const currentTitle = (id: string) =>
this.sessions.get(id)?.title ?? this.knownSessions.get(id)?.title;
const titlesBeforeRefresh = new Map(
@@ -727,6 +729,7 @@ export class CloudSessionController {
if (this.disposed) throw new Error("Cloud session controller was disposed");
// Keep canonical rows available while their status checks run.
this.lastListedSessions = listed;
this.lastListedAt = undefined;
for (const session of listed) {
preserveLocalTitle(session);
this.knownSessions.set(session.id, session);
@@ -803,7 +806,8 @@ export class CloudSessionController {
}
}
this.lastListedSessions = scoped;
this.lastListedAt = Date.now();
this.lastListedAt =
generation === this.discoveryGeneration ? Date.now() : undefined;
for (const record of scoped)
if (this.sessions.has(record.id)) this.publishSnapshot(record.id);
return immutableCopy(scoped);
@@ -840,7 +844,13 @@ export class CloudSessionController {
};
}
invalidateDiscoveryCache(): void {
this.lastListedAt = undefined;
this.discoveryGeneration++;
}
async refreshOrganization(): Promise<void> {
this.invalidateDiscoveryCache();
await this.resolveActiveOrganizationId({ fresh: true });
}
@@ -89,6 +89,7 @@ function fixture() {
send: vi.fn(async () => ({ sessionId: "ses-one", ok: true })),
restoreCreationOptions: vi.fn(async () => {}),
listForDiscovery: vi.fn(async () => [{ sessionId: "ses-one" }]),
invalidateDiscoveryCache: vi.fn(),
dispose: vi.fn(async () => {}),
};
return { controller, emit: (event: CloudSessionEvent) => emit(event) };
@@ -158,6 +159,17 @@ function fixture() {
}
describe("hub-owned cloud sessions", () => {
it("preserves discovery cache options across the cloud RPC boundary", async () => {
const f = fixture();
const viewer = f.viewer();
const options = { timeoutMs: 2_000, maxAgeMs: 30_000 };
await viewer.listForDiscovery(options);
expect(f.controllers[0]?.controller.listForDiscovery).toHaveBeenCalledWith(
options,
);
await viewer.dispose();
});
it("does not rebind a prepared request when account recovery runs before dispatch", async () => {
const f = fixture();
const viewer = f.viewer();
@@ -595,6 +607,10 @@ describe("hub cloud credentials", () => {
const controller = service["controller"]!;
const getOrganization = controller["options"].getActiveOrganizationId!;
expect(await getOrganization()).toBe("org-one");
const list = vi
.spyOn(controller["options"].api, "list")
.mockResolvedValue([]);
await command(service, "listForDiscovery", [{ maxAgeMs: 30_000 }]);
if (reason === "failed refresh") {
fetch.mockRejectedValueOnce(new Error("organization request failed"));
await expect(command(service, "refreshOrganization", [])).rejects.toThrow(
@@ -605,6 +621,9 @@ describe("hub cloud credentials", () => {
{ organizationId: "org-two", active: true },
] as never);
expect(await getOrganization()).toBe("org-two");
await command(service, "listForDiscovery", [{ maxAgeMs: 30_000 }]);
expect(list).toHaveBeenCalledTimes(2);
expect(list).toHaveBeenLastCalledWith("org-two");
expect(service["controller"]).toBe(controller);
});
@@ -633,8 +652,15 @@ describe("hub cloud credentials", () => {
expect(await getOrganization()).toBe("org-one");
expect(await getOrganization()).toBe("org-one");
expect(fetch).toHaveBeenCalledOnce();
const list = vi
.spyOn(controller["options"].api, "list")
.mockResolvedValue([]);
await command(service, "listForDiscovery", [{ maxAgeMs: 30_000 }]);
await command(service, "refreshOrganization", []);
expect(await getOrganization()).toBe("org-two");
await command(service, "listForDiscovery", [{ maxAgeMs: 30_000 }]);
expect(list).toHaveBeenCalledTimes(2);
expect(list).toHaveBeenLastCalledWith("org-two");
expect(service["controller"]).toBe(controller);
expect(fetch).toHaveBeenCalledTimes(2);
});
@@ -692,6 +718,7 @@ describe("hub cloud credentials", () => {
emit = listener;
},
dispose: async () => {},
invalidateDiscoveryCache: () => {},
}) as unknown as CloudSessionController,
});
services.push(service);
@@ -771,6 +798,7 @@ describe("persisted cloud account scope", () => {
const controller = {
subscribe: vi.fn(),
dispose: vi.fn(async () => {}),
invalidateDiscoveryCache: vi.fn(),
};
created.push(controller);
return controller as unknown as CloudSessionController;
@@ -802,7 +830,11 @@ describe("persisted cloud account scope", () => {
provider: "cline",
auth: { accountId: "same-account", accessToken },
}));
const controller = { subscribe: vi.fn(), dispose: vi.fn(async () => {}) };
const controller = {
subscribe: vi.fn(),
dispose: vi.fn(async () => {}),
invalidateDiscoveryCache: vi.fn(),
};
const createController = vi.fn(
() => controller as unknown as CloudSessionController,
);
@@ -71,7 +71,10 @@ const schemas = {
list: z.tuple([]),
listForDiscovery: z.tuple([
z
.object({ timeoutMs: z.number().positive().max(30000).optional() })
.object({
timeoutMs: z.number().positive().max(30000).optional(),
maxAgeMs: z.number().nonnegative().max(30000).optional(),
})
.nullish(),
]),
getCrossScopeDiscoveryRecord: z.tuple([id]),
@@ -322,7 +325,7 @@ export class HubCloudSessionService {
watch(): ReturnType<HubCloudSessionService["snapshot"]> {
// A returning viewer may have switched organization while the Hub was unreachable.
this.invalidateOrganization();
this.getController();
this.getController().invalidateDiscoveryCache();
return this.snapshot();
}
async command(