mirror of
https://github.com/cline/cline.git
synced 2026-08-29 03:52:41 +08:00
fix(core): key the outdated-Hub check by daemon instance, not build
The consecutive-sighting check that keeps a routine replacement from flashing an informational dialog was keyed by build id. Two daemons from the same build share one, so an outdated Hub replaced by another daemon of the same older build satisfied the check and reported exactly the churn the check exists to hide. Carry a hubInstanceId on the mismatch event - the Hub's own id, falling back to pid and start time - and key the pending sighting by it. A replacement instance now restarts the count instead of confirming it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -244,6 +244,75 @@ describe("watchManagedHubBuildMismatch", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Two daemons from the same build share a build id, so a replacement of the
|
||||
* same older build must not satisfy the consecutive-sighting check - that
|
||||
* churn is exactly what the check exists to hide.
|
||||
*/
|
||||
it("does not report when a different daemon of the same older build takes over", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
let probeResult: Record<string, unknown> | undefined = {
|
||||
hubId: "hub-instance-a",
|
||||
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);
|
||||
expect(onMismatch).not.toHaveBeenCalled();
|
||||
|
||||
// A different daemon, same build: not the Hub we were watching.
|
||||
probeResult = { ...probeResult, hubId: "hub-instance-b" };
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(onMismatch).not.toHaveBeenCalled();
|
||||
|
||||
// It settles: the second instance is still there on the next check.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(onMismatch).toHaveBeenCalledTimes(1);
|
||||
expect(onMismatch).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: "outdated_hub",
|
||||
hubInstanceId: "hub-instance-b",
|
||||
}),
|
||||
);
|
||||
} 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");
|
||||
|
||||
@@ -41,10 +41,32 @@ export interface ManagedHubBuildMismatchEvent {
|
||||
hubBuildId?: string;
|
||||
/** Core package version reported by the running Hub. */
|
||||
hubCoreVersion?: string;
|
||||
/**
|
||||
* Identifies the running daemon *instance*, not its build. Two daemons from
|
||||
* the same build share a build id, so anything that needs to know whether
|
||||
* the very same Hub is still running - rather than merely another Hub of
|
||||
* the same build - must compare this.
|
||||
*/
|
||||
hubInstanceId?: string;
|
||||
/** Build identity this client expects a managed Hub to match. */
|
||||
expectedBuildId: string;
|
||||
}
|
||||
|
||||
function resolveHubInstanceId(record: {
|
||||
hubId?: string;
|
||||
pid?: number;
|
||||
startedAt?: string;
|
||||
}): string | undefined {
|
||||
const hubId = record.hubId?.trim();
|
||||
if (hubId) {
|
||||
return hubId;
|
||||
}
|
||||
const fallback = [record.pid, record.startedAt]
|
||||
.filter((part) => part !== undefined && part !== null && part !== "")
|
||||
.join(":");
|
||||
return fallback || undefined;
|
||||
}
|
||||
|
||||
function resolveDefaultHubOwnerContext(): HubOwnerContext {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
@@ -107,6 +129,7 @@ export async function checkManagedHubBuildMismatch(): Promise<
|
||||
reason,
|
||||
hubBuildId: healthy.buildId,
|
||||
hubCoreVersion: healthy.coreVersion,
|
||||
hubInstanceId: resolveHubInstanceId(healthy),
|
||||
expectedBuildId,
|
||||
});
|
||||
if (compatibility.reason === "unsupported_protocol") {
|
||||
@@ -172,8 +195,13 @@ export function watchManagedHubBuildMismatch(
|
||||
// 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;
|
||||
//
|
||||
// Keyed by instance, not build: a replacement daemon from the
|
||||
// same older build is a different Hub, and treating it as the
|
||||
// same one would report the churn this check exists to hide.
|
||||
const instanceKey = `${key}:${mismatch.hubInstanceId ?? ""}`;
|
||||
if (mismatch.reason === "outdated_hub" && pendingKey !== instanceKey) {
|
||||
pendingKey = instanceKey;
|
||||
return;
|
||||
}
|
||||
pendingKey = undefined;
|
||||
|
||||
Reference in New Issue
Block a user