fix(core): confirm the Hub record still describes the daemon just probed

Instance identity is read from discovery before the probe and build data
comes back after it, so a daemon replaced between those two steps was
described with its predecessor's identity - the replacement then satisfied
the prior daemon's pending sighting and emitted the notification the
consecutive-instance check exists to suppress.

Re-read discovery after the probe and report nothing when the record no
longer describes the same daemon. A Hub mid-swap is churn; the next check
sees whatever it settles into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
abeatrix
2026-08-13 13:28:57 -07:00
parent 931431371d
commit 9d634f7b35
2 changed files with 64 additions and 0 deletions
@@ -168,6 +168,53 @@ describe("checkManagedHubBuildMismatch", () => {
await expect(checkManagedHubBuildMismatch()).resolves.toBeUndefined();
});
/**
* Identity is read before the probe and build data after it, so a daemon
* swapped in between would otherwise be reported under its predecessor's
* identity.
*/
it("returns undefined when the hub is replaced while it is being probed", async () => {
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
const records = [
{ ...liveRecord, hubId: "hub-instance-a" },
{ ...liveRecord, hubId: "hub-instance-b" },
];
let read = 0;
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",
// Second read within the same check sees the replacement.
readHubDiscovery: vi.fn(async () => records[read++] ?? records[1]),
probeHubServer: vi.fn(async () => ({
protocolVersion: "v1",
buildId: "old-build",
buildEpochMs: 500,
host: "127.0.0.1",
port: 59999,
url: "ws://127.0.0.1:59999/hub",
})),
};
});
const { checkManagedHubBuildMismatch } = await import(
"./managed-hub-build-watcher"
);
await expect(checkManagedHubBuildMismatch()).resolves.toBeUndefined();
});
it("returns undefined when the recorded hub is unreachable", async () => {
mockDiscovery({ record: liveRecord, probe: undefined });
const { checkManagedHubBuildMismatch } = await import(
@@ -133,6 +133,23 @@ export async function checkManagedHubBuildMismatch(): Promise<
if (!healthy?.url) {
return undefined;
}
// Reading discovery and probing the Hub are separate steps, and instance
// identity comes from the first while the build data comes from the second.
// A daemon replaced between them would be described with its predecessor's
// identity, which is exactly the confusion the caller's consecutive-instance
// check exists to avoid. Confirm the record still describes the daemon just
// probed, and report nothing this round when it does not - a Hub mid-swap is
// churn, and the next check sees whatever it settles into.
const recheck = await readHubDiscovery(owner.discoveryPath).catch(
() => undefined,
);
if (
!recheck?.url ||
recheck.url !== record.url ||
resolveHubInstanceId({}, recheck) !== resolveHubInstanceId({}, record)
) {
return undefined;
}
const expectedBuildId = resolveHubBuildId();
const compatibility = getManagedHubCompatibility(healthy, expectedBuildId);
if (compatibility.compatible) {