diff --git a/src/infra/state-migrations.orphan-keys.test.ts b/src/infra/state-migrations.orphan-keys.test.ts index a1335892242e..e4d9b339f7ef 100644 --- a/src/infra/state-migrations.orphan-keys.test.ts +++ b/src/infra/state-migrations.orphan-keys.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { migrateOrphanedSessionKeys } from "./state-migrations.js"; +import { resolveSessionStoreOwnership } from "./state-migrations.session-store.js"; const listPluginDoctorSessionStoreAgentIdsMock = vi.hoisted(() => vi.fn((): string[] => [])); @@ -173,6 +174,74 @@ describe("migrateOrphanedSessionKeys", () => { }); }); + it("distinguishes large adjacent inodes before planning store aliases", async () => { + await withStateFixture(async ({ tmpDir, stateDir }) => { + const configuredStorePath = path.join(tmpDir, "configured-sessions.json"); + const targetStorePath = path.join(stateDir, "agents", "voice", "sessions", "sessions.json"); + writeStore(configuredStorePath, {}); + writeStore(targetStorePath, {}); + const cfg = { + session: { store: configuredStorePath }, + agents: { list: [{ id: "ops", default: true }] }, + } as OpenClawConfig; + const realStatSync = fs.statSync.bind(fs); + const largeInodes = new Map([ + [configuredStorePath, 72057594037932382n], + [targetStorePath, 72057594037932383n], + ]); + const statSpy = vi.spyOn(fs, "statSync").mockImplementation((( + candidate: Parameters[0], + options?: { bigint?: boolean }, + ) => { + const resolvedPath = path.resolve(candidate.toString()); + const inode = largeInodes.get(resolvedPath); + const useBigInt = options?.bigint === true; + const stat = useBigInt + ? realStatSync(candidate, { bigint: true }) + : realStatSync(candidate); + if (inode === undefined) { + return stat; + } + return new Proxy(stat, { + get(target, property, receiver) { + if (property === "dev") { + return useBigInt ? 2n : 2; + } + if (property === "ino") { + return useBigInt ? inode : Number(inode); + } + return Reflect.get(target, property, receiver); + }, + }); + }) as typeof fs.statSync); + + let ownership: ReturnType; + try { + ownership = resolveSessionStoreOwnership({ + cfg, + env: { OPENCLAW_STATE_DIR: stateDir }, + stateDir, + targetAgentId: "voice", + pluginSessionStoreAgentIds: ["voice"], + }); + expect(statSpy).toHaveBeenCalledWith(configuredStorePath, { bigint: true }); + expect(statSpy).toHaveBeenCalledWith(targetStorePath, { bigint: true }); + } finally { + statSpy.mockRestore(); + } + + expect(ownership).toEqual({ + preserveAmbiguousKeys: false, + preserveForeignMainAliases: false, + targetStoreAliases: { + hasDistinctAliases: false, + hasFinalSymlink: false, + hasUnresolvedIdentity: false, + }, + }); + }); + }); + it("discovers plugin-owned agents through doctor contracts", async () => { await withStateFixture(async ({ tmpDir, stateDir }) => { listPluginDoctorSessionStoreAgentIdsMock.mockReturnValue(["voice"]); diff --git a/src/infra/state-migrations.session-store.ts b/src/infra/state-migrations.session-store.ts index 256b5fe2f374..afcb29b0c218 100644 --- a/src/infra/state-migrations.session-store.ts +++ b/src/infra/state-migrations.session-store.ts @@ -1214,7 +1214,12 @@ function resolveSessionStorePathRelationship( return "same"; } try { - return sameFileIdentity(fs.statSync(left), fs.statSync(right)) ? "same" : "different"; + return sameFileIdentity( + fs.statSync(left, { bigint: true }), + fs.statSync(right, { bigint: true }), + ) + ? "same" + : "different"; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTDIR") {