From 90ce357074f98b9bbb232834fc1ca704e8ac1d28 Mon Sep 17 00:00:00 2001 From: abeatrix Date: Fri, 23 Oct 2026 12:30:13 -0700 Subject: [PATCH] fix(core): include organization lookup in cloud discovery deadline --- sdk/ARCHITECTURE.md | 2 +- sdk/DOC.md | 5 +- .../core/src/cloud/controller.test.ts | 78 ++++++++++++++++++ sdk/packages/core/src/cloud/controller.ts | 81 +++++++++++-------- 4 files changed, 130 insertions(+), 36 deletions(-) diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 056ed54fbf..243b6dd044 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -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. 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. 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. 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 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: diff --git a/sdk/DOC.md b/sdk/DOC.md index df04002e94..43948b0be0 100644 --- a/sdk/DOC.md +++ b/sdk/DOC.md @@ -182,7 +182,10 @@ features and cannot cross this RPC boundary. Hub-backed creation defaults to 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. +task state without displaying their rows. Discovery’s `timeoutMs` covers both +organization resolution and listing. If the organization is still unknown at +the deadline, discovery returns no cloud rows; cached rows are used only after +establishing their matching organization. After a successful organization switch, `refreshOrganization()` invalidates the Hub's cached organization before fetching the current one. A fetch failure leaves diff --git a/sdk/packages/core/src/cloud/controller.test.ts b/sdk/packages/core/src/cloud/controller.test.ts index f7b98d7555..21dc3921b7 100644 --- a/sdk/packages/core/src/cloud/controller.test.ts +++ b/sdk/packages/core/src/cloud/controller.test.ts @@ -210,6 +210,84 @@ function resumableFixture(status = "ready") { } describe("CloudSessionController neutral host contract", () => { + it.each([ + 750, 2000, + ])("bounds a pending organization lookup by the %ims discovery budget", async (timeoutMs) => { + vi.useFakeTimers(); + const getOrganization = vi + .fn<() => Promise>() + .mockResolvedValue("org-one"); + const f = fixture({ getActiveOrganizationId: getOrganization }); + let release!: (organizationId: string | undefined) => void; + let discovering: Promise[]> | undefined; + try { + await f.controller.create({ + modelId: "model", + repoUrl: "https://github.com/cline/test", + }); + await f.controller.listForDiscovery(); + getOrganization.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + let settled = false; + discovering = f.controller + .listForDiscovery({ timeoutMs }) + .then((rows) => { + settled = true; + return rows; + }); + await vi.advanceTimersByTimeAsync(timeoutMs); + expect(settled).toBe(true); + expect(await discovering).toEqual([]); + } finally { + release?.("org-two"); + await vi.runAllTimersAsync(); + await discovering; + await f.controller.dispose(); + vi.useRealTimers(); + } + }); + it("shares one discovery budget between organization resolution and listing", async () => { + vi.useFakeTimers(); + let resolveOrganization!: (organizationId: string) => void; + const f = fixture({ + getActiveOrganizationId: () => + new Promise((resolve) => { + resolveOrganization = resolve; + }), + }); + let resolveListing!: (rows: CloudSessionRecord[]) => void; + f.api.list.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveListing = resolve; + }), + ); + let settled = false; + const discovering = f.controller + .listForDiscovery({ timeoutMs: 750 }) + .then((rows) => { + settled = true; + return rows; + }); + try { + await vi.advanceTimersByTimeAsync(650); + resolveOrganization("org-two"); + await vi.advanceTimersByTimeAsync(100); + expect(settled).toBe(true); + expect(await discovering).toEqual([]); + } finally { + resolveListing([]); + await vi.runAllTimersAsync(); + await discovering; + await f.controller.dispose(); + vi.useRealTimers(); + } + }); + it("does not reuse another organization's listing while the new listing times out", async () => { let organizationId = "org-one"; const f = fixture({ getActiveOrganizationId: async () => organizationId }); diff --git a/sdk/packages/core/src/cloud/controller.ts b/sdk/packages/core/src/cloud/controller.ts index b778d8685e..14245a69b7 100644 --- a/sdk/packages/core/src/cloud/controller.ts +++ b/sdk/packages/core/src/cloud/controller.ts @@ -929,32 +929,42 @@ export class CloudSessionController { async listForDiscovery( options: { timeoutMs?: number } = {}, ): Promise { - const organizationId = await this.resolveActiveOrganizationId(); - let refresh = - this.discoveryRefresh?.organizationId === organizationId - ? this.discoveryRefresh?.promise + let resolvedScope: { organizationId: string | undefined } | undefined; + const refresh = (async () => { + const organizationId = await this.resolveActiveOrganizationId(); + resolvedScope = { organizationId }; + let listing = + this.discoveryRefresh?.organizationId === organizationId + ? this.discoveryRefresh?.promise + : undefined; + if (!listing) { + const promise = this.listInOrganization(organizationId).finally(() => { + if (this.discoveryRefresh?.promise === promise) + this.discoveryRefresh = undefined; + }); + this.discoveryRefresh = { organizationId, promise }; + listing = promise; + } + return { organizationId, records: await listing }; + })(); + const cachedResult = () => + resolvedScope + ? { + organizationId: resolvedScope.organizationId, + records: + this.lastListedOrganizationId === resolvedScope.organizationId + ? this.lastListedSessions + : [], + } : undefined; - if (!refresh) { - const promise = this.listInOrganization(organizationId).finally(() => { - if (this.discoveryRefresh?.promise === promise) - this.discoveryRefresh = undefined; - }); - this.discoveryRefresh = { organizationId, promise }; - refresh = promise; - } - const cachedRecords = () => - this.lastListedOrganizationId === organizationId - ? this.lastListedSessions - : []; - let records = cachedRecords(); + let result: Awaited | undefined; if (options.timeoutMs === undefined) { - records = await refresh; + result = await refresh; } else { let timeout: ReturnType | undefined; - const result = await Promise.race([ - refresh.then( - (value) => ({ value }), - (error) => { + try { + result = await Promise.race([ + refresh.catch((error) => { if ( !( error instanceof CloudSessionError && @@ -965,19 +975,22 @@ export class CloudSessionController { error, }); } - return { value: cachedRecords() }; - }, - ), - new Promise<{ value: CloudSessionRecord[] }>((resolve) => { - timeout = setTimeout( - () => resolve({ value: cachedRecords() }), - Math.max(0, options.timeoutMs ?? 0), - ); - }), - ]); - if (timeout) clearTimeout(timeout); - records = result.value; + return cachedResult(); + }), + new Promise>((resolve) => { + timeout = setTimeout( + () => resolve(cachedResult()), + Math.max(0, options.timeoutMs ?? 0), + ); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } } + // Personal is a known scope with an undefined id; an unresolved scope is not. + if (!result) return []; + const { organizationId, records } = result; const recordsById = new Map( [