mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
fix(core): defer replacing a Hub that is serving live sessions
Retiring a Hub kills its established WebSockets, so replacing one under a running session ends that turn with an abnormal close (code=1006). The replacement is correct - the newer build should own the Hub - but the timing is not the user's to absorb mid-turn. Defer instead while the Hub reports live sessions: the newer client attaches to the older Hub over the compatible wire protocol, and the swap happens once those sessions end. Attaching rather than spawning matters - a second daemon would race the busy one for the port. Deferring silently would be worse than the interruption it avoids, because a long-lived session pins the Hub to old code indefinitely with nothing to show for it. The build-mismatch watcher only ever prompted in the direction where updating the client resolves the mismatch; its own comment notes that older Hubs "are retired and replaced automatically, so prompting would only flash a stale dialog", which stops being true once replacement can be deferred. Add the missing direction as `outdated_hub`, reported only when a mismatch survives consecutive checks - an idle older Hub is replaced within moments of being seen, so a single sighting would flash exactly the stale dialog the original comment warns about. The CLI and desktop dialogs render it as information rather than an update prompt: nothing to install, the Hub swaps itself when the sessions end. The direction is decided by compareHubBuilds rather than reusability, because a Hub that is newer and one that carries too little metadata to order are both "reusable" but need opposite advice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,9 @@ import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
type ClineSubscriptionPlan,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
type UserCurrentPlan,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
|
||||
@@ -6,23 +6,50 @@ import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
hubCoreVersion?: string;
|
||||
/**
|
||||
* `outdated_hub` means this CLI is already the newer build and the Hub was
|
||||
* left running because it is serving sessions. There is nothing to install,
|
||||
* so the dialog only explains what to do.
|
||||
*/
|
||||
reason?: "unsupported_protocol" | "build_mismatch" | "outdated_hub";
|
||||
}
|
||||
|
||||
export function HubUpdateRequiredContent(
|
||||
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
|
||||
) {
|
||||
const { dialogId, dismiss, hubCoreVersion, resolve } = props;
|
||||
const { dialogId, dismiss, hubCoreVersion, reason, resolve } = props;
|
||||
const hubIsOutdated = reason === "outdated_hub";
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
const action = resolveHubUpdateRequiredKeyAction(key);
|
||||
if (action === "ignore") return;
|
||||
if (action === "update") {
|
||||
if (action === "update" && !hubIsOutdated) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
}, dialogId);
|
||||
|
||||
if (hubIsOutdated) {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="yellow">Cline Hub is running an older build</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
This CLI is newer than the shared Cline Hub
|
||||
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""}, which was left
|
||||
running because it is still serving active sessions.
|
||||
</text>
|
||||
<text selectable>
|
||||
Your work is unaffected. The Hub is replaced with the newer build
|
||||
once those sessions end.
|
||||
</text>
|
||||
</box>
|
||||
<text fg={palette.muted}>Press Esc to dismiss</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="yellow">Cline Hub was updated</text>
|
||||
|
||||
@@ -584,10 +584,15 @@ function App(props: TuiProps) {
|
||||
if (!hubBuildMismatch) return;
|
||||
setHubBuildMismatch(null);
|
||||
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
|
||||
const reason = hubBuildMismatch.reason;
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<HubUpdateRequiredContent {...ctx} hubCoreVersion={hubCoreVersion} />
|
||||
<HubUpdateRequiredContent
|
||||
{...ctx}
|
||||
hubCoreVersion={hubCoreVersion}
|
||||
reason={reason}
|
||||
/>
|
||||
),
|
||||
})
|
||||
.then((update) => {
|
||||
@@ -596,7 +601,9 @@ function App(props: TuiProps) {
|
||||
return;
|
||||
}
|
||||
showToast(
|
||||
"Hub still differs from this CLI. Run 'cline update' and restart when convenient.",
|
||||
reason === "outdated_hub"
|
||||
? "Hub is on an older build until its active sessions end. No action needed."
|
||||
: "Hub still differs from this CLI. Run 'cline update' and restart when convenient.",
|
||||
"info",
|
||||
);
|
||||
refocusTextareaRef.current();
|
||||
|
||||
@@ -62,6 +62,10 @@ export function HubUpdateRequiredDialog() {
|
||||
|
||||
const mismatchKey = mismatch ? mismatchKeyOf(mismatch) : null;
|
||||
const open = mismatchKey !== null && mismatchKey !== dismissedKey;
|
||||
// This app is already the newer build: the Hub is behind because it is
|
||||
// serving sessions, so there is nothing to install and the dialog is
|
||||
// informational.
|
||||
const hubIsOutdated = mismatch?.reason === "outdated_hub";
|
||||
|
||||
const handleUpdateAndRestart = useCallback(async () => {
|
||||
setPhase("updating");
|
||||
@@ -90,38 +94,58 @@ export function HubUpdateRequiredDialog() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cline Hub was updated</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
{hubIsOutdated
|
||||
? "Cline Hub is running an older build"
|
||||
: "Cline Hub was updated"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Another Cline installation updated the shared Cline Hub
|
||||
{mismatch?.hubCoreVersion
|
||||
? ` (core ${mismatch.hubCoreVersion})`
|
||||
: ""}
|
||||
, and it no longer matches this app. Update and restart Cline Code
|
||||
to stay in sync with the running Hub.
|
||||
{hubIsOutdated ? (
|
||||
<>
|
||||
This app is newer than the shared Cline Hub
|
||||
{mismatch?.hubCoreVersion
|
||||
? ` (core ${mismatch.hubCoreVersion})`
|
||||
: ""}
|
||||
, which was left running because it is still serving active
|
||||
sessions. Your work is unaffected - the Hub is replaced with the
|
||||
newer build once those sessions end.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Another Cline installation updated the shared Cline Hub
|
||||
{mismatch?.hubCoreVersion
|
||||
? ` (core ${mismatch.hubCoreVersion})`
|
||||
: ""}
|
||||
, and it no longer matches this app. Update and restart Cline
|
||||
Code to stay in sync with the running Hub.
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
{updateHint ? (
|
||||
{updateHint && !hubIsOutdated ? (
|
||||
<AlertDialogDescription>{updateHint}</AlertDialogDescription>
|
||||
) : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={phase !== "idle"}>
|
||||
Later
|
||||
{hubIsOutdated ? "Got it" : "Later"}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={phase !== "idle"}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleUpdateAndRestart();
|
||||
}}
|
||||
>
|
||||
{phase === "restarting"
|
||||
? "Restarting…"
|
||||
: phase === "updating"
|
||||
? "Checking for updates…"
|
||||
: updateHint
|
||||
? "Try again"
|
||||
: "Update and restart"}
|
||||
</AlertDialogAction>
|
||||
{hubIsOutdated ? null : (
|
||||
<AlertDialogAction
|
||||
disabled={phase !== "idle"}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleUpdateAndRestart();
|
||||
}}
|
||||
>
|
||||
{phase === "restarting"
|
||||
? "Restarting…"
|
||||
: phase === "updating"
|
||||
? "Checking for updates…"
|
||||
: updateHint
|
||||
? "Try again"
|
||||
: "Update and restart"}
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -953,7 +953,7 @@ function hasActiveHubSessions(payload: unknown): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
async function localHubHasNoActiveSessions(
|
||||
export async function localHubHasNoActiveSessions(
|
||||
url: string,
|
||||
authToken?: string,
|
||||
options?: Pick<HubClientOptions, "workspaceRoot" | "cwd">,
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("checkManagedHubBuildMismatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not prompt for an older or unordered hub build (it gets replaced instead)", async () => {
|
||||
it("reports an older hub build as outdated rather than a client update", async () => {
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
mockDiscovery({
|
||||
record: liveRecord,
|
||||
@@ -91,10 +91,15 @@ describe("checkManagedHubBuildMismatch", () => {
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toBeUndefined();
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toMatchObject({
|
||||
reason: "outdated_hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not prompt for a legacy hub without build metadata", async () => {
|
||||
// A Hub carrying no ordering metadata cannot be placed relative to this
|
||||
// build, and updating this client is what supplies the missing metadata, so
|
||||
// it stays a client-update prompt.
|
||||
it("prompts to update the client for a legacy hub without build metadata", async () => {
|
||||
mockDiscovery({
|
||||
record: liveRecord,
|
||||
probe: {
|
||||
@@ -108,7 +113,9 @@ describe("checkManagedHubBuildMismatch", () => {
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toBeUndefined();
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toMatchObject({
|
||||
reason: "build_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts when the hub protocol is not supported by this client", async () => {
|
||||
@@ -175,6 +182,125 @@ describe("watchManagedHubBuildMismatch", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
/**
|
||||
* An older Hub is normally retired within moments of being seen, so
|
||||
* reporting the first sighting would flash a dialog about a Hub that is
|
||||
* already gone. Only a Hub still there on the next check was deliberately
|
||||
* left running.
|
||||
*/
|
||||
it("reports an outdated hub only once it survives a second check", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
const probeResult: Record<string, unknown> | undefined = {
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
buildEpochMs: 500,
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
};
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-watcher-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-watcher-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
resolveHubBuildId: () => "current-build",
|
||||
readHubDiscovery: vi.fn(async () => liveRecord),
|
||||
probeHubServer: vi.fn(async () => probeResult),
|
||||
};
|
||||
});
|
||||
const { watchManagedHubBuildMismatch } = await import(
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
const onMismatch = vi.fn();
|
||||
const stop = watchManagedHubBuildMismatch({
|
||||
onMismatch,
|
||||
intervalMs: 1_000,
|
||||
});
|
||||
try {
|
||||
// First sighting is held back.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(onMismatch).not.toHaveBeenCalled();
|
||||
|
||||
// Still there on the next check: the Hub was left in place.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(onMismatch).toHaveBeenCalledTimes(1);
|
||||
expect(onMismatch).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ reason: "outdated_hub" }),
|
||||
);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("never reports an outdated hub that is replaced right after it is seen", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
let probeResult: Record<string, unknown> | undefined = {
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
buildEpochMs: 500,
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
};
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-watcher-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-watcher-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
resolveHubBuildId: () => "current-build",
|
||||
readHubDiscovery: vi.fn(async () => liveRecord),
|
||||
probeHubServer: vi.fn(async () => probeResult),
|
||||
};
|
||||
});
|
||||
const { watchManagedHubBuildMismatch } = await import(
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
const onMismatch = vi.fn();
|
||||
const stop = watchManagedHubBuildMismatch({
|
||||
onMismatch,
|
||||
intervalMs: 1_000,
|
||||
});
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
// Replaced by this build before the next check.
|
||||
probeResult = {
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
};
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
expect(onMismatch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("fires once per mismatched hub build and re-arms after recovery", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { isHubDaemonProcess, resolveClineBuildEnv } from "@cline/shared";
|
||||
import {
|
||||
compareHubBuilds,
|
||||
getManagedHubCompatibility,
|
||||
type HubOwnerContext,
|
||||
isManagedHubReusable,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveHubBuildId,
|
||||
resolveHubBuildIdentity,
|
||||
} from "../discovery";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
@@ -26,9 +27,16 @@ function resolveDefaultWatchIntervalMs(): number {
|
||||
export interface ManagedHubBuildMismatchEvent {
|
||||
/** WebSocket URL of the live managed Hub that does not match this build. */
|
||||
url: string;
|
||||
/** Why this client should update: the running Hub is a newer build this
|
||||
* client stays attached to, or it speaks an unsupported protocol. */
|
||||
reason: "unsupported_protocol" | "build_mismatch";
|
||||
/**
|
||||
* Why client and Hub disagree:
|
||||
* - `build_mismatch`: the running Hub is a newer build this client stays
|
||||
* attached to, and updating this client resolves it.
|
||||
* - `unsupported_protocol`: this client cannot speak the Hub's protocol.
|
||||
* - `outdated_hub`: this client is the newer build, but the running Hub was
|
||||
* left in place because it is still serving sessions. Nothing to install;
|
||||
* the Hub is replaced once those sessions end.
|
||||
*/
|
||||
reason: "unsupported_protocol" | "build_mismatch" | "outdated_hub";
|
||||
/** Build identity reported by the running Hub, when it reports one. */
|
||||
hubBuildId?: string;
|
||||
/** Core package version reported by the running Hub. */
|
||||
@@ -86,11 +94,12 @@ export async function checkManagedHubBuildMismatch(): Promise<
|
||||
if (compatibility.compatible) {
|
||||
return undefined;
|
||||
}
|
||||
// Prompt only for mismatches that persist and that updating this client
|
||||
// resolves: a newer reusable Hub this client stays attached to, or a Hub
|
||||
// whose protocol this client cannot speak at all. Older or unordered
|
||||
// builds are retired and replaced automatically, so prompting would only
|
||||
// flash a stale dialog.
|
||||
// Prompt for mismatches that persist: a newer reusable Hub this client
|
||||
// stays attached to, a Hub whose protocol this client cannot speak, or an
|
||||
// older Hub that was left running because it is serving sessions. An older
|
||||
// idle Hub is retired and replaced automatically, so reporting it here
|
||||
// would only flash a stale dialog - the caller filters that case by
|
||||
// requiring the mismatch to survive consecutive checks.
|
||||
const report = (
|
||||
reason: ManagedHubBuildMismatchEvent["reason"],
|
||||
): ManagedHubBuildMismatchEvent => ({
|
||||
@@ -103,13 +112,15 @@ export async function checkManagedHubBuildMismatch(): Promise<
|
||||
if (compatibility.reason === "unsupported_protocol") {
|
||||
return report("unsupported_protocol");
|
||||
}
|
||||
if (
|
||||
compatibility.reason === "build_mismatch" &&
|
||||
isManagedHubReusable(healthy)
|
||||
) {
|
||||
return report("build_mismatch");
|
||||
// Only a Hub this client is strictly newer than gets the "older Hub" copy;
|
||||
// that is the case the retire path defers while sessions are live. A newer
|
||||
// Hub - or one that carries too little metadata to order, where updating
|
||||
// this client is what supplies the missing ordering - is a client-update
|
||||
// prompt as before.
|
||||
if (compareHubBuilds(resolveHubBuildIdentity(), healthy) > 0) {
|
||||
return report("outdated_hub");
|
||||
}
|
||||
return undefined;
|
||||
return report("build_mismatch");
|
||||
}
|
||||
|
||||
export interface WatchManagedHubBuildOptions {
|
||||
@@ -140,6 +151,7 @@ export function watchManagedHubBuildMismatch(
|
||||
}
|
||||
const intervalMs = options.intervalMs ?? resolveDefaultWatchIntervalMs();
|
||||
let notifiedKey: string | undefined;
|
||||
let pendingKey: string | undefined;
|
||||
let checking = false;
|
||||
const timer = setInterval(() => {
|
||||
if (checking) {
|
||||
@@ -150,12 +162,21 @@ export function watchManagedHubBuildMismatch(
|
||||
.then((mismatch) => {
|
||||
if (!mismatch) {
|
||||
notifiedKey = undefined;
|
||||
pendingKey = undefined;
|
||||
return;
|
||||
}
|
||||
const key = `${mismatch.reason}:${mismatch.hubBuildId ?? ""}`;
|
||||
if (key === notifiedKey) {
|
||||
return;
|
||||
}
|
||||
// An older Hub is normally retired and replaced within a moment
|
||||
// of being observed. Only report one that is still there on the
|
||||
// next check, which means it was deliberately left running.
|
||||
if (mismatch.reason === "outdated_hub" && pendingKey !== key) {
|
||||
pendingKey = key;
|
||||
return;
|
||||
}
|
||||
pendingKey = undefined;
|
||||
notifiedKey = key;
|
||||
options.onMismatch(mismatch);
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
openSync,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
verifyHubConnection,
|
||||
localHubHasNoActiveSessions,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
createHubServerUrl,
|
||||
@@ -28,6 +29,8 @@ const {
|
||||
openSync: vi.fn(() => 17),
|
||||
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
|
||||
verifyHubConnection: vi.fn(),
|
||||
// Idle by default, so existing replacement cases are unaffected.
|
||||
localHubHasNoActiveSessions: vi.fn(async () => true),
|
||||
resolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
})),
|
||||
@@ -96,6 +99,7 @@ vi.mock("@cline/shared", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
localHubHasNoActiveSessions,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
requestHubShutdown,
|
||||
verifyHubConnection,
|
||||
@@ -137,6 +141,8 @@ describe("ensureDetachedHubServer", () => {
|
||||
rememberRecoverableLocalHubUrl.mockReset();
|
||||
rememberRecoverableLocalHubUrl.mockImplementation((url: string) => url);
|
||||
verifyHubConnection.mockReset();
|
||||
localHubHasNoActiveSessions.mockReset();
|
||||
localHubHasNoActiveSessions.mockResolvedValue(true);
|
||||
clearHubDiscovery.mockReset();
|
||||
clearHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer.mockReset();
|
||||
@@ -404,6 +410,39 @@ describe("ensureDetachedHubServer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches to an older hub that is still serving sessions instead of retiring it", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
localHubHasNoActiveSessions.mockResolvedValue(false);
|
||||
readHubDiscovery.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "busy-token",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
});
|
||||
// Reuse is rejected by build id before any connection check, so the
|
||||
// only verify call is the one guarding the deferred attach.
|
||||
verifyHubConnection.mockResolvedValue(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
|
||||
await expect(ensureDetachedHubServer("/workspace")).resolves.toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "busy-token",
|
||||
});
|
||||
expect(requestHubShutdown).not.toHaveBeenCalled();
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(clearHubDiscovery).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a healthy hub from a newer build without retiring it", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
localHubHasNoActiveSessions,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
requestHubShutdown,
|
||||
verifyHubConnection,
|
||||
@@ -49,6 +50,13 @@ const retireAttemptsByUrl = new Map<
|
||||
{ count: number; windowStartedAt: number }
|
||||
>();
|
||||
|
||||
export const __test__ = {
|
||||
/** Retire attempts are module state keyed by URL; clear between cases. */
|
||||
resetRetireAttempts(): void {
|
||||
retireAttemptsByUrl.clear();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Circuit breaker on repeated retirements of the same Hub URL.
|
||||
*
|
||||
@@ -60,12 +68,6 @@ const retireAttemptsByUrl = new Map<
|
||||
* socket close. Backing off after a few attempts keeps a future ordering bug to
|
||||
* a stale-build prompt instead of an unusable Hub.
|
||||
*/
|
||||
export const __test__ = {
|
||||
resetRetireAttempts(): void {
|
||||
retireAttemptsByUrl.clear();
|
||||
},
|
||||
};
|
||||
|
||||
function shouldAttemptRetire(url: string, now = Date.now()): boolean {
|
||||
const entry = retireAttemptsByUrl.get(url);
|
||||
if (!entry || now - entry.windowStartedAt > HUB_RETIRE_ATTEMPT_WINDOW_MS) {
|
||||
@@ -167,14 +169,49 @@ async function retireDiscoveredHub(
|
||||
return retired;
|
||||
}
|
||||
|
||||
export type HubRetirementOutcome =
|
||||
| "reusable"
|
||||
| "retired"
|
||||
| "deferred_busy"
|
||||
| "failed";
|
||||
|
||||
/**
|
||||
* Whether the Hub is currently serving sessions, and so must not be shut down
|
||||
* under them.
|
||||
*
|
||||
* Failing open (treating an unanswerable Hub as idle) preserves the existing
|
||||
* replacement path for a Hub that is wedged or too old to answer the query;
|
||||
* only a Hub that positively reports live sessions is spared.
|
||||
*/
|
||||
async function hubHasLiveSessions(
|
||||
record: HubServerProbeRecord,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return !(await localHubHasNoActiveSessions(record.url, record.authToken));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retiring a Hub kills its established WebSockets, so a session running on it
|
||||
* dies mid-turn with an abnormal close. Defer instead while it is busy: the
|
||||
* caller attaches to the older Hub, the build-mismatch watcher tells the user a
|
||||
* newer build is waiting, and the swap happens at a boundary they choose.
|
||||
*/
|
||||
async function retireIncompatibleHub(
|
||||
record: HubServerProbeRecord,
|
||||
discoveryPath: string,
|
||||
): Promise<boolean> {
|
||||
): Promise<HubRetirementOutcome> {
|
||||
if (isReusableHubRecord(record)) {
|
||||
return true;
|
||||
return "reusable";
|
||||
}
|
||||
return retireDiscoveredHub(record, discoveryPath);
|
||||
if (await hubHasLiveSessions(record)) {
|
||||
return "deferred_busy";
|
||||
}
|
||||
return (await retireDiscoveredHub(record, discoveryPath))
|
||||
? "retired"
|
||||
: "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,10 +405,23 @@ async function ensureDetachedHubServerLocked(
|
||||
});
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
const outcome = await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discoveredAuthToken },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
// A busy older Hub is left running, so attach to it rather than
|
||||
// spawning a second daemon that would race it for the port.
|
||||
if (
|
||||
outcome === "deferred_busy" &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discoveredAuthToken,
|
||||
}))
|
||||
) {
|
||||
return rememberIfManaged({
|
||||
url: healthy.url,
|
||||
authToken: discoveredAuthToken,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
@@ -437,12 +487,32 @@ async function ensureDetachedHubServerLocked(
|
||||
`A compatible Cline Hub is already running at ${expectedUrl}, but its discovery record is missing or unreadable and no usable auth token is available. Run 'cline doctor fix' to repair local hub discovery.${upgradeHint}`,
|
||||
);
|
||||
}
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
const expectedOutcome = await retireIncompatibleHub(
|
||||
expectedForRetirement,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (expectedOutcome === "deferred_busy") {
|
||||
// Same as above: the older Hub is still serving sessions, so attach
|
||||
// with whichever token verifies instead of replacing it.
|
||||
for (const token of [
|
||||
expectedForRetirement.authToken,
|
||||
discovered?.authToken,
|
||||
].filter(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === "string" && candidate.trim().length > 0,
|
||||
)) {
|
||||
if (await verifyHubConnection(expected.url, { authToken: token })) {
|
||||
return rememberIfManaged({ url: expected.url, authToken: token });
|
||||
}
|
||||
}
|
||||
if (endpointOverrides.allowPortFallback !== true && endpoint.port !== 0) {
|
||||
throw new Error(
|
||||
`An older Cline Hub is running at ${expectedUrl} and is still serving active sessions, so it was not replaced, but no usable auth token is available to attach to it. Finish those sessions, or run 'cline doctor fix' to stop the hub.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!retiredExpected &&
|
||||
expectedOutcome === "failed" &&
|
||||
endpointOverrides.allowPortFallback !== true &&
|
||||
endpoint.port !== 0
|
||||
) {
|
||||
@@ -485,12 +555,24 @@ async function ensureDetachedHubServerLocked(
|
||||
nextDiscovery,
|
||||
expectedUrl,
|
||||
);
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
const nextOutcome = await retireIncompatibleHub(
|
||||
expectedForRetirement,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (
|
||||
!retiredExpected &&
|
||||
nextOutcome === "deferred_busy" &&
|
||||
nextDiscovery?.authToken &&
|
||||
(await verifyHubConnection(nextExpected.url, {
|
||||
authToken: nextDiscovery.authToken,
|
||||
}))
|
||||
) {
|
||||
return rememberIfManaged({
|
||||
url: nextExpected.url,
|
||||
authToken: nextDiscovery.authToken,
|
||||
});
|
||||
}
|
||||
if (
|
||||
nextOutcome === "failed" &&
|
||||
endpointOverrides.allowPortFallback !== true &&
|
||||
endpoint.port !== 0
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user