perf(workers): cut repeated full-tree workspace hashing (#121365)

* perf(workers): reuse workspace hashes during reconcile

* chore(workers): drop one-off sync benchmark harness
This commit is contained in:
Peter Steinberger
2026-08-09 20:50:08 -07:00
committed by GitHub
parent 374007083a
commit 592bf90b07
17 changed files with 1183 additions and 427 deletions
@@ -17,9 +17,10 @@ import {
isAcceptedWorkspacePublicationIndeterminateError,
} from "./workspace-accepted-publication.js";
import {
createAcceptedWorkspacePublisherFactory,
createAcceptedWorkspacePublisherFactory as createAcceptedWorkspacePublisherFactoryRaw,
recoverAcceptedWorkspacePublication,
} from "./workspace-accepted-sync.js";
import { createWorkspaceReconcileMetrics } from "./workspace-hash-memo.js";
import {
serializeWorkerWorkspaceManifest,
type WorkerWorkspaceManifest,
@@ -69,6 +70,40 @@ function settlement(outcome: "begun" | "rolled-back" | "applied" | "committed"):
return result({ stdout: `${JSON.stringify({ version: 1, outcome })}\n` });
}
function createAcceptedWorkspacePublisherFactory(
params: Omit<
Parameters<typeof createAcceptedWorkspacePublisherFactoryRaw>[0],
"hashMemo" | "metrics"
>,
) {
const runWorkspaceCommand = params.runWorkspaceCommand;
return createAcceptedWorkspacePublisherFactoryRaw({
...params,
hashMemo: new Map(),
metrics: createWorkspaceReconcileMetrics(),
runWorkspaceCommand: async (command) => {
const response = await runWorkspaceCommand(command);
const returnedRef = response.stdout.trim();
if (command.argv.at(-1) !== "memo-v1" || !/^sha256:[a-f0-9]{64}$/u.test(returnedRef)) {
return response;
}
return result({
stdout: `${JSON.stringify({
version: 1,
manifestRef: returnedRef,
memo: [],
metrics: {
contentHashCount: 0,
contentHashDurationMs: 0,
memoHitCount: 0,
totalDurationMs: 0,
},
})}\n`,
});
},
});
}
describe("accepted workspace publication", () => {
it.skipIf(process.platform === "win32")(
"waits for the staging receiver group before promoting its inodes live",
@@ -10,13 +10,14 @@ import {
parseAcceptedWorkspaceSettlement,
type AcceptedWorkspaceSettlementOutcome,
} from "./workspace-accepted-publication.js";
import type { WorkspaceHashMemo, WorkspaceReconcileMetrics } from "./workspace-hash-memo.js";
import {
serializeWorkerWorkspaceManifest,
type WorkerWorkspaceManifest,
} from "./workspace-manifest.js";
import { changedPaths, manifestNodes } from "./workspace-reconcile.js";
import {
parseManifestRef,
captureRemoteWorkspaceManifest,
WORKER_WORKSPACE_RSYNC_DESTINATION,
workerAcceptedWorkspaceRsyncReceiverPath,
workerWorkspaceCommandSucceeded,
@@ -67,6 +68,8 @@ function createAcceptedWorkspacePublisher(params: {
localPath: string;
remoteWorkspaceDir: string;
remoteManifest: WorkerWorkspaceManifest;
hashMemo: WorkspaceHashMemo;
metrics: WorkspaceReconcileMetrics;
}) {
return async (accepted: {
manifestRef: string;
@@ -96,21 +99,14 @@ function createAcceptedWorkspacePublisher(params: {
}
const verifyAcceptedWorkspace = async () => {
const verified = await params.runWorkspaceCommand({
transportRetry: "idempotent",
argv: [
"node",
"-e",
REMOTE_WORKSPACE_MANIFEST_JS,
params.remoteWorkspaceDir,
accepted.manifest.baseCommit ?? "",
...(accepted.manifest.baseCommit ? ["eligible", acceptedDigest] : []),
],
const verifiedRef = await captureRemoteWorkspaceManifest({
runWorkspaceCommand: params.runWorkspaceCommand,
remoteWorkspaceDir: params.remoteWorkspaceDir,
baseCommit: accepted.manifest.baseCommit,
priorManifestDigests: accepted.manifest.baseCommit ? [acceptedDigest] : [],
hashMemo: params.hashMemo,
metrics: params.metrics,
});
if (!workerWorkspaceCommandSucceeded(verified)) {
throw workspaceSyncError(verified);
}
const verifiedRef = parseManifestRef(verified.stdout.trim());
if (verifiedRef !== accepted.manifestRef) {
throw new Error(
`Worker workspace does not match its accepted manifest: expected ${accepted.manifestRef}, got ${verifiedRef}`,
@@ -1,29 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import { verifyReconciledWorkspaceFinal } from "./workspace-finalize.js";
import {
registerWorkspaceReconcileReporter,
verifyReconciledWorkspaceFinal,
} from "./workspace-finalize.js";
describe("final worker workspace fences", () => {
it("rechecks remote and local stability after the final quiescence renewal", async () => {
const log: string[] = [];
await verifyReconciledWorkspaceFinal(
{
manifestRef: "sha256:" + "a".repeat(64),
changed: true,
verifyStable: async () => {
log.push("remote");
},
verifyLocalStable: async () => {
log.push("local");
},
const reconciliation = {
manifestRef: "sha256:" + "a".repeat(64),
changed: true,
verifyStable: async () => {
log.push("remote");
},
{
assertActive: async () => {
log.push("quiescence");
},
resume: async () => {},
verifyLocalStable: async () => {
log.push("local");
},
);
};
const outcomes: string[] = [];
registerWorkspaceReconcileReporter(reconciliation, (outcome) => outcomes.push(outcome));
await verifyReconciledWorkspaceFinal(reconciliation, {
assertActive: async () => {
log.push("quiescence");
},
resume: async () => {},
});
expect(log).toEqual(["remote", "local", "quiescence", "remote", "local"]);
expect(outcomes).toEqual(["succeeded"]);
});
it("rejects a remote write observed after the final quiescence renewal", async () => {
@@ -31,40 +31,74 @@ const runRetryableFinalFenceStep = async (operation: () => Promise<void>): Promi
const runResultPreservingFinalFenceStep = async (operation: () => Promise<void>): Promise<void> =>
await runFinalFenceStep(operation, "preserve-result");
type WorkspaceReconcileOutcome = "failed" | "succeeded";
const workspaceReconcileReporters = new WeakMap<
WorkerWorkspaceReconcileResult,
(outcome: WorkspaceReconcileOutcome) => void
>();
export function registerWorkspaceReconcileReporter(
reconciliation: WorkerWorkspaceReconcileResult,
reporter: (outcome: WorkspaceReconcileOutcome) => void,
): void {
workspaceReconcileReporters.set(reconciliation, reporter);
}
function reportWorkspaceReconcile(
reconciliation: WorkerWorkspaceReconcileResult,
outcome: WorkspaceReconcileOutcome,
): void {
const reporter = workspaceReconcileReporters.get(reconciliation);
workspaceReconcileReporters.delete(reconciliation);
reporter?.(outcome);
}
/** Rechecks both owners after renewing the remote quiescence lease. */
export async function verifyReconciledWorkspaceFinal(
reconciliation: WorkerWorkspaceReconcileResult,
quiescence: WorkerWorkspaceQuiescence,
): Promise<WorkerWorkspaceApplyResult | undefined> {
if (reconciliation.applyPreparedStagedResult && reconciliation.publishStagedResult) {
try {
// Fence the prepared remote capture before quiescence renewal can enroll late writers.
await runRetryableFinalFenceStep(async () => await reconciliation.verifyStable());
// Renew quiescence and freeze any writers that appeared after the prepared capture.
await runRetryableFinalFenceStep(async () => await quiescence.assertActive());
// Keep this fence: a late writer can mutate before renewal enrolls and SIGSTOPs it.
await runRetryableFinalFenceStep(async () => await reconciliation.verifyStable());
await reconciliation.applyPreparedStagedResult();
await reconciliation.verifyLocalStable();
// Renew after apply so lease expiry cannot race the final publish gate.
await runResultPreservingFinalFenceStep(async () => await quiescence.assertActive());
// Recheck the remote owner after apply before publishing the prepared result.
await runResultPreservingFinalFenceStep(async () => await reconciliation.verifyStable());
await runResultPreservingFinalFenceStep(async () => await reconciliation.verifyLocalStable());
await reconciliation.publishStagedResult();
return reconciliation.getAppliedWorkspaceResult?.();
} catch (error) {
await reconciliation.discardPreparedStagedResult?.().catch(() => undefined);
throw error;
let succeeded = false;
try {
if (reconciliation.applyPreparedStagedResult && reconciliation.publishStagedResult) {
try {
// Fence the prepared remote capture before quiescence renewal can enroll late writers.
await runRetryableFinalFenceStep(async () => await reconciliation.verifyStable());
// Renew quiescence and freeze any writers that appeared after the prepared capture.
await runRetryableFinalFenceStep(async () => await quiescence.assertActive());
// Keep this fence: a late writer can mutate before renewal enrolls and SIGSTOPs it.
await runRetryableFinalFenceStep(async () => await reconciliation.verifyStable());
await reconciliation.applyPreparedStagedResult();
await reconciliation.verifyLocalStable();
// Renew after apply so lease expiry cannot race the final publish gate.
await runResultPreservingFinalFenceStep(async () => await quiescence.assertActive());
// Recheck the remote owner after apply before publishing the prepared result.
await runResultPreservingFinalFenceStep(async () => await reconciliation.verifyStable());
await runResultPreservingFinalFenceStep(
async () => await reconciliation.verifyLocalStable(),
);
await reconciliation.publishStagedResult();
const applied = reconciliation.getAppliedWorkspaceResult?.();
succeeded = true;
return applied;
} catch (error) {
await reconciliation.discardPreparedStagedResult?.().catch(() => undefined);
throw error;
}
}
const runFenceStep = reconciliation.changed
? runResultPreservingFinalFenceStep
: runRetryableFinalFenceStep;
await runFenceStep(async () => await reconciliation.verifyStable());
await runFenceStep(async () => await reconciliation.verifyLocalStable());
await runFenceStep(async () => await quiescence.assertActive());
await runFenceStep(async () => await reconciliation.verifyStable());
await runFenceStep(async () => await reconciliation.verifyLocalStable());
const applied = reconciliation.getAppliedWorkspaceResult?.();
succeeded = true;
return applied;
} finally {
reportWorkspaceReconcile(reconciliation, succeeded ? "succeeded" : "failed");
}
const runFenceStep = reconciliation.changed
? runResultPreservingFinalFenceStep
: runRetryableFinalFenceStep;
await runFenceStep(async () => await reconciliation.verifyStable());
await runFenceStep(async () => await reconciliation.verifyLocalStable());
await runFenceStep(async () => await quiescence.assertActive());
await runFenceStep(async () => await reconciliation.verifyStable());
await runFenceStep(async () => await reconciliation.verifyLocalStable());
return reconciliation.getAppliedWorkspaceResult?.();
}
@@ -0,0 +1,236 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import {
createWorkspaceReconcileMetrics,
MAX_WORKSPACE_HASH_MEMO_BYTES,
recordRemoteWorkspaceHashMetrics,
serializeRemoteWorkspaceHashMemo,
withWorkspaceHashMemo,
} from "./workspace-hash-memo.js";
import { MAX_RECONCILIATION_ENTRIES, type WorkerWorkspaceManifest } from "./workspace-manifest.js";
import { preflightWorkspaceApply, readActualWorkspaceManifest } from "./workspace-reconcile.js";
import { REMOTE_WORKSPACE_MANIFEST_JS } from "./workspace-sync-scripts.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => vi.restoreAllMocks());
function hashMetrics() {
return {
contentHashCount: 0,
contentHashDurationMs: 0,
memoHitCount: 0,
};
}
describe("workspace hash memo", () => {
it("reuses content hashes only within one reconcile stat identity", async () => {
const root = await fs.realpath(tempDirs.make("openclaw-workspace-hash-memo-"));
const target = path.join(root, "same-size.txt");
await fs.writeFile(target, "alpha");
const memo = new Map<string, string>();
const metrics = hashMetrics();
let replacedManifestRef = "";
await withWorkspaceHashMemo(
memo,
async () => {
const first = await readActualWorkspaceManifest({ root, baseCommit: null });
const unchanged = await withWorkspaceHashMemo(
memo,
async () => await readActualWorkspaceManifest({ root, baseCommit: null }),
);
expect(unchanged.manifestRef).toBe(first.manifestRef);
expect(metrics).toMatchObject({ contentHashCount: 1, memoHitCount: 1 });
await fs.writeFile(target, "bravo");
await fs.utimes(target, new Date(), new Date(Date.now() + 1_000));
const changed = await readActualWorkspaceManifest({ root, baseCommit: null });
expect(changed.manifestRef).not.toBe(first.manifestRef);
expect(metrics.contentHashCount).toBe(2);
const replacement = path.join(root, "replacement.txt");
await fs.writeFile(replacement, "cider");
await fs.rename(replacement, target);
const replaced = await readActualWorkspaceManifest({ root, baseCommit: null });
expect(replaced.manifestRef).not.toBe(changed.manifestRef);
expect(metrics.contentHashCount).toBe(3);
replacedManifestRef = replaced.manifestRef;
},
metrics,
);
const nextReconcileMetrics = hashMetrics();
const nextReconcile = await withWorkspaceHashMemo(
new Map(),
async () => await readActualWorkspaceManifest({ root, baseCommit: null }),
nextReconcileMetrics,
);
expect(nextReconcile.manifestRef).toBe(replacedManifestRef);
expect(nextReconcileMetrics).toMatchObject({ contentHashCount: 1, memoHitCount: 0 });
});
it("reuses local workspace nodes within one preflight but not across fences", async () => {
const root = await fs.realpath(tempDirs.make("openclaw-workspace-preflight-memo-"));
await fs.writeFile(path.join(root, "parent"), "base");
const baseContent = Buffer.from("base");
const currentContent = Buffer.from("worker");
const base: WorkerWorkspaceManifest = {
version: 1,
baseCommit: null,
entries: [
{
path: "parent",
type: "file",
mode: 0o644,
size: baseContent.length,
sha256: createHash("sha256").update(baseContent).digest("hex"),
},
],
directories: [],
};
const current: WorkerWorkspaceManifest = {
version: 1,
baseCommit: null,
entries: [
{
path: "parent/child.txt",
type: "file",
mode: 0o644,
size: currentContent.length,
sha256: createHash("sha256").update(currentContent).digest("hex"),
},
{
path: "parent/sibling.txt",
type: "file",
mode: 0o644,
size: currentContent.length,
sha256: createHash("sha256").update(currentContent).digest("hex"),
},
],
directories: ["parent"],
};
const metrics = hashMetrics();
const open = vi.spyOn(fs, "open");
const parentPath = path.join(root, "parent");
const parentSnapshots = () => open.mock.calls.filter(([file]) => file === parentPath).length;
const first = await withWorkspaceHashMemo(
new Map(),
async () => await preflightWorkspaceApply({ root, base, current }),
metrics,
);
expect([...first.applyPaths].toSorted()).toEqual([
"parent",
"parent/child.txt",
"parent/sibling.txt",
]);
expect(metrics.contentHashCount).toBe(1);
expect(parentSnapshots()).toBe(1);
await withWorkspaceHashMemo(
new Map(),
async () => await preflightWorkspaceApply({ root, base, current }),
metrics,
);
expect(metrics.contentHashCount).toBe(2);
expect(parentSnapshots()).toBe(2);
});
it("aggregates remote metrics and bounds a maximum-entry memo envelope", () => {
const aggregate = createWorkspaceReconcileMetrics();
recordRemoteWorkspaceHashMetrics(aggregate, {
contentHashCount: 7,
contentHashDurationMs: 11,
memoHitCount: 13,
totalDurationMs: 17,
});
recordRemoteWorkspaceHashMetrics(aggregate, {
contentHashCount: 19,
contentHashDurationMs: 23,
memoHitCount: 29,
totalDurationMs: 31,
});
expect(aggregate).toMatchObject({
remoteContentHashCount: 26,
remoteMemoHitCount: 42,
remoteHashDurationMs: 34,
remoteManifestDurationMs: 48,
});
const uint64 = "18446744073709551615";
const memo = new Map<string, string>();
for (let index = 0; index < MAX_RECONCILIATION_ENTRIES; index += 1) {
const inode = String(index).padStart(20, "0");
memo.set(
`worker:${uint64}:${inode}:${uint64}:${uint64}:${uint64}`,
index.toString(16).padStart(64, "0"),
);
}
const serializedMemo = serializeRemoteWorkspaceHashMemo(memo);
const envelopeBytes = Buffer.byteLength(
`${JSON.stringify({
version: 1,
manifestRef: `sha256:${"f".repeat(64)}`,
memo: JSON.parse(serializedMemo),
metrics: {
contentHashCount: MAX_RECONCILIATION_ENTRIES,
contentHashDurationMs: Number.MAX_SAFE_INTEGER,
memoHitCount: MAX_RECONCILIATION_ENTRIES,
totalDurationMs: Number.MAX_SAFE_INTEGER,
},
})}\n`,
);
expect(envelopeBytes).toBeLessThan(MAX_WORKSPACE_HASH_MEMO_BYTES);
expect(MAX_WORKSPACE_HASH_MEMO_BYTES - envelopeBytes).toBeGreaterThan(3 * 1024 * 1024);
});
it("reuses hashes only for matching stat identities in one remote reconcile", async () => {
const root = tempDirs.make("openclaw-remote-manifest-memo-");
const home = path.join(root, "home");
let workspace = path.join(root, "workspace");
await Promise.all([fs.mkdir(home), fs.mkdir(workspace)]);
workspace = await fs.realpath(workspace);
const target = path.join(workspace, "same-size.txt");
await fs.writeFile(target, "alpha");
const env = { ...process.env, HOME: home };
type MemoResponse = {
manifestRef: string;
memo: [string, string][];
metrics: { contentHashCount: number; memoHitCount: number };
};
const capture = async (memo: [string, string][]): Promise<MemoResponse> => {
const result = await runCommandWithTimeout(
[process.execPath, "-e", REMOTE_WORKSPACE_MANIFEST_JS, 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.metrics).toMatchObject({ contentHashCount: 1, memoHitCount: 0 });
const unchanged = await capture(first.memo);
expect(unchanged.manifestRef).toBe(first.manifestRef);
expect(unchanged.metrics).toMatchObject({ contentHashCount: 0, memoHitCount: 1 });
await fs.writeFile(target, "bravo");
await fs.utimes(target, new Date(), new Date(Date.now() + 1_000));
const changed = await capture(unchanged.memo);
expect(changed.manifestRef).not.toBe(first.manifestRef);
expect(changed.metrics).toMatchObject({ contentHashCount: 1, memoHitCount: 0 });
const replacement = path.join(workspace, "replacement.txt");
await fs.writeFile(replacement, "cider");
await fs.rename(replacement, target);
const replaced = await capture(changed.memo);
expect(replaced.manifestRef).not.toBe(changed.manifestRef);
expect(replaced.metrics).toMatchObject({ contentHashCount: 1, memoHitCount: 0 });
const nextReconcile = await capture([]);
expect(nextReconcile.manifestRef).toBe(replaced.manifestRef);
expect(nextReconcile.metrics).toMatchObject({ contentHashCount: 1, memoHitCount: 0 });
});
});
@@ -0,0 +1,111 @@
import { AsyncLocalStorage } from "node:async_hooks";
type WorkspaceHashMetrics = {
contentHashCount: number;
contentHashDurationMs: number;
memoHitCount: number;
};
export type WorkspaceHashMemo = Map<string, string>;
export type WorkspaceReconcileMetrics = {
gateway: WorkspaceHashMetrics;
remoteManifestCalls: number;
remoteContentHashCount: number;
remoteMemoHitCount: number;
remoteHashDurationMs: number;
remoteManifestDurationMs: number;
remoteManifestWallDurationMs: number;
localReconciliationDurationMs: number;
};
type RemoteWorkspaceHashMetrics = WorkspaceHashMetrics & { totalDurationMs: number };
export const MAX_WORKSPACE_HASH_MEMO_BYTES = 8 * 1024 * 1024;
type WorkspaceHashContext = {
memo: WorkspaceHashMemo;
metrics?: WorkspaceHashMetrics;
};
const workspaceHashContext = new AsyncLocalStorage<WorkspaceHashContext>();
export function createWorkspaceReconcileMetrics(): WorkspaceReconcileMetrics {
return {
gateway: {
contentHashCount: 0,
contentHashDurationMs: 0,
memoHitCount: 0,
},
remoteManifestCalls: 0,
remoteContentHashCount: 0,
remoteMemoHitCount: 0,
remoteHashDurationMs: 0,
remoteManifestDurationMs: 0,
remoteManifestWallDurationMs: 0,
localReconciliationDurationMs: 0,
};
}
export function activeWorkspaceHashContext(): WorkspaceHashContext | undefined {
return workspaceHashContext.getStore();
}
export async function withWorkspaceHashMemo<T>(
memo: WorkspaceHashMemo,
operation: () => Promise<T>,
metrics?: WorkspaceHashMetrics,
): Promise<T> {
const active = workspaceHashContext.getStore();
const inheritedMetrics = metrics ?? active?.metrics;
if (active?.memo === memo && active.metrics === inheritedMetrics) {
return await operation();
}
return await workspaceHashContext.run({ memo, metrics: inheritedMetrics }, operation);
}
export async function withWorkspaceHashContext<T>(operation: () => Promise<T>): Promise<T> {
const active = workspaceHashContext.getStore();
return await withWorkspaceHashMemo(active?.memo ?? new Map(), operation, active?.metrics);
}
export function serializeRemoteWorkspaceHashMemo(memo: WorkspaceHashMemo): string {
const serialized = JSON.stringify(
[...memo]
.filter(([identity]) => identity.startsWith("worker:"))
.toSorted(([left], [right]) => left.localeCompare(right)),
);
if (Buffer.byteLength(serialized) > MAX_WORKSPACE_HASH_MEMO_BYTES) {
throw new Error("Workspace hash memo exceeds its byte limit");
}
return serialized;
}
export function recordRemoteWorkspaceHashMetrics(
aggregate: WorkspaceReconcileMetrics,
metrics: RemoteWorkspaceHashMetrics,
): void {
aggregate.remoteContentHashCount += metrics.contentHashCount;
aggregate.remoteMemoHitCount += metrics.memoHitCount;
aggregate.remoteHashDurationMs += metrics.contentHashDurationMs;
aggregate.remoteManifestDurationMs += metrics.totalDurationMs;
}
export async function measureLocalWorkspaceReconciliation<T>(
metrics: WorkspaceReconcileMetrics,
operation: () => Promise<T>,
): Promise<T> {
const startedAt = performance.now();
try {
return await operation();
} finally {
metrics.localReconciliationDurationMs += performance.now() - startedAt;
}
}
export function workspaceStatIdentity(
owner: "gateway" | "worker",
stats: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint },
): string {
return `${owner}:${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
}
@@ -0,0 +1,117 @@
import path from "node:path";
import type { SpawnResult } from "../../process/exec.js";
import type { WorkerWorkspaceCommand, WorkerWorkspaceQuiescence } from "./tunnel-contract.js";
import {
REMOTE_WORKSPACE_QUIESCE_JS,
REMOTE_WORKSPACE_RENEW_QUIESCENCE_JS,
REMOTE_WORKSPACE_RESUME_JS,
} from "./workspace-quiescence-scripts.js";
import {
waitForQuiescenceRenewal,
workerWorkspaceCommandSucceeded,
workspaceSyncError,
} from "./workspace-sync-helpers.js";
const WORKSPACE_QUIESCENCE_TIMEOUT_MS = 12 * 60_000;
const WORKSPACE_QUIESCENCE_RENEW_INTERVAL_MS = 4 * 60_000;
export function createWorkerWorkspaceQuiescence(params: {
ownerSignal: AbortSignal;
sharedHost: boolean;
runWorkspaceCommand: (command: WorkerWorkspaceCommand) => Promise<SpawnResult>;
}): (remoteWorkspaceDir: string) => Promise<WorkerWorkspaceQuiescence> {
return async (remoteWorkspaceDir) => {
if (!path.posix.isAbsolute(remoteWorkspaceDir)) {
throw new Error("Worker workspace quiescence path must be absolute");
}
const hostMode = params.sharedHost ? "shared-host" : "dedicated";
const run = async (argv: string[]) => {
const result = await params.runWorkspaceCommand({ transportRetry: "never", argv });
if (!workerWorkspaceCommandSucceeded(result)) {
throw workspaceSyncError(result);
}
return result;
};
const result = await run([
"node",
"-e",
REMOTE_WORKSPACE_QUIESCE_JS,
remoteWorkspaceDir,
String(WORKSPACE_QUIESCENCE_TIMEOUT_MS),
hostMode,
]);
const acknowledgement = /^quiesced ([a-f0-9]{32})$/u.exec(result.stdout.trim());
if (!acknowledgement) {
throw new Error("Worker workspace quiescence returned an invalid acknowledgement");
}
const nonce = acknowledgement[1]!;
let resumed = false;
let renewalFailure: unknown;
const renewalAbort = new AbortController();
const abortRenewal = () => renewalAbort.abort(params.ownerSignal.reason);
params.ownerSignal.addEventListener("abort", abortRenewal, { once: true });
let renewalQueue = Promise.resolve();
const renew = (validationMode: "heartbeat" | "final") => {
const operation = renewalQueue.then(async () => {
const renewedResult = await run([
"node",
"-e",
REMOTE_WORKSPACE_RENEW_QUIESCENCE_JS,
remoteWorkspaceDir,
nonce,
String(WORKSPACE_QUIESCENCE_TIMEOUT_MS),
validationMode,
hostMode,
]);
if (renewedResult.stdout.trim() !== `renewed ${nonce}`) {
throw new Error(
"Worker workspace quiescence renewal returned an invalid acknowledgement",
);
}
});
renewalQueue = operation.catch(() => undefined);
return operation;
};
const renewalLoop = (async () => {
while (!renewalAbort.signal.aborted) {
if (
!(await waitForQuiescenceRenewal(
renewalAbort.signal,
WORKSPACE_QUIESCENCE_RENEW_INTERVAL_MS,
))
) {
return;
}
try {
await renew("heartbeat");
} catch (error) {
renewalFailure = error;
return;
}
}
})();
return {
assertActive: async () => {
if (resumed) {
throw new Error("Worker workspace quiescence was already released");
}
if (renewalFailure) {
throw new Error("Worker workspace quiescence renewal failed", {
cause: renewalFailure,
});
}
await renew("final");
},
resume: async () => {
if (resumed) {
return;
}
params.ownerSignal.removeEventListener("abort", abortRenewal);
renewalAbort.abort();
await renewalLoop;
await run(["node", "-e", REMOTE_WORKSPACE_RESUME_JS, remoteWorkspaceDir, nonce]);
resumed = true;
},
};
};
}
@@ -1,6 +1,11 @@
import { createHash, randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import { isAcceptedWorkspacePublicationIndeterminateError } from "./workspace-accepted-publication.js";
import {
activeWorkspaceHashContext,
withWorkspaceHashContext,
withWorkspaceHashMemo,
} from "./workspace-hash-memo.js";
import {
MAX_RECONCILIATION_ENTRIES,
type WorkerWorkspaceManifest,
@@ -46,6 +51,15 @@ export async function applyStagedWorkerWorkspace(params: {
conflictPaths: string[];
}) => Promise<void>;
}): Promise<WorkerWorkspaceApplyResult> {
return await withWorkspaceHashContext(
async () => await applyStagedWorkerWorkspaceWithMemo(params),
);
}
async function applyStagedWorkerWorkspaceWithMemo(
params: Parameters<typeof applyStagedWorkerWorkspace>[0],
): Promise<WorkerWorkspaceApplyResult> {
const { memo: hashMemo, metrics } = activeWorkspaceHashContext()!;
const root = await fs.realpath(params.root);
const preserveDirectories = new Set(reconciliationDirectories(params.current.directories));
// Git workspaces must keep the eligibility boundary established at dispatch.
@@ -53,6 +67,26 @@ export async function applyStagedWorkerWorkspace(params: {
const includePaths = params.current.baseCommit
? new Set([...manifestNodes(params.base).keys(), ...manifestNodes(params.current).keys()])
: undefined;
const createApplyResult = (
actual: Awaited<ReturnType<typeof readActualWorkspaceManifest>>,
conflictPaths: string[],
): WorkerWorkspaceApplyResult => ({
...actual,
conflictPaths,
verifyLocalStable: async () =>
await withWorkspaceHashMemo(
hashMemo,
async () =>
await assertActualWorkspaceManifest({
root,
expectedRef: actual.manifestRef,
baseCommit: actual.manifest.baseCommit,
preserveDirectories,
includePaths,
}),
metrics,
),
});
const preflight = await preflightWorkspaceApply({
root,
base: params.base,
@@ -76,18 +110,7 @@ export async function applyStagedWorkerWorkspace(params: {
const conflictPaths = retainedConflictPaths(preflight, preflight.applyPaths);
await params.publishAcceptedManifest?.({ ...actual, conflictPaths });
params.journal.commit(actual.manifestRef);
return {
...actual,
conflictPaths,
verifyLocalStable: async () =>
await assertActualWorkspaceManifest({
root,
expectedRef: actual.manifestRef,
baseCommit: actual.manifest.baseCommit,
preserveDirectories,
includePaths,
}),
};
return createApplyResult(actual, conflictPaths);
}
const baseByPath = new Map(
reconciliationEntries(params.base.entries).map((entry) => [entry.path, entry]),
@@ -197,18 +220,7 @@ export async function applyStagedWorkerWorkspace(params: {
const conflictPaths = retainedConflictPaths(finalPreflight, preflight.applyPaths);
await params.publishAcceptedManifest?.({ ...actual, conflictPaths });
params.journal.commit(actual.manifestRef);
return {
...actual,
conflictPaths,
verifyLocalStable: async () =>
await assertActualWorkspaceManifest({
root,
expectedRef: actual.manifestRef,
baseCommit: actual.manifest.baseCommit,
preserveDirectories,
includePaths,
}),
};
return createApplyResult(actual, conflictPaths);
} catch (error) {
// Transport or settlement timeouts are observation evidence, never authority
// for an inverse operation; recovery owns restoring both sides.
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { FsSafeError, root as openFsSafeRoot } from "../../infra/fs-safe.js";
import { activeWorkspaceHashContext, withWorkspaceHashMemo } from "./workspace-hash-memo.js";
import {
MAX_RECONCILIATION_ENTRIES,
MAX_RECONCILIATION_TOTAL_BYTES,
@@ -324,6 +325,7 @@ export async function inspectAcceptedWorkerWorkspace(params: {
current: WorkerWorkspaceManifest;
}): Promise<WorkerWorkspaceApplyResult | undefined> {
const root = await fs.realpath(params.root);
const { memo: hashMemo, metrics } = activeWorkspaceHashContext() ?? {};
const preserveDirectories = new Set(reconciliationDirectories(params.current.directories));
const actual = await readActualWorkspaceManifest({
root,
@@ -341,16 +343,20 @@ export async function inspectAcceptedWorkerWorkspace(params: {
const conflictPaths = params.allowAdvancedLocalState
? retainedConflictPaths(preflight)
: preflight.conflictPaths;
const verifyLocalStable = async () =>
await assertActualWorkspaceManifest({
root,
expectedRef: actual.manifestRef,
baseCommit: actual.manifest.baseCommit,
preserveDirectories,
});
return {
...actual,
conflictPaths,
verifyLocalStable: async () =>
await assertActualWorkspaceManifest({
root,
expectedRef: actual.manifestRef,
baseCommit: actual.manifest.baseCommit,
preserveDirectories,
}),
hashMemo
? await withWorkspaceHashMemo(hashMemo, verifyLocalStable, metrics)
: await verifyLocalStable(),
};
}
@@ -526,6 +532,18 @@ export async function preflightWorkspaceApply(params: {
const applyPaths = new Set<string>();
const conflicts = new Set<string>();
const blockingConflicts = new Set<string>();
// Node snapshots may be shared only inside this pass. Separate preflight
// calls are concurrency fences and must stat paths again.
const localNodes = new Map<string, Promise<WorkspaceNode>>();
const localNode = (entryPath: string): Promise<WorkspaceNode> => {
const existing = localNodes.get(entryPath);
if (existing) {
return existing;
}
const node = localWorkspaceNode(params.root, entryPath);
localNodes.set(entryPath, node);
return node;
};
for (const entryPath of paths) {
if (hasPathAncestor(blockingConflicts, entryPath)) {
continue;
@@ -551,7 +569,7 @@ export async function preflightWorkspaceApply(params: {
const baseAncestor = baseNodes.get(ancestor);
const currentAncestor = currentNodes.get(ancestor);
if (!baseAncestor && !currentAncestor) {
const localAncestor = await localWorkspaceNode(params.root, ancestor);
const localAncestor = await localNode(ancestor);
if (localAncestor && localAncestor.type !== "directory") {
conflicts.add(ancestor);
blockingConflicts.add(ancestor);
@@ -560,7 +578,7 @@ export async function preflightWorkspaceApply(params: {
}
continue;
}
const localAncestor = await localWorkspaceNode(params.root, ancestor);
const localAncestor = await localNode(ancestor);
const localStructurallyMatchesBase =
localAncestor?.type === "directory" && baseAncestor?.type === "directory"
? true
@@ -588,7 +606,7 @@ export async function preflightWorkspaceApply(params: {
baseAncestor &&
baseAncestor.type !== "directory" &&
!sameEntry(baseAncestor, currentNodes.get(ancestor)) &&
sameEntry(await localWorkspaceNode(params.root, ancestor), baseAncestor)
sameEntry(await localNode(ancestor), baseAncestor)
) {
replacedBaseAncestor = true;
break;
@@ -597,7 +615,7 @@ export async function preflightWorkspaceApply(params: {
if (replacedBaseAncestor) {
local = undefined;
} else {
local = await localWorkspaceNode(params.root, entryPath);
local = await localNode(entryPath);
if (
local?.type === "directory" &&
(!baseNodes.has(entryPath) || !currentNodes.has(entryPath)) &&
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { isPathInside, resolveOpenedFileRealPathForHandle } from "../../infra/fs-safe.js";
import { runCommandBuffered } from "../../process/exec.js";
import { activeWorkspaceHashContext, workspaceStatIdentity } from "./workspace-hash-memo.js";
import {
gitFileMode,
MAX_RECONCILIATION_FILE_BYTES,
@@ -26,44 +27,54 @@ async function readOpenedWorkspaceFile(params: {
expectedPath: string;
root?: string;
}): Promise<WorkspaceFileSnapshot> {
const before = await params.handle.stat();
const { memo: hashMemo, metrics } = activeWorkspaceHashContext() ?? {};
const before = await params.handle.stat({ bigint: true });
const realPath = await resolveOpenedFileRealPathForHandle(params.handle, params.expectedPath);
if (!before.isFile() || (params.root && !isPathInside(params.root, realPath))) {
throw new Error("Gateway workspace file changed while it was being read");
}
if (before.size > MAX_RECONCILIATION_FILE_BYTES) {
if (before.size > BigInt(MAX_RECONCILIATION_FILE_BYTES)) {
return { type: "unsupported" };
}
const hash = createHash("sha256");
const buffer = Buffer.allocUnsafe(64 * 1024);
let size = 0;
for (;;) {
const { bytesRead } = await params.handle.read(buffer, 0, buffer.length, size);
if (bytesRead === 0) {
break;
const identity = workspaceStatIdentity("gateway", before);
let sha256 = hashMemo?.get(identity);
let size = Number(before.size);
if (sha256) {
if (metrics) {
metrics.memoHitCount += 1;
}
size += bytesRead;
if (size > MAX_RECONCILIATION_FILE_BYTES) {
return { type: "unsupported" };
} else {
const hashStartedAt = performance.now();
const hash = createHash("sha256");
const buffer = Buffer.allocUnsafe(64 * 1024);
size = 0;
for (;;) {
const { bytesRead } = await params.handle.read(buffer, 0, buffer.length, size);
if (bytesRead === 0) {
break;
}
size += bytesRead;
if (size > MAX_RECONCILIATION_FILE_BYTES) {
return { type: "unsupported" };
}
hash.update(buffer.subarray(0, bytesRead));
}
sha256 = hash.digest("hex");
if (metrics) {
metrics.contentHashCount += 1;
metrics.contentHashDurationMs += performance.now() - hashStartedAt;
}
hash.update(buffer.subarray(0, bytesRead));
}
const after = await params.handle.stat();
if (
after.size !== size ||
after.size !== before.size ||
after.mtimeMs !== before.mtimeMs ||
after.ctimeMs !== before.ctimeMs ||
after.ino !== before.ino ||
after.dev !== before.dev
) {
const after = await params.handle.stat({ bigint: true });
if (after.size !== BigInt(size) || workspaceStatIdentity("gateway", after) !== identity) {
throw new Error("Gateway workspace file changed while it was being read");
}
hashMemo?.set(identity, sha256);
return {
type: "file",
mode: gitFileMode(after.mode & 0o777),
mode: gitFileMode(Number(after.mode & 0o777n)),
size,
sha256: hash.digest("hex"),
sha256,
};
}
@@ -77,7 +88,11 @@ export async function readWorkspaceFileSnapshot(
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
return await readOpenedWorkspaceFile({ handle, expectedPath: absolute, root });
return await readOpenedWorkspaceFile({
handle,
expectedPath: absolute,
root,
});
} finally {
await handle.close();
}
@@ -4,6 +4,11 @@ import os from "node:os";
import path from "node:path";
import { runCommandBuffered, runCommandWithTimeout } from "../../process/exec.js";
import type { WorkerWorkspaceReconcileRequest } from "./tunnel-contract.js";
import {
activeWorkspaceHashContext,
withWorkspaceHashContext,
withWorkspaceHashMemo,
} from "./workspace-hash-memo.js";
import {
MAX_RECONCILIATION_ENTRIES,
MAX_RECONCILIATION_FILE_BYTES,
@@ -440,6 +445,14 @@ export async function applyStagedWorkerWorkspaceResult(params: {
conflictPaths: string[];
}) => Promise<void>;
}): Promise<WorkerWorkspaceApplyResult & { changed: boolean }> {
return await withWorkspaceHashContext(
async () => await applyStagedWorkerWorkspaceResultWithMemo(params),
);
}
async function applyStagedWorkerWorkspaceResultWithMemo(
params: Parameters<typeof applyStagedWorkerWorkspaceResult>[0],
): Promise<WorkerWorkspaceApplyResult & { changed: boolean }> {
const root = await fs.realpath(params.root);
const staged = await loadStagedWorkerWorkspace(root, params.stagedResultRef);
if (params.alreadyAccepted || staged.baseManifestRef !== params.expectedBaseManifestRef) {
@@ -523,6 +536,9 @@ async function prepareRequestedWorkerWorkspaceResult(params: {
throw new Error("Cloud workspace durable result staging was not requested");
}
const candidateRef = preparedWorkerWorkspaceResultRef(stagedResult.ref);
const active = activeWorkspaceHashContext();
const hashMemo = active?.memo ?? new Map();
const metrics = active?.metrics;
let appliedWorkspaceResult: WorkerWorkspaceApplyResult | undefined;
await stageWorkerWorkspaceResult({
root: params.request.localPath,
@@ -536,13 +552,18 @@ async function prepareRequestedWorkerWorkspaceResult(params: {
return {
applyPreparedStagedResult: async () => {
const root = await ensureWorkerWorkspaceResultRepository(params.request.localPath);
appliedWorkspaceResult = await applyStagedWorkerWorkspaceResult({
root,
stagedResultRef: candidateRef,
expectedBaseManifestRef: params.request.baseManifestRef,
journal: params.request.journal,
publishAcceptedManifest: params.publishAcceptedManifest,
});
appliedWorkspaceResult = await withWorkspaceHashMemo(
hashMemo,
async () =>
await applyStagedWorkerWorkspaceResult({
root,
stagedResultRef: candidateRef,
expectedBaseManifestRef: params.request.baseManifestRef,
journal: params.request.journal,
publishAcceptedManifest: params.publishAcceptedManifest,
}),
metrics,
);
},
getAppliedWorkspaceResult: () => appliedWorkspaceResult,
verifyLocalStable: async () => {
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { z } from "zod";
import { redactSensitiveText } from "../../logging/redact.js";
import type { CommandOptions, SpawnResult } from "../../process/exec.js";
import {
@@ -12,9 +13,39 @@ import {
workerSshRemoteCommand,
} from "./ssh.js";
import type { WorkerWorkspaceCommand, WorkerWorkspaceSyncRequest } from "./tunnel-contract.js";
import {
recordRemoteWorkspaceHashMetrics,
serializeRemoteWorkspaceHashMemo,
type WorkspaceHashMemo,
type WorkspaceReconcileMetrics,
} from "./workspace-hash-memo.js";
import { MAX_RECONCILIATION_ENTRIES } from "./workspace-manifest.js";
import { REMOTE_WORKSPACE_MANIFEST_JS } from "./workspace-sync-scripts.js";
const MANIFEST_REF_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const WORKER_HASH_IDENTITY_PATTERN = /^worker:\d+:\d+:\d+:\d+:\d+$/u;
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
const remoteWorkspaceManifestEnvelopeSchema = z
.object({
version: z.literal(1),
manifestRef: z.string().regex(MANIFEST_REF_PATTERN),
memo: z
.array(
z.tuple([z.string().regex(WORKER_HASH_IDENTITY_PATTERN), z.string().regex(SHA256_PATTERN)]),
)
.max(MAX_RECONCILIATION_ENTRIES),
metrics: z
.object({
contentHashCount: z.number().finite().nonnegative(),
contentHashDurationMs: z.number().finite().nonnegative(),
memoHitCount: z.number().finite().nonnegative(),
totalDurationMs: z.number().finite().nonnegative(),
})
.strict(),
})
.strict();
const INBOUND_QUOTA_INITIAL_POLL_MS = 25;
const INBOUND_QUOTA_MAX_POLL_MS = 250;
export const WORKER_WORKSPACE_RSYNC_DESTINATION = "openclaw-rsync-destination";
export type WorkerWorkspaceActionsOptions = {
@@ -200,33 +231,55 @@ export async function resolveRemoteWorkspaceManifest(
);
}
export async function verifyRemoteWorkspaceManifest(params: {
export async function captureRemoteWorkspaceManifest(params: {
runWorkspaceCommand: (command: WorkerWorkspaceCommand) => Promise<SpawnResult>;
remoteWorkspaceDir: string;
baseCommit: string | null;
baseDigest: string;
expectedRef: string;
}): Promise<void> {
const expectedDigest = params.expectedRef.slice("sha256:".length);
const verified = await params.runWorkspaceCommand({
transportRetry: "idempotent",
argv: [
"node",
"-e",
REMOTE_WORKSPACE_MANIFEST_JS,
params.remoteWorkspaceDir,
params.baseCommit ?? "",
// Seed both manifests so a deleted path recreated under a new ignore rule
// still invalidates the fence.
...(params.baseCommit ? ["eligible", expectedDigest, params.baseDigest] : []),
],
});
if (!workerWorkspaceCommandSucceeded(verified)) {
throw workspaceSyncError(verified);
priorManifestDigests: readonly string[];
hashMemo: WorkspaceHashMemo;
metrics: WorkspaceReconcileMetrics;
}): Promise<string> {
params.metrics.remoteManifestCalls += 1;
const startedAt = performance.now();
const captured = await params
.runWorkspaceCommand({
transportRetry: "idempotent",
argv: [
"node",
"-e",
REMOTE_WORKSPACE_MANIFEST_JS,
params.remoteWorkspaceDir,
params.baseCommit ?? "",
...(params.baseCommit ? ["eligible"] : []),
...params.priorManifestDigests,
"memo-v1",
],
input: serializeRemoteWorkspaceHashMemo(params.hashMemo),
})
.finally(() => {
params.metrics.remoteManifestWallDurationMs += performance.now() - startedAt;
});
if (!workerWorkspaceCommandSucceeded(captured)) {
throw workspaceSyncError(captured);
}
if (parseManifestRef(verified.stdout.trim()) !== params.expectedRef) {
throw new Error("Cloud workspace changed during final reconciliation");
let response;
try {
response = remoteWorkspaceManifestEnvelopeSchema.parse(JSON.parse(captured.stdout));
} catch (error) {
throw new Error("Worker workspace manifest returned an invalid memo response", {
cause: error,
});
}
for (const identity of params.hashMemo.keys()) {
if (identity.startsWith("worker:")) {
params.hashMemo.delete(identity);
}
}
for (const [identity, sha256] of response.memo) {
params.hashMemo.set(identity, sha256);
}
recordRemoteWorkspaceHashMetrics(params.metrics, response.metrics);
return response.manifestRef;
}
export async function probeWorkspaceGitMode(params: {
@@ -400,7 +453,10 @@ export async function runBoundedInboundRsync(params: {
() => true,
);
let quotaError: Error | undefined;
while (!(await Promise.race([transferSettled, delay(25).then(() => false)]))) {
let pollIntervalMs = INBOUND_QUOTA_INITIAL_POLL_MS;
// Rsync reports logical updates, not partial files or retry residue. Back off
// the canonical tree scan, then always recheck once more before acceptance.
while (!(await Promise.race([transferSettled, delay(pollIntervalMs).then(() => false)]))) {
const usage = await inboundDirectoryUsage(params.destinationRoot, {
bytes: params.totalByteLimit,
entries: params.entryLimit,
@@ -412,6 +468,7 @@ export async function runBoundedInboundRsync(params: {
quotaAbort.abort(quotaError);
break;
}
pollIntervalMs = Math.min(pollIntervalMs * 2, INBOUND_QUOTA_MAX_POLL_MS);
}
let result: SpawnResult;
try {
@@ -317,3 +317,27 @@ export async function createGitTransferList(params: {
});
return outputPath;
}
export async function filterExistingGitTransferList(params: {
gitRoot: string;
preparedListPath: string;
outputPath: string;
}): Promise<string> {
const output = await fs.open(params.outputPath, "wx", 0o600);
try {
for await (const file of readNulFile(params.preparedListPath)) {
const stats = await fs.lstat(path.join(params.gitRoot, file)).catch((error: unknown) => {
if (hasNodeErrorCode(error, "ENOENT")) {
return undefined;
}
throw error;
});
if (stats?.isFile() || stats?.isSymbolicLink()) {
await output.write(`${file}\0`);
}
}
} finally {
await output.close();
}
return params.outputPath;
}
@@ -4,6 +4,8 @@ import {
} from "./workspace-manifest-remote-script.js";
export { REMOTE_WORKSPACE_ACCEPTED_TRANSACTION_JS } from "./workspace-accepted-remote-script.js";
export { REMOTE_GIT_WORKSPACE_RETRY_RESET_JS } from "./workspace-mutation-remote-script.js";
import { MAX_WORKSPACE_HASH_MEMO_BYTES, workspaceStatIdentity } from "./workspace-hash-memo.js";
import { MAX_RECONCILIATION_ENTRIES } from "./workspace-manifest.js";
import {
DERIVED_WORKSPACE_DIRECTORY_NAMES,
DERIVED_WORKSPACE_FILE_NAMES,
@@ -68,16 +70,46 @@ const DERIVED_WORKSPACE_DIRECTORY_NAMES = ${JSON.stringify(DERIVED_WORKSPACE_DIR
const DERIVED_WORKSPACE_FILE_NAMES = ${JSON.stringify(DERIVED_WORKSPACE_FILE_NAMES)};
const DERIVED_WORKSPACE_FILE_SUFFIXES = ${JSON.stringify(DERIVED_WORKSPACE_FILE_SUFFIXES)};
const isDerivedWorkspacePath = ${isDerivedWorkspacePath.toString()};
const workspaceStatIdentity = ${workspaceStatIdentity.toString()};
const MAX_RECONCILIATION_ENTRIES = ${MAX_RECONCILIATION_ENTRIES};
const MAX_HASH_MEMO_BYTES = ${MAX_WORKSPACE_HASH_MEMO_BYTES};
const root = fs.realpathSync(process.argv[1]);
const requestedBaseCommit = process.argv[2] || null;
const eligibleOnly = process.argv[3] === "eligible";
const requestedManifestDigest = process.argv[3] === "resolve" ? process.argv[4] : null;
const publishedManifestDigest = process.argv[3] === "publish" ? process.argv[4] : null;
const priorManifestDigests = [...new Set(process.argv.slice(4).filter(Boolean))];
const memoMode = process.argv.at(-1) === "memo-v1";
const priorManifestDigests = [
...new Set(process.argv.slice(4).filter((value) => value && value !== "memo-v1")),
];
const entriesByPath = new Map();
const usedHashMemo = new Map();
const metrics = { contentHashCount: 0, contentHashDurationMs: 0, memoHitCount: 0 };
const startedAt = performance.now();
function fail(message) {
throw new Error(message);
}
function readHashMemo() {
if (!memoMode) return new Map();
const raw = fs.readFileSync(0, "utf8");
if (Buffer.byteLength(raw) > MAX_HASH_MEMO_BYTES) {
fail("workspace hash memo exceeds its byte limit");
}
let entries;
try {
entries = JSON.parse(raw);
} catch {
fail("invalid workspace hash memo");
}
if (
!Array.isArray(entries) ||
entries.length > MAX_RECONCILIATION_ENTRIES
) {
fail("invalid workspace hash memo");
}
return new Map(entries);
}
const hashMemo = readHashMemo();
${REMOTE_WORKSPACE_MANIFEST_CANONICAL_JS}
function addEntry(relative) {
if (
@@ -213,12 +245,40 @@ async function hashFiles() {
if (entry.type !== "file") {
continue;
}
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(path.join(root, entry.path));
for await (const chunk of stream) {
hash.update(chunk);
const absolute = path.join(root, entry.path);
const handle = await fs.promises.open(
absolute,
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
);
try {
const before = await handle.stat({ bigint: true });
if (!before.isFile()) fail("worker workspace file changed while it was being read");
const identity = workspaceStatIdentity("worker", before);
let sha256 = hashMemo.get(identity);
if (sha256) {
metrics.memoHitCount += 1;
} else {
const hashStartedAt = performance.now();
const hash = crypto.createHash("sha256");
const stream = handle.createReadStream({ autoClose: false });
for await (const chunk of stream) {
hash.update(chunk);
}
sha256 = hash.digest("hex");
metrics.contentHashCount += 1;
metrics.contentHashDurationMs += performance.now() - hashStartedAt;
}
const after = await handle.stat({ bigint: true });
if (workspaceStatIdentity("worker", after) !== identity) {
fail("worker workspace file changed while it was being read");
}
entry.mode = Number(after.mode & 0o777n);
entry.size = Number(after.size);
entry.sha256 = sha256;
usedHashMemo.set(identity, sha256);
} finally {
await handle.close();
}
entry.sha256 = hash.digest("hex");
}
return entries;
}
@@ -267,7 +327,20 @@ async function main() {
const baseCommit = requestedBaseCommit;
const manifest = serializeManifest(baseCommit, entries);
const digest = publishManifest(manifestRoot, manifest);
process.stdout.write("sha256:" + digest + "\n");
const manifestRef = "sha256:" + digest;
const measured = { ...metrics, totalDurationMs: performance.now() - startedAt };
if (memoMode) {
process.stdout.write(JSON.stringify({
version: 1,
manifestRef,
memo: [...usedHashMemo].sort((left, right) =>
left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0,
),
metrics: measured,
}) + "\n");
} else {
process.stdout.write(manifestRef + "\n");
}
}
main().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error) + "\n");
@@ -517,6 +517,11 @@ describe("worker tunnel manager", () => {
entry.argv[0] === "rsync" && entry.argv.some((arg) => arg.startsWith("--files-from=")),
);
expect(transfers.map((entry) => rsyncArgvPort(entry.argv))).toEqual([2222, 22]);
const fileLists = transfers.map((entry) =>
entry.argv.find((arg) => arg.startsWith("--files-from="))!.slice(13),
);
expect(new Set(fileLists.map((file) => path.dirname(file))).size).toBe(1);
expect(fileLists.map((file) => path.basename(file))).toEqual(["attempt-0", "attempt-1"]);
for (const transfer of transfers) {
expect(transfer.argv).toContain("--delete-delay");
expect(transfer.argv).not.toContain("--delete-excluded");
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { CommandOptions, SpawnResult } from "../../process/exec.js";
import type { PreparedWorkerSsh } from "./ssh.js";
import { rsyncArgvPort, sshArgvPort } from "./worker-ssh-argv.test-support.js";
import { runBoundedInboundRsync } from "./workspace-sync-helpers.js";
import { createWorkerWorkspaceRsyncTransport } from "./workspace-sync-transport.js";
import { createWorkerWorkspaceActions } from "./workspace-sync.js";
@@ -189,3 +190,65 @@ describe("worker workspace rsync transport retry", () => {
}
});
});
describe("bounded inbound workspace transfer", () => {
it("aborts an in-flight transfer when the destination crosses quota", async () => {
const destinationRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rsync-quota-"));
let transferSignal: AbortSignal | undefined;
try {
const runTask = vi.fn(async (_argv: string[], options: CommandOptions) => {
transferSignal = options.signal;
await fs.writeFile(path.join(destinationRoot, "oversized"), "over quota");
return await new Promise<SpawnResult>((_resolve, reject) => {
const abort = () => {
const reason = options.signal?.reason;
reject(reason instanceof Error ? reason : new Error("aborted"));
};
options.signal?.addEventListener("abort", abort, { once: true });
if (options.signal?.aborted) {
abort();
}
});
});
await expect(
runBoundedInboundRsync({
argv: ["rsync"],
destinationRoot,
entryLimit: 10,
totalByteLimit: 1,
ownerSignal: new AbortController().signal,
runTask,
timeoutMs: 10_000,
}),
).rejects.toThrow("inbound transfer exceeds");
expect(transferSignal?.aborted).toBe(true);
} finally {
await fs.rm(destinationRoot, { recursive: true, force: true });
}
});
it("rejects a completed over-quota transfer in the authoritative final scan", async () => {
const destinationRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rsync-final-quota-"));
try {
const runTask = vi.fn(async () => {
await fs.writeFile(path.join(destinationRoot, "oversized"), "over quota");
return result();
});
await expect(
runBoundedInboundRsync({
argv: ["rsync"],
destinationRoot,
entryLimit: 10,
totalByteLimit: 1,
ownerSignal: new AbortController().signal,
runTask,
timeoutMs: 10_000,
}),
).rejects.toThrow("inbound transfer exceeds");
} finally {
await fs.rm(destinationRoot, { recursive: true, force: true });
}
});
});
+204 -269
View File
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { CommandOptions, SpawnResult } from "../../process/exec.js";
import { type PreparedWorkerSsh, runWorkerSshCandidates, workerSshCommandOptions } from "./ssh.js";
import {
@@ -17,12 +18,16 @@ import {
createAcceptedWorkspacePublisherFactory,
recoverAcceptedWorkspacePublication,
} from "./workspace-accepted-sync.js";
import { DERIVED_WORKSPACE_RSYNC_EXCLUDES } from "./workspace-path-exclusions.js";
import { registerWorkspaceReconcileReporter } from "./workspace-finalize.js";
import {
REMOTE_WORKSPACE_QUIESCE_JS,
REMOTE_WORKSPACE_RENEW_QUIESCENCE_JS,
REMOTE_WORKSPACE_RESUME_JS,
} from "./workspace-quiescence-scripts.js";
createWorkspaceReconcileMetrics,
MAX_WORKSPACE_HASH_MEMO_BYTES,
measureLocalWorkspaceReconciliation,
withWorkspaceHashMemo,
type WorkspaceReconcileMetrics,
} from "./workspace-hash-memo.js";
import { DERIVED_WORKSPACE_RSYNC_EXCLUDES } from "./workspace-path-exclusions.js";
import { createWorkerWorkspaceQuiescence } from "./workspace-quiescence.js";
import {
applyStagedWorkerWorkspace,
assertWorkspaceMatchesManifest,
@@ -39,6 +44,7 @@ import {
workerWorkspaceTransferPaths,
} from "./workspace-result-staging.js";
import {
captureRemoteWorkspaceManifest,
createWorkerWorkspaceRsyncReceiverPathFactory,
parseManifestRef,
parseRemoteWorkspaceSetup,
@@ -47,8 +53,6 @@ import {
resolveRemoteWorkspaceManifest,
stableWorkerPathComponent,
validateWorkspaceSyncRequest,
verifyRemoteWorkspaceManifest,
waitForQuiescenceRenewal,
WORKER_WORKSPACE_RSYNC_DESTINATION,
workerWorkspaceCommandSucceeded as success,
workerWorkspaceRsyncRemoteCommand,
@@ -57,7 +61,11 @@ import {
workspaceSyncError,
type WorkerWorkspaceActionsOptions,
} from "./workspace-sync-helpers.js";
import { createGitTransferList, runLocalCommandToFile } from "./workspace-sync-local.js";
import {
createGitTransferList,
filterExistingGitTransferList,
runLocalCommandToFile,
} from "./workspace-sync-local.js";
import {
REMOTE_GIT_WORKSPACE_RETRY_RESET_JS,
REMOTE_GIT_WORKSPACE_SETUP_SCRIPT,
@@ -68,14 +76,13 @@ import { createWorkerWorkspaceRsyncTransport } from "./workspace-sync-transport.
const REMOTE_SETUP_TIMEOUT_MS = 20_000;
const WORKSPACE_TIMEOUT_MS = 10 * 60_000;
const WORKSPACE_QUIESCENCE_TIMEOUT_MS = 12 * 60_000;
const WORKSPACE_QUIESCENCE_RENEW_INTERVAL_MS = 4 * 60_000;
// Relative to the canonical worker $HOME owned by REMOTE_WORKSPACE_SETUP_SCRIPT;
// rsync targets must use the returned absolute directory, never this relative path.
const REMOTE_WORKSPACE_ROOT = ".openclaw-worker/workspaces";
const REMOTE_GIT_PACK_NAME = ".openclaw-base.pack";
const GIT_COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u;
const INBOUND_RSYNC_BW_LIMIT_KIB = 65_536;
const workspaceSyncLog = createSubsystemLogger("gateway/worker-workspace");
/** Binds workspace commands and synchronization to one connected tunnel owner. */
export function createWorkerWorkspaceActions(
@@ -117,12 +124,22 @@ export function createWorkerWorkspaceActions(
const signal = command.signal
? AbortSignal.any([options.ownerSignal, command.signal])
: options.ownerSignal;
const commandOptions = (remainingTimeoutMs: number): CommandOptions => {
const base = workerSshCommandOptions({
input: command.input,
timeoutMs: remainingTimeoutMs,
signal,
});
return command.argv.at(-1) === "memo-v1"
? { ...base, maxOutputBytes: MAX_WORKSPACE_HASH_MEMO_BYTES }
: base;
};
// Exit 255 does not prove whether the remote command was accepted, so stateful
// commands must stay pinned to one transport attempt.
if (command.transportRetry === "never") {
return await runTask(
workerWorkspaceSshArgv(prepared, command.argv),
workerSshCommandOptions({ input: command.input, timeoutMs, signal }),
commandOptions(timeoutMs),
);
}
return await runWorkerSshCandidates(
@@ -131,119 +148,16 @@ export function createWorkerWorkspaceActions(
async (port, remainingTimeoutMs) =>
await runTask(
workerWorkspaceSshArgv(prepared, command.argv, port),
workerSshCommandOptions({
input: command.input,
timeoutMs: remainingTimeoutMs,
signal,
}),
commandOptions(remainingTimeoutMs),
),
);
};
const quiesceWorkspace = async (remoteWorkspaceDir: string) => {
if (!path.posix.isAbsolute(remoteWorkspaceDir)) {
throw new Error("Worker workspace quiescence path must be absolute");
}
const result = await runWorkspaceCommand({
transportRetry: "never",
argv: [
"node",
"-e",
REMOTE_WORKSPACE_QUIESCE_JS,
remoteWorkspaceDir,
String(WORKSPACE_QUIESCENCE_TIMEOUT_MS),
options.sharedHost === true ? "shared-host" : "dedicated",
],
});
if (!success(result)) {
throw workspaceSyncError(result);
}
const acknowledgement = /^quiesced ([a-f0-9]{32})$/u.exec(result.stdout.trim());
if (!acknowledgement) {
throw new Error("Worker workspace quiescence returned an invalid acknowledgement");
}
const nonce = acknowledgement[1]!;
let resumed = false;
let renewalFailure: unknown;
const renewalAbort = new AbortController();
const abortRenewal = () => renewalAbort.abort(options.ownerSignal.reason);
options.ownerSignal.addEventListener("abort", abortRenewal, { once: true });
let renewalQueue = Promise.resolve();
const renew = (validationMode: "heartbeat" | "final") => {
const operation = renewalQueue.then(async () => {
const renewedResult = await runWorkspaceCommand({
transportRetry: "never",
argv: [
"node",
"-e",
REMOTE_WORKSPACE_RENEW_QUIESCENCE_JS,
remoteWorkspaceDir,
nonce,
String(WORKSPACE_QUIESCENCE_TIMEOUT_MS),
validationMode,
options.sharedHost === true ? "shared-host" : "dedicated",
],
});
if (!success(renewedResult)) {
throw workspaceSyncError(renewedResult);
}
if (renewedResult.stdout.trim() !== `renewed ${nonce}`) {
throw new Error(
"Worker workspace quiescence renewal returned an invalid acknowledgement",
);
}
});
renewalQueue = operation.catch(() => undefined);
return operation;
};
const renewalLoop = (async () => {
while (!renewalAbort.signal.aborted) {
if (
!(await waitForQuiescenceRenewal(
renewalAbort.signal,
WORKSPACE_QUIESCENCE_RENEW_INTERVAL_MS,
))
) {
return;
}
try {
await renew("heartbeat");
} catch (error) {
renewalFailure = error;
return;
}
}
})();
return {
assertActive: async () => {
if (resumed) {
throw new Error("Worker workspace quiescence was already released");
}
if (renewalFailure) {
throw new Error("Worker workspace quiescence renewal failed", {
cause: renewalFailure,
});
}
await renew("final");
},
resume: async () => {
if (resumed) {
return;
}
options.ownerSignal.removeEventListener("abort", abortRenewal);
renewalAbort.abort();
await renewalLoop;
const resumedResult = await runWorkspaceCommand({
transportRetry: "never",
argv: ["node", "-e", REMOTE_WORKSPACE_RESUME_JS, remoteWorkspaceDir, nonce],
});
if (!success(resumedResult)) {
throw workspaceSyncError(resumedResult);
}
resumed = true;
},
};
};
const quiesceWorkspace = createWorkerWorkspaceQuiescence({
ownerSignal: options.ownerSignal,
sharedHost: options.sharedHost === true,
runWorkspaceCommand,
});
const syncWorkspaceImpl = async (
request: WorkerWorkspaceSyncRequest,
@@ -288,7 +202,7 @@ export function createWorkerWorkspaceActions(
remoteRelative,
};
const mutationReceiverPath = createWorkerWorkspaceRsyncReceiverPathFactory(receiverContext);
let prepareGitTransferList: (() => Promise<string>) | undefined;
let gitTransferListPath: string | undefined;
if (mode === "git") {
const [canonicalRequestPath, canonicalGitRoot] = await Promise.all([
fs.realpath(request.localPath),
@@ -301,14 +215,12 @@ export function createWorkerWorkspaceActions(
throw new Error("Worker workspace git base is not a commit id");
}
let transferAttempt = 0;
prepareGitTransferList = async () =>
await createGitTransferList({
gitRoot,
temporaryDirectory: path.join(temporaryDirectory, `transfer-${transferAttempt++}`),
signal: options.ownerSignal,
timeoutMs: WORKSPACE_TIMEOUT_MS,
});
gitTransferListPath = await createGitTransferList({
gitRoot,
temporaryDirectory: path.join(temporaryDirectory, "transfer"),
signal: options.ownerSignal,
timeoutMs: WORKSPACE_TIMEOUT_MS,
});
const objectListPath = path.join(temporaryDirectory, "base-objects");
const packPath = path.join(temporaryDirectory, "base.pack");
@@ -396,7 +308,9 @@ export function createWorkerWorkspaceActions(
`${prepared.scpTarget}:${WORKER_WORKSPACE_RSYNC_DESTINATION}`,
];
let retryingGitTransfer = false;
const transfer = prepareGitTransferList
let transferAttempt = 0;
const preparedGitTransferListPath = gitTransferListPath;
const transfer = preparedGitTransferListPath
? await runWorkerSshCandidates(
prepared,
WORKSPACE_TIMEOUT_MS,
@@ -435,7 +349,14 @@ export function createWorkerWorkspaceActions(
);
}
}
const fileListPath = await prepareGitTransferList();
const fileListPath = await filterExistingGitTransferList({
gitRoot,
preparedListPath: preparedGitTransferListPath,
outputPath: path.join(
path.dirname(preparedGitTransferListPath),
`attempt-${transferAttempt++}`,
),
});
const result = await runTask(
transferArgv(workerWorkspaceRsyncRemoteCommand(prepared, port), fileListPath),
commandOptions(),
@@ -473,8 +394,9 @@ export function createWorkerWorkspaceActions(
}
};
const reconcileWorkspaceImpl = async (
const reconcileWorkspaceRun = async (
request: WorkerWorkspaceReconcileRequest,
metrics: WorkspaceReconcileMetrics,
): Promise<WorkerWorkspaceReconcileResult> => {
if (!path.isAbsolute(request.localPath) || !path.posix.isAbsolute(request.remoteWorkspaceDir)) {
throw new Error("Worker workspace reconcile paths must be absolute");
@@ -484,6 +406,11 @@ export function createWorkerWorkspaceActions(
await recoverWorkerWorkspaceReconciliation({ root: request.localPath, journal: pending });
request.journal.abort();
}
const hashMemo = new Map<string, string>();
const runLocalReconciliation = <T>(operation: () => Promise<T>): Promise<T> =>
measureLocalWorkspaceReconciliation(metrics, () =>
withWorkspaceHashMemo(hashMemo, operation, metrics.gateway),
);
const baseDigest = await resolveRemoteWorkspaceManifest(
runWorkspaceCommand,
request.remoteWorkspaceDir,
@@ -504,6 +431,8 @@ export function createWorkerWorkspaceActions(
receiverEntryPath,
localPath: request.localPath,
remoteWorkspaceDir: request.remoteWorkspaceDir,
hashMemo,
metrics,
});
try {
await fs.mkdir(stagingRoot, { mode: 0o700 });
@@ -539,177 +468,161 @@ export function createWorkerWorkspaceActions(
runWorkspaceCommand,
remoteWorkspaceDir: request.remoteWorkspaceDir,
});
const verifyStable = async (expectedRef: string): Promise<void> =>
await verifyRemoteWorkspaceManifest({
const verifyStable = async (expectedRef: string): Promise<void> => {
const expectedDigest = expectedRef.slice("sha256:".length);
const observedRef = await captureRemoteWorkspaceManifest({
runWorkspaceCommand,
remoteWorkspaceDir: request.remoteWorkspaceDir,
baseCommit: base.baseCommit,
baseDigest,
expectedRef,
// Seed both manifests so a recreated path under a new ignore rule
// still invalidates the late-writer fence.
priorManifestDigests: base.baseCommit ? [expectedDigest, baseDigest] : [],
hashMemo,
metrics,
});
const currentResult = await runWorkspaceCommand({
transportRetry: "idempotent",
argv: [
"node",
"-e",
REMOTE_WORKSPACE_MANIFEST_JS,
request.remoteWorkspaceDir,
base.baseCommit ?? "",
...(base.baseCommit ? ["eligible"] : []),
...(base.baseCommit ? [baseDigest] : []),
],
});
if (!success(currentResult)) {
throw workspaceSyncError(currentResult);
}
const currentRef = parseManifestRef(currentResult.stdout.trim());
if (currentRef === request.baseManifestRef) {
const { expectedRemoteRef, publishAcceptedManifest } = acceptedWorkspacePublisher(
base,
currentRef,
);
await verifyStable(currentRef);
const stagedResult = request.stagedResult
? await workerWorkspaceResultStaging.prepareRequestedWorkerWorkspaceResult({
request,
stagingRoot,
currentManifestRef: currentRef,
baseManifestRaw: baseRaw,
currentManifestRaw: baseRaw,
publishAcceptedManifest,
})
: undefined;
let appliedWorkspaceResult: WorkerWorkspaceApplyResult | undefined;
if (!stagedResult) {
appliedWorkspaceResult = await applyStagedWorkerWorkspace({
root: request.localPath,
stagingRoot,
baseManifestRef: request.baseManifestRef,
currentManifestRef: currentRef,
base,
current: base,
journal: request.journal,
publishAcceptedManifest,
});
if (observedRef !== expectedRef) {
throw new Error("Cloud workspace changed during final reconciliation");
}
return {
get manifestRef() {
return expectedRemoteRef();
},
changed: false,
verifyStable: async () => await verifyStable(expectedRemoteRef()),
verifyLocalStable: async () =>
await (appliedWorkspaceResult?.verifyLocalStable() ??
assertWorkspaceResultStable({ root: request.localPath, base, current: base })),
getAppliedWorkspaceResult: () => appliedWorkspaceResult,
...stagedResult,
};
}
const currentDigest = currentRef.slice("sha256:".length);
const currentManifestPath = path.join(manifestRoot, `${currentDigest}.json`);
const currentManifestTransfer = await runBoundedInboundRsync({
prepared,
argv: (rsyncSsh) => [
"rsync",
"--archive",
"--no-recursive",
"--checksum",
`--max-size=${MAX_RECONCILIATION_FILE_BYTES}`,
`--bwlimit=${INBOUND_RSYNC_BW_LIMIT_KIB}`,
"-e",
rsyncSsh,
"--",
`${prepared.scpTarget}:.openclaw-worker/manifests/${currentDigest}.json`,
currentManifestPath,
],
destinationRoot: manifestRoot,
entryLimit: 1,
totalByteLimit: MAX_RECONCILIATION_FILE_BYTES,
};
const currentRef = await captureRemoteWorkspaceManifest({
runWorkspaceCommand,
remoteWorkspaceDir: request.remoteWorkspaceDir,
baseCommit: base.baseCommit,
priorManifestDigests: base.baseCommit ? [baseDigest] : [],
hashMemo,
metrics,
});
if (!success(currentManifestTransfer)) {
throw workspaceSyncError(currentManifestTransfer);
}
const currentRaw = await readTransferredManifest(currentManifestPath);
const current = parseWorkerWorkspaceManifest(currentRaw, currentRef);
const { expectedRemoteRef, publishAcceptedManifest } = acceptedWorkspacePublisher(
current,
currentRef,
);
const transferPaths = workerWorkspaceTransferPaths(current, base);
const transferPathSet = new Set(transferPaths);
if (transferPaths.length > 0) {
await fs.writeFile(transferListPath, Buffer.from(`${transferPaths.join("\0")}\0`), {
mode: 0o600,
});
const resultTransfer = await runBoundedInboundRsync({
const changed = currentRef !== request.baseManifestRef;
let current = base;
let currentRaw = baseRaw;
if (changed) {
const currentDigest = currentRef.slice("sha256:".length);
const currentManifestPath = path.join(manifestRoot, `${currentDigest}.json`);
const currentManifestTransfer = await runBoundedInboundRsync({
prepared,
argv: (rsyncSsh) => [
"rsync",
"--archive",
"--no-recursive",
"--checksum",
`--max-size=${MAX_RECONCILIATION_FILE_BYTES}`,
`--bwlimit=${INBOUND_RSYNC_BW_LIMIT_KIB}`,
"--from0",
`--files-from=${transferListPath}`,
"-e",
rsyncSsh,
"--",
`${prepared.scpTarget}:${request.remoteWorkspaceDir}/`,
`${stagingRoot}/`,
`${prepared.scpTarget}:.openclaw-worker/manifests/${currentDigest}.json`,
currentManifestPath,
],
destinationRoot: stagingRoot,
entryLimit: MAX_RECONCILIATION_ENTRIES * 2,
totalByteLimit: MAX_RECONCILIATION_TOTAL_BYTES,
destinationRoot: manifestRoot,
entryLimit: 1,
totalByteLimit: MAX_RECONCILIATION_FILE_BYTES,
});
if (!success(resultTransfer)) {
throw workspaceSyncError(resultTransfer);
if (!success(currentManifestTransfer)) {
throw workspaceSyncError(currentManifestTransfer);
}
currentRaw = await readTransferredManifest(currentManifestPath);
current = parseWorkerWorkspaceManifest(currentRaw, currentRef);
}
const { expectedRemoteRef, publishAcceptedManifest } = acceptedWorkspacePublisher(
current,
currentRef,
);
if (changed) {
const transferPaths = workerWorkspaceTransferPaths(current, base);
const transferPathSet = new Set(transferPaths);
if (transferPaths.length > 0) {
await fs.writeFile(transferListPath, Buffer.from(`${transferPaths.join("\0")}\0`), {
mode: 0o600,
});
const resultTransfer = await runBoundedInboundRsync({
prepared,
argv: (rsyncSsh) => [
"rsync",
"--archive",
"--checksum",
`--max-size=${MAX_RECONCILIATION_FILE_BYTES}`,
`--bwlimit=${INBOUND_RSYNC_BW_LIMIT_KIB}`,
"--from0",
`--files-from=${transferListPath}`,
"-e",
rsyncSsh,
"--",
`${prepared.scpTarget}:${request.remoteWorkspaceDir}/`,
`${stagingRoot}/`,
],
destinationRoot: stagingRoot,
entryLimit: MAX_RECONCILIATION_ENTRIES * 2,
totalByteLimit: MAX_RECONCILIATION_TOTAL_BYTES,
});
if (!success(resultTransfer)) {
throw workspaceSyncError(resultTransfer);
}
}
await assertWorkspaceMatchesManifest({
root: stagingRoot,
manifest: current,
entries: current.entries.filter((entry) => transferPathSet.has(entry.path)),
});
}
await assertWorkspaceMatchesManifest({
root: stagingRoot,
manifest: current,
entries: current.entries.filter((entry) => transferPathSet.has(entry.path)),
});
// Catch additions, deletions, and writes that raced the inbound transfer.
// Stop performs this check once more after local acceptance, directly
// before destroying the remote owner.
await verifyStable(currentRef);
const stagedResult = request.stagedResult
? await workerWorkspaceResultStaging.prepareRequestedWorkerWorkspaceResult({
request,
stagingRoot,
currentManifestRef: currentRef,
baseManifestRaw: baseRaw,
currentManifestRaw: currentRaw,
publishAcceptedManifest,
})
const preparedStagedResult = request.stagedResult
? await runLocalReconciliation(
async () =>
await workerWorkspaceResultStaging.prepareRequestedWorkerWorkspaceResult({
request,
stagingRoot,
currentManifestRef: currentRef,
baseManifestRaw: baseRaw,
currentManifestRaw: currentRaw,
publishAcceptedManifest,
}),
)
: undefined;
const stagedResult = preparedStagedResult
? {
...preparedStagedResult,
applyPreparedStagedResult: async () =>
await runLocalReconciliation(
async () => await preparedStagedResult.applyPreparedStagedResult(),
),
verifyLocalStable: async () =>
await runLocalReconciliation(
async () => await preparedStagedResult.verifyLocalStable(),
),
}
: undefined;
let appliedWorkspaceResult: WorkerWorkspaceApplyResult | undefined;
if (!stagedResult) {
appliedWorkspaceResult = await applyStagedWorkerWorkspace({
root: request.localPath,
stagingRoot,
baseManifestRef: request.baseManifestRef,
currentManifestRef: currentRef,
base,
current,
journal: request.journal,
publishAcceptedManifest,
});
appliedWorkspaceResult = await runLocalReconciliation(
async () =>
await applyStagedWorkerWorkspace({
root: request.localPath,
stagingRoot,
baseManifestRef: request.baseManifestRef,
currentManifestRef: currentRef,
base,
current,
journal: request.journal,
publishAcceptedManifest,
}),
);
}
return {
get manifestRef() {
return expectedRemoteRef();
},
changed: true,
changed,
verifyStable: async () => await verifyStable(expectedRemoteRef()),
verifyLocalStable: async () =>
appliedWorkspaceResult
? await appliedWorkspaceResult.verifyLocalStable()
: await assertWorkspaceResultStable({ root: request.localPath, base, current }),
...(appliedWorkspaceResult
? { getAppliedWorkspaceResult: () => appliedWorkspaceResult }
: {}),
await runLocalReconciliation(
async () =>
await (appliedWorkspaceResult?.verifyLocalStable() ??
assertWorkspaceResultStable({ root: request.localPath, base, current })),
),
getAppliedWorkspaceResult: () => appliedWorkspaceResult,
...stagedResult,
};
} finally {
@@ -717,6 +630,28 @@ export function createWorkerWorkspaceActions(
}
};
const reconcileWorkspaceImpl = async (
request: WorkerWorkspaceReconcileRequest,
): Promise<WorkerWorkspaceReconcileResult> => {
const metrics = createWorkspaceReconcileMetrics();
const startedAt = performance.now();
const report = (outcome: "failed" | "succeeded") => {
workspaceSyncLog.debug("worker workspace reconcile completed", {
outcome,
durationMs: performance.now() - startedAt,
...metrics,
});
};
try {
const reconciliation = await reconcileWorkspaceRun(request, metrics);
registerWorkspaceReconcileReporter(reconciliation, report);
return reconciliation;
} catch (error) {
report("failed");
throw error;
}
};
return {
quiesceWorkspace,
reconcileWorkspace: (request) => track(reconcileWorkspaceImpl(request)),