diff --git a/src/gateway/worker-environments/workspace-accepted-sync.test.ts b/src/gateway/worker-environments/workspace-accepted-sync.test.ts index b3de97e8045f..84853cd1f4b6 100644 --- a/src/gateway/worker-environments/workspace-accepted-sync.test.ts +++ b/src/gateway/worker-environments/workspace-accepted-sync.test.ts @@ -96,6 +96,7 @@ function createAcceptedWorkspacePublisherFactory( contentHashCount: 0, contentHashDurationMs: 0, memoHitCount: 0, + memoTruncatedCount: 0, totalDurationMs: 0, }, })}\n`, diff --git a/src/gateway/worker-environments/workspace-hash-memo.test.ts b/src/gateway/worker-environments/workspace-hash-memo.test.ts index aafd47c000cf..bb9116909092 100644 --- a/src/gateway/worker-environments/workspace-hash-memo.test.ts +++ b/src/gateway/worker-environments/workspace-hash-memo.test.ts @@ -145,17 +145,20 @@ describe("workspace hash memo", () => { contentHashCount: 7, contentHashDurationMs: 11, memoHitCount: 13, + memoTruncatedCount: 17, totalDurationMs: 17, }); recordRemoteWorkspaceHashMetrics(aggregate, { contentHashCount: 19, contentHashDurationMs: 23, memoHitCount: 29, + memoTruncatedCount: 31, totalDurationMs: 31, }); expect(aggregate).toMatchObject({ remoteContentHashCount: 26, remoteMemoHitCount: 42, + remoteMemoTruncatedCount: 48, remoteHashDurationMs: 34, remoteManifestDurationMs: 48, }); @@ -179,6 +182,7 @@ describe("workspace hash memo", () => { contentHashCount: MAX_RECONCILIATION_ENTRIES, contentHashDurationMs: Number.MAX_SAFE_INTEGER, memoHitCount: MAX_RECONCILIATION_ENTRIES, + memoTruncatedCount: MAX_RECONCILIATION_ENTRIES, totalDurationMs: Number.MAX_SAFE_INTEGER, }, })}\n`, @@ -233,4 +237,58 @@ describe("workspace hash memo", () => { expect(nextReconcile.manifestRef).toBe(replaced.manifestRef); expect(nextReconcile.metrics).toMatchObject({ contentHashCount: 1, memoHitCount: 0 }); }); + + it("bounds the remote memo to the largest files and reports truncation", async () => { + const root = tempDirs.make("openclaw-remote-manifest-memo-cap-"); + const home = path.join(root, "home"); + const workspace = path.join(root, "workspace"); + await Promise.all([fs.mkdir(home), fs.mkdir(workspace)]); + await Promise.all([ + fs.writeFile(path.join(workspace, "small.txt"), "1"), + fs.writeFile(path.join(workspace, "medium.txt"), "22"), + fs.writeFile(path.join(workspace, "large.txt"), "333"), + ]); + const limitDeclaration = `const MAX_RECONCILIATION_ENTRIES = ${MAX_RECONCILIATION_ENTRIES};`; + const limitedScript = REMOTE_WORKSPACE_MANIFEST_JS.replace( + limitDeclaration, + "const MAX_RECONCILIATION_ENTRIES = 2;", + ); + expect(limitedScript).not.toBe(REMOTE_WORKSPACE_MANIFEST_JS); + const env = { ...process.env, HOME: home }; + type MemoResponse = { + manifestRef: string; + memo: [string, string][]; + metrics: { contentHashCount: number; memoHitCount: number; memoTruncatedCount: number }; + }; + const capture = async (memo: [string, string][]): Promise => { + const result = await runCommandWithTimeout( + [process.execPath, "-e", limitedScript, workspace, "", "memo-v1"], + { timeoutMs: 10_000, baseEnv: env, input: JSON.stringify(memo) }, + ); + expect(result).toMatchObject({ code: 0, stderr: "" }); + return JSON.parse(result.stdout) as MemoResponse; + }; + + const first = await capture([]); + expect(first.memo).toHaveLength(2); + expect( + first.memo + .map(([identity]) => Number(identity.split(":")[3])) + .toSorted((left, right) => left - right), + ).toEqual([2, 3]); + expect(first.metrics).toMatchObject({ + contentHashCount: 3, + memoHitCount: 0, + memoTruncatedCount: 1, + }); + + const unchanged = await capture(first.memo); + expect(unchanged.manifestRef).toBe(first.manifestRef); + expect(unchanged.memo).toEqual(first.memo); + expect(unchanged.metrics).toMatchObject({ + contentHashCount: 1, + memoHitCount: 2, + memoTruncatedCount: 1, + }); + }); }); diff --git a/src/gateway/worker-environments/workspace-hash-memo.ts b/src/gateway/worker-environments/workspace-hash-memo.ts index 0d699b274328..ad8130518ffb 100644 --- a/src/gateway/worker-environments/workspace-hash-memo.ts +++ b/src/gateway/worker-environments/workspace-hash-memo.ts @@ -13,13 +13,17 @@ export type WorkspaceReconcileMetrics = { remoteManifestCalls: number; remoteContentHashCount: number; remoteMemoHitCount: number; + remoteMemoTruncatedCount: number; remoteHashDurationMs: number; remoteManifestDurationMs: number; remoteManifestWallDurationMs: number; localReconciliationDurationMs: number; }; -type RemoteWorkspaceHashMetrics = WorkspaceHashMetrics & { totalDurationMs: number }; +type RemoteWorkspaceHashMetrics = WorkspaceHashMetrics & { + memoTruncatedCount: number; + totalDurationMs: number; +}; export const MAX_WORKSPACE_HASH_MEMO_BYTES = 8 * 1024 * 1024; @@ -40,6 +44,7 @@ export function createWorkspaceReconcileMetrics(): WorkspaceReconcileMetrics { remoteManifestCalls: 0, remoteContentHashCount: 0, remoteMemoHitCount: 0, + remoteMemoTruncatedCount: 0, remoteHashDurationMs: 0, remoteManifestDurationMs: 0, remoteManifestWallDurationMs: 0, @@ -87,6 +92,7 @@ export function recordRemoteWorkspaceHashMetrics( ): void { aggregate.remoteContentHashCount += metrics.contentHashCount; aggregate.remoteMemoHitCount += metrics.memoHitCount; + aggregate.remoteMemoTruncatedCount += metrics.memoTruncatedCount; aggregate.remoteHashDurationMs += metrics.contentHashDurationMs; aggregate.remoteManifestDurationMs += metrics.totalDurationMs; } diff --git a/src/gateway/worker-environments/workspace-sync-helpers.ts b/src/gateway/worker-environments/workspace-sync-helpers.ts index d417616ffdcf..94b27da4949d 100644 --- a/src/gateway/worker-environments/workspace-sync-helpers.ts +++ b/src/gateway/worker-environments/workspace-sync-helpers.ts @@ -40,6 +40,7 @@ const remoteWorkspaceManifestEnvelopeSchema = z contentHashCount: z.number().finite().nonnegative(), contentHashDurationMs: z.number().finite().nonnegative(), memoHitCount: z.number().finite().nonnegative(), + memoTruncatedCount: z.number().finite().nonnegative(), totalDurationMs: z.number().finite().nonnegative(), }) .strict(), diff --git a/src/gateway/worker-environments/workspace-sync-scripts.ts b/src/gateway/worker-environments/workspace-sync-scripts.ts index 5e8e43d659da..c02044d29e8b 100644 --- a/src/gateway/worker-environments/workspace-sync-scripts.ts +++ b/src/gateway/worker-environments/workspace-sync-scripts.ts @@ -98,11 +98,19 @@ const entriesByPath = new Map(); let inventoryPathBytes = 0; let eligibleBytes = 0; const usedHashMemo = new Map(); -const metrics = { contentHashCount: 0, contentHashDurationMs: 0, memoHitCount: 0 }; +const metrics = { + contentHashCount: 0, + contentHashDurationMs: 0, + memoHitCount: 0, + memoTruncatedCount: 0, +}; const startedAt = performance.now(); function fail(message) { throw new Error(message); } +function compareHashMemoIdentity(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} function readHashMemo() { if (!memoMode) return new Map(); const raw = fs.readFileSync(0, "utf8"); @@ -373,7 +381,7 @@ async function hashFiles(entries) { entry.mode = Number(after.mode & 0o777n); entry.size = Number(after.size); entry.sha256 = sha256; - usedHashMemo.set(identity, sha256); + usedHashMemo.set(identity, { sha256, size: Number(after.size) }); } finally { await handle.close(); } @@ -440,14 +448,23 @@ async function main() { const manifest = serializeManifest(baseCommit, entries); const digest = publishManifest(manifestRoot, manifest); const manifestRef = "sha256:" + digest; - const measured = { ...metrics, totalDurationMs: performance.now() - startedAt }; if (memoMode) { + // Largest files preserve the most expensive hashes. Identity tie-breaking and + // final ordering keep the bounded cache deterministic across captures. + const memo = [...usedHashMemo] + .sort( + (left, right) => + right[1].size - left[1].size || compareHashMemoIdentity(left[0], right[0]), + ) + .slice(0, MAX_RECONCILIATION_ENTRIES) + .map(([identity, value]) => [identity, value.sha256]) + .sort((left, right) => compareHashMemoIdentity(left[0], right[0])); + metrics.memoTruncatedCount = usedHashMemo.size - memo.length; + const measured = { ...metrics, totalDurationMs: performance.now() - startedAt }; process.stdout.write(JSON.stringify({ version: 1, manifestRef, - memo: [...usedHashMemo].sort((left, right) => - left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0, - ), + memo, metrics: measured, }) + "\n"); } else {