mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
fix(desktop): reuse recent cloud session listings
This commit is contained in:
@@ -65,7 +65,7 @@ export class CloudSessionManager extends CloudSessionController {
|
||||
});
|
||||
}
|
||||
override async listForDiscovery(
|
||||
options: { timeoutMs?: number } = {},
|
||||
options: Parameters<CloudSessionController["listForDiscovery"]>[0] = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return (await super.listForDiscovery(options)).map((record) => ({
|
||||
...record,
|
||||
|
||||
@@ -388,6 +388,7 @@ const OPENABLE_URL_PROTOCOLS = new Set(["https:", "http:", "mailto:", "tel:"]);
|
||||
|
||||
// Fall back to cached rows rather than stall the sidebar on the cloud API.
|
||||
const CLOUD_DISCOVERY_BUDGET_MS = 2_000;
|
||||
const CLOUD_DISCOVERY_MAX_AGE_MS = 30_000;
|
||||
|
||||
function openUrlInDefaultBrowser(url: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
@@ -2360,7 +2361,10 @@ export async function handleCommand(
|
||||
const local = discoverChatSessions(ctx, limit);
|
||||
if (!isCloudAgentsEnabled()) return local;
|
||||
const cloud = await getCloudSessionManager(ctx)
|
||||
.listForDiscovery({ timeoutMs: CLOUD_DISCOVERY_BUDGET_MS })
|
||||
.listForDiscovery({
|
||||
timeoutMs: CLOUD_DISCOVERY_BUDGET_MS,
|
||||
maxAgeMs: CLOUD_DISCOVERY_MAX_AGE_MS,
|
||||
})
|
||||
.catch((error) => {
|
||||
// A persistent listing failure silently empties the sidebar's
|
||||
// cloud rows; keep a diagnostic trail.
|
||||
@@ -2387,7 +2391,10 @@ export async function handleCommand(
|
||||
)) as JsonRecord[];
|
||||
if (!isCloudAgentsEnabled()) return local;
|
||||
const cloud = await getCloudSessionManager(ctx)
|
||||
.listForDiscovery({ timeoutMs: CLOUD_DISCOVERY_BUDGET_MS })
|
||||
.listForDiscovery({
|
||||
timeoutMs: CLOUD_DISCOVERY_BUDGET_MS,
|
||||
maxAgeMs: CLOUD_DISCOVERY_MAX_AGE_MS,
|
||||
})
|
||||
.catch((error) => {
|
||||
ctx.logger?.error?.("Cloud session discovery failed", { error });
|
||||
return [];
|
||||
|
||||
@@ -135,6 +135,13 @@ the outer session is deleted). `dispose` preserves an injected map; without one,
|
||||
the controller owns and clears its pending state. Restoring options alone never
|
||||
authorizes recreation of a missing established task.
|
||||
|
||||
`CloudSessionController.listForDiscovery({ maxAgeMs })` can reuse the last
|
||||
successful list for the specified duration. Omitting `maxAgeMs` starts or joins
|
||||
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.
|
||||
|
||||
## Experimental cloud handoff
|
||||
|
||||
`loadCloudModels` and `CloudSessionController.listModels()` provide eligible cloud
|
||||
|
||||
@@ -210,6 +210,109 @@ function resumableFixture(status = "ready") {
|
||||
}
|
||||
|
||||
describe("CloudSessionController neutral host contract", () => {
|
||||
it.each([
|
||||
{ rows: [] },
|
||||
{ rows: [record] },
|
||||
])("reuses a successful discovery list within maxAgeMs: %j", async ({
|
||||
rows,
|
||||
}) => {
|
||||
const f = fixture();
|
||||
f.api.list.mockResolvedValue(rows);
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
try {
|
||||
const initial = await f.controller.listForDiscovery({ maxAgeMs: 30_000 });
|
||||
now.mockReturnValue(29_999);
|
||||
expect(await f.controller.listForDiscovery({ maxAgeMs: 30_000 })).toEqual(
|
||||
initial,
|
||||
);
|
||||
expect(f.api.list).toHaveBeenCalledTimes(1);
|
||||
now.mockReturnValue(30_000);
|
||||
await f.controller.listForDiscovery({ maxAgeMs: 30_000 });
|
||||
expect(f.api.list).toHaveBeenCalledTimes(2);
|
||||
await f.controller.listForDiscovery();
|
||||
expect(f.api.list).toHaveBeenCalledTimes(3);
|
||||
await f.controller.dispose();
|
||||
await expect(
|
||||
f.controller.listForDiscovery({ maxAgeMs: 30_000 }),
|
||||
).rejects.toThrow("disposed");
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
await f.controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not renew discovery freshness after a failed refresh", async () => {
|
||||
const f = fixture();
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
try {
|
||||
const initial = await f.controller.listForDiscovery({ maxAgeMs: 30_000 });
|
||||
now.mockReturnValue(30_000);
|
||||
f.api.list.mockRejectedValueOnce(new Error("Network unavailable"));
|
||||
expect(
|
||||
await f.controller.listForDiscovery({ maxAgeMs: 30_000, timeoutMs: 1 }),
|
||||
).toEqual(initial);
|
||||
await f.controller.listForDiscovery({ maxAgeMs: 30_000 });
|
||||
expect(f.api.list).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
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();
|
||||
let complete!: (rows: CloudSessionRecord[]) => void;
|
||||
f.api.list.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const refresh = f.controller.listForDiscovery();
|
||||
const cached = f.controller.listForDiscovery({ maxAgeMs: 30_000 });
|
||||
await vi.waitFor(() => expect(f.api.list).toHaveBeenCalledTimes(2));
|
||||
complete([{ ...record, title: "Updated remotely" }]);
|
||||
await refresh;
|
||||
expect((await cached)[0]?.metadata).toMatchObject({
|
||||
title: "Updated remotely",
|
||||
});
|
||||
await f.controller.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("keeps cached discovery consistent after deletion (fails: %s)", async (fails) => {
|
||||
const f = fixture();
|
||||
await f.controller.listForDiscovery();
|
||||
if (fails) {
|
||||
f.api.delete.mockRejectedValueOnce(new Error("Network unavailable"));
|
||||
await expect(f.controller.delete(record.id)).rejects.toThrow(
|
||||
"Network unavailable",
|
||||
);
|
||||
} else {
|
||||
await f.controller.delete(record.id);
|
||||
}
|
||||
expect(
|
||||
await f.controller.listForDiscovery({ maxAgeMs: 30_000 }),
|
||||
).toHaveLength(fails ? 1 : 0);
|
||||
expect(f.api.list).toHaveBeenCalledTimes(1);
|
||||
await f.controller.dispose();
|
||||
});
|
||||
|
||||
it("keeps cached discovery titles current after a readiness refresh and rename", async () => {
|
||||
const f = fixture();
|
||||
await f.controller.listForDiscovery();
|
||||
await f.controller.waitUntilReady(record.id);
|
||||
await f.controller.updateTitle(record.id, "New title");
|
||||
expect(
|
||||
(await f.controller.listForDiscovery({ maxAgeMs: 30_000 }))[0]?.metadata,
|
||||
).toMatchObject({ title: "New title" });
|
||||
expect(f.api.list).toHaveBeenCalledTimes(2);
|
||||
await f.controller.dispose();
|
||||
});
|
||||
|
||||
it("refreshes an attached session title after an external rename", async () => {
|
||||
const f = await attached();
|
||||
await f.controller.updateTitle(record.id, "Desktop title");
|
||||
|
||||
@@ -438,6 +438,7 @@ export class CloudSessionController {
|
||||
private readonly unlistedSessions = new Map<string, CloudSessionRecord>();
|
||||
private readonly pendingInitialTasks: Map<string, CloudCreationOptions>;
|
||||
private lastListedSessions: CloudSessionRecord[] = [];
|
||||
private lastListedAt?: number;
|
||||
private discoveryRefresh?: Promise<CloudSessionRecord[]>;
|
||||
private readonly unconfirmedInnerCreates = new Set<string>();
|
||||
private readonly createRequests = new Map<
|
||||
@@ -802,6 +803,7 @@ export class CloudSessionController {
|
||||
}
|
||||
}
|
||||
this.lastListedSessions = scoped;
|
||||
this.lastListedAt = Date.now();
|
||||
for (const record of scoped)
|
||||
if (this.sessions.has(record.id)) this.publishSnapshot(record.id);
|
||||
return immutableCopy(scoped);
|
||||
@@ -907,21 +909,29 @@ export class CloudSessionController {
|
||||
}
|
||||
|
||||
async listForDiscovery(
|
||||
options: { timeoutMs?: number } = {},
|
||||
options: { timeoutMs?: number; maxAgeMs?: number } = {},
|
||||
): Promise<JsonRecord[]> {
|
||||
if (this.disposed) throw new Error("Cloud session controller was disposed");
|
||||
const maxAgeMs = options.maxAgeMs ?? 0;
|
||||
const cacheIsFresh =
|
||||
maxAgeMs > 0 &&
|
||||
this.lastListedAt !== undefined &&
|
||||
Date.now() - this.lastListedAt < maxAgeMs;
|
||||
const refresh =
|
||||
this.discoveryRefresh ??
|
||||
this.list().finally(() => {
|
||||
if (this.discoveryRefresh === refresh) {
|
||||
this.discoveryRefresh = undefined;
|
||||
}
|
||||
});
|
||||
(cacheIsFresh
|
||||
? undefined
|
||||
: this.list().finally(() => {
|
||||
if (this.discoveryRefresh === refresh) {
|
||||
this.discoveryRefresh = undefined;
|
||||
}
|
||||
}));
|
||||
this.discoveryRefresh = refresh;
|
||||
|
||||
let records = this.lastListedSessions;
|
||||
if (options.timeoutMs === undefined) {
|
||||
if (refresh && options.timeoutMs === undefined) {
|
||||
records = await refresh;
|
||||
} else {
|
||||
} else if (refresh) {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const result = await Promise.race([
|
||||
refresh.then(
|
||||
@@ -2007,6 +2017,12 @@ export class CloudSessionController {
|
||||
if (record) {
|
||||
record.title = title;
|
||||
}
|
||||
const listed = this.lastListedSessions.find(
|
||||
(session) => session.id === outerSessionId,
|
||||
);
|
||||
if (listed) {
|
||||
listed.title = title;
|
||||
}
|
||||
const live = this.sessions.get(outerSessionId);
|
||||
if (live) {
|
||||
live.title = title;
|
||||
@@ -2044,6 +2060,9 @@ export class CloudSessionController {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.lastListedSessions = this.lastListedSessions.filter(
|
||||
(record) => record.id !== outerSessionId,
|
||||
);
|
||||
this.knownSessions.delete(outerSessionId);
|
||||
this.unlistedSessions.delete(outerSessionId);
|
||||
this.pendingInitialTasks.delete(outerSessionId);
|
||||
|
||||
Reference in New Issue
Block a user