mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* perf(nodes): reuse warm workers so node turns start as fast as local ones Retain settled workers for two minutes with at most two idle children per node. Negotiate node-worker-idle-retention-v1 across Gateway, node, and bundle; fresh turn admission and credentials remain mandatory. Protect background work and preserve durable process cleanup and reconciliation. Expose reclaimable idle capacity to placement admission and the session picker. Consolidate lifecycle helpers under the supervisor and use the shared capacity parser. Document the bounded process-count and memory tradeoff; include lifecycle, negotiation, and picker regression coverage. fix(nodes): retry failed idle worker cleanup Rearm the supervisor idle timer after a transient physical cleanup failure while retaining the durable slot and retirement fence. Cover automatic expiry and idle-limit eviction alongside explicit cleanup with a fake clock. Fold the cancellation forwarding wrapper into its owner and remove the immediately repeated admission abort check. Preserve the existing authority, settlement, and shutdown guards. fix(nodes): synchronize idle capacity protocol models Regenerate the Swift worker-slot model for the optional reclaimableIdle field. The schema and named capability contract remain unchanged. Inline the single-caller prepared workspace custody wrapper in the supervisor and move its four cases to the public launch entry point. Preserve exact acquisition, abort, shutdown, and release ordering while keeping the complete branch production delta negative. chore(nodes): prune stale worker assertion allowance Remove the exact one-entry assertion baseline for worker-command.runtime.ts after the unsafe assertion was eliminated. Keep the scanner and all unrelated allowances unchanged. fix(nodes): keep supervisor dependencies on protocol owners Import protocol parsers and types directly from their canonical leaf, including the QA fixture, and remove the redundant supervisor-control re-exports. This breaks the dependency cycle without duplicating the wire contract. Keep the runner cancellation mock and shutdown callback asynchronous with the real Promise<void> contract, including teardown restoration. fix(nodes): complete asynchronous cleanup contracts Join cancellation cleanup in callers and tests while preserving parallel shutdown settlement and expected failure identity. Exclude absent cleanup owners before aggregation and keep stale-connection rejection unchanged. Use immutable worker input, type-only journal imports, stable sorted idle projections, and receiver-bound fault injection. Remove redundant descriptor bookkeeping and finish the test lint cleanup without changing limits or suppressions. * test(nodes): disambiguate inventory fixtures
372 lines
15 KiB
TypeScript
372 lines
15 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import * as exec from "../process/exec.js";
|
|
import {
|
|
disposeWorkerGitHubEnvironment,
|
|
prepareWorkerGitHubEnvironment,
|
|
} from "./github-binding.runtime.js";
|
|
|
|
const { warn, inspectPathPermissions } = vi.hoisted(() => ({
|
|
warn: vi.fn(),
|
|
inspectPathPermissions: vi.fn(),
|
|
}));
|
|
vi.mock("@openclaw/fs-safe/permissions", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@openclaw/fs-safe/permissions")>();
|
|
inspectPathPermissions.mockImplementation(actual.inspectPathPermissions);
|
|
return { ...actual, inspectPathPermissions };
|
|
});
|
|
vi.mock("../logging/subsystem.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../logging/subsystem.js")>();
|
|
return {
|
|
...actual,
|
|
createSubsystemLogger: (name: string) => ({ ...actual.createSubsystemLogger(name), warn }),
|
|
};
|
|
});
|
|
|
|
describe("prepareWorkerGitHubEnvironment", () => {
|
|
const remoteUrl = "https://github.com/openclaw/worker-fixture.git";
|
|
const binding = {
|
|
token: "worker-checkout-synthetic-token",
|
|
login: "worker-fixture",
|
|
branch: "openclaw/session-fixture",
|
|
remoteUrl,
|
|
gitAuthor: { name: "Worker Fixture", email: "worker@openclaw.invalid" },
|
|
};
|
|
const filename = " reconciled file.txt";
|
|
const pushedContent = "earlier worker content\n";
|
|
let root: string;
|
|
let cwd: string;
|
|
let origin: string;
|
|
let initialHead: string;
|
|
|
|
async function git(directory: string, ...args: string[]) {
|
|
const result = await exec.runExec("git", ["-C", directory, ...args], {
|
|
timeoutMs: 10_000,
|
|
logOutput: false,
|
|
});
|
|
return result.stdout;
|
|
}
|
|
|
|
async function commit(directory: string, message: string) {
|
|
await git(directory, "add", ".");
|
|
await git(
|
|
directory,
|
|
"-c",
|
|
"user.name=Worker Fixture",
|
|
"-c",
|
|
"user.email=worker@openclaw.invalid",
|
|
"commit",
|
|
"--quiet",
|
|
"--no-gpg-sign",
|
|
"--allow-empty",
|
|
"-m",
|
|
message,
|
|
);
|
|
return (await git(directory, "rev-parse", "HEAD")).trim();
|
|
}
|
|
|
|
async function publishEarlierTurn() {
|
|
const seed = path.join(root, "earlier-worker");
|
|
await git(root, "clone", "--quiet", "--branch", "main", origin, seed);
|
|
await fs.writeFile(path.join(seed, filename), pushedContent);
|
|
const remoteHead = await commit(seed, "Earlier turn");
|
|
await git(seed, "push", "--quiet", "origin", `HEAD:refs/heads/${binding.branch}`);
|
|
return remoteHead;
|
|
}
|
|
|
|
const prepare = () =>
|
|
prepareWorkerGitHubEnvironment({
|
|
binding,
|
|
stateDir: path.join(root, "state"),
|
|
turnId: "turn",
|
|
cwd,
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "worker-checkout-")));
|
|
cwd = path.join(root, "worker");
|
|
origin = pathToFileURL(path.join(root, "origin.git")).href;
|
|
vi.stubEnv("GIT_CONFIG_GLOBAL", path.join(root, "gitconfig"));
|
|
vi.stubEnv("GIT_CONFIG_NOSYSTEM", "1");
|
|
vi.stubEnv("GIT_CONFIG_COUNT", "0");
|
|
await fs.mkdir(cwd);
|
|
await git(root, "init", "--quiet", "--bare", "origin.git");
|
|
// The binding rewrites origin to the verified GitHub URL; route it to the local bare repo.
|
|
await git(root, "config", "--global", `url.${origin}.insteadOf`, remoteUrl);
|
|
await git(cwd, "init", "--quiet", `--initial-branch=${binding.branch}`);
|
|
initialHead = await commit(cwd, "Initial commit");
|
|
await git(cwd, "remote", "add", "origin", origin);
|
|
await git(cwd, "push", "--quiet", "origin", "HEAD:refs/heads/main");
|
|
warn.mockClear();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
it.each([
|
|
{ scenario: "reconciled identical content", content: pushedContent, porcelain: "" },
|
|
{ scenario: "reconciled local edits", content: "local edit\n", porcelain: ` M ${filename}\0` },
|
|
{ scenario: "a missing pushed file", content: undefined, porcelain: "" },
|
|
])(
|
|
"fast-forwards $scenario without losing working-tree bytes",
|
|
async ({ content, porcelain }) => {
|
|
const remoteHead = await publishEarlierTurn();
|
|
if (content !== undefined) {
|
|
await fs.writeFile(path.join(cwd, filename), content);
|
|
}
|
|
|
|
await prepare();
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(remoteHead);
|
|
expect((await git(cwd, "rev-parse", `refs/heads/${binding.branch}`)).trim()).toBe(remoteHead);
|
|
expect((await git(cwd, "rev-parse", `refs/remotes/origin/${binding.branch}`)).trim()).toBe(
|
|
remoteHead,
|
|
);
|
|
expect(await git(cwd, "status", "--porcelain", "-z")).toBe(porcelain);
|
|
expect(await fs.readFile(path.join(cwd, filename), "utf8")).toBe(content ?? pushedContent);
|
|
expect(
|
|
(await git(cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")).trim(),
|
|
).toBe(`origin/${binding.branch}`);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("leaves diverged local history and files untouched with one warning", async () => {
|
|
const remoteHead = await publishEarlierTurn();
|
|
await fs.writeFile(path.join(cwd, "local.txt"), "local commit\n");
|
|
const localHead = await commit(cwd, "Local turn");
|
|
await fs.writeFile(path.join(cwd, filename), "local untracked content\n");
|
|
const before = await git(cwd, "status", "--porcelain");
|
|
|
|
await prepare();
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(localHead);
|
|
expect(await git(cwd, "status", "--porcelain")).toBe(before);
|
|
expect(await fs.readFile(path.join(cwd, filename), "utf8")).toBe("local untracked content\n");
|
|
expect(warn).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(binding.branch));
|
|
expect(warn.mock.lastCall?.[0]).toContain(localHead.slice(0, 7));
|
|
expect(warn.mock.lastCall?.[0]).toContain(remoteHead.slice(0, 7));
|
|
});
|
|
|
|
it("keeps the session's own tracked-file deletion while materializing new pushed files", async () => {
|
|
// A file tracked since the initial commit that the previous turn deleted locally.
|
|
const keepDeleted = "keep-deleted.txt";
|
|
await fs.writeFile(path.join(cwd, keepDeleted), "to be deleted\n");
|
|
initialHead = await commit(cwd, "Track a file that will be deleted");
|
|
await git(cwd, "push", "--quiet", "--force", "origin", "HEAD:refs/heads/main");
|
|
const remoteHead = await publishEarlierTurn();
|
|
await fs.rm(path.join(cwd, keepDeleted));
|
|
|
|
await prepare();
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(remoteHead);
|
|
await expect(fs.access(path.join(cwd, keepDeleted))).rejects.toThrow();
|
|
expect(await git(cwd, "status", "--porcelain", "-z")).toBe(` D ${keepDeleted}\0`);
|
|
expect(await fs.readFile(path.join(cwd, filename), "utf8")).toBe(pushedContent);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never starts the credentialed fetch for a fenced turn", async () => {
|
|
const remoteHead = await publishEarlierTurn();
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("worker fenced: owner-epoch-mismatch"));
|
|
|
|
await prepareWorkerGitHubEnvironment({
|
|
binding,
|
|
stateDir: path.join(root, "state"),
|
|
turnId: "turn",
|
|
cwd,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(initialHead);
|
|
expect(remoteHead).not.toBe(initialHead);
|
|
await expect(git(cwd, "rev-parse", "--verify", "FETCH_HEAD")).rejects.toThrow();
|
|
});
|
|
|
|
it("rejects the fetch when the turn is fenced while it is being spawned", async () => {
|
|
const remoteHead = await publishEarlierTurn();
|
|
const controller = new AbortController();
|
|
const runCommand = exec.runCommandWithTimeout;
|
|
let fetchAttempts = 0;
|
|
vi.spyOn(exec, "runCommandWithTimeout").mockImplementation(async (argv, options) => {
|
|
if (argv.includes("fetch")) {
|
|
fetchAttempts += 1;
|
|
// The claim closes after the pre-check and before the process starts.
|
|
controller.abort(new Error("worker fenced: credential-replaced"));
|
|
}
|
|
return await runCommand(argv, options);
|
|
});
|
|
|
|
await prepareWorkerGitHubEnvironment({
|
|
binding,
|
|
stateDir: path.join(root, "state"),
|
|
turnId: "turn",
|
|
cwd,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
expect(fetchAttempts).toBe(1);
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(initialHead);
|
|
expect(remoteHead).not.toBe(initialHead);
|
|
await expect(git(cwd, "rev-parse", "--verify", "FETCH_HEAD")).rejects.toThrow();
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("GitHub checkout binding failed"));
|
|
});
|
|
|
|
it("disables the binding before any token use when a Windows profile is not owner-only", async () => {
|
|
const remoteHead = await publishEarlierTurn();
|
|
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
|
inspectPathPermissions.mockResolvedValueOnce({
|
|
ok: true,
|
|
source: "windows-acl",
|
|
ownerTrusted: false,
|
|
groupReadable: true,
|
|
worldReadable: false,
|
|
groupWritable: false,
|
|
worldWritable: false,
|
|
} as never);
|
|
try {
|
|
await expect(prepare()).resolves.toBeUndefined();
|
|
} finally {
|
|
Object.defineProperty(process, "platform", platform);
|
|
}
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(initialHead);
|
|
expect(remoteHead).not.toBe(initialHead);
|
|
await expect(git(cwd, "rev-parse", "--verify", "FETCH_HEAD")).rejects.toThrow();
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("profile is not owner-only"));
|
|
});
|
|
|
|
it("never fetches or fast-forwards without a verified GitHub origin", async () => {
|
|
const remoteHead = await publishEarlierTurn();
|
|
const { remoteUrl: _omitted, ...withoutRemote } = binding;
|
|
|
|
await prepareWorkerGitHubEnvironment({
|
|
binding: withoutRemote,
|
|
stateDir: path.join(root, "state"),
|
|
turnId: "turn",
|
|
cwd,
|
|
});
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(initialHead);
|
|
expect(remoteHead).not.toBe(initialHead);
|
|
await expect(git(cwd, "rev-parse", "--verify", "FETCH_HEAD")).rejects.toThrow();
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("silently leaves the checkout alone when the session branch does not exist on origin", async () => {
|
|
await fs.writeFile(path.join(cwd, filename), "first turn\n");
|
|
const before = await git(cwd, "status", "--porcelain");
|
|
|
|
await prepare();
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(initialHead);
|
|
expect(await git(cwd, "status", "--porcelain")).toBe(before);
|
|
expect(await fs.readFile(path.join(cwd, filename), "utf8")).toBe("first turn\n");
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches with the private turn token and managed identity environment", async () => {
|
|
const remoteHead = await publishEarlierTurn();
|
|
vi.stubEnv("GH_TOKEN", "inherited-synthetic-token");
|
|
vi.stubEnv("GITHUB_TOKEN", "inherited-synthetic-token");
|
|
const runner = vi.spyOn(exec, "runCommandWithTimeout");
|
|
|
|
const prepared = await prepare();
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(remoteHead);
|
|
expect(prepared?.localIdentityEnv).toMatchObject({
|
|
GH_CONFIG_DIR: expect.stringContaining(path.join(root, "state", "github-profiles")),
|
|
GIT_CONFIG_COUNT: "4",
|
|
GIT_CONFIG_KEY_0: "credential.helper",
|
|
GIT_CONFIG_VALUE_0: "",
|
|
GIT_CONFIG_KEY_1: "credential.helper",
|
|
GIT_CONFIG_VALUE_1: "!gh auth git-credential",
|
|
GIT_AUTHOR_NAME: binding.gitAuthor.name,
|
|
GIT_AUTHOR_EMAIL: binding.gitAuthor.email,
|
|
});
|
|
const fetchCall = runner.mock.calls.find(([args]) => args[3] === "fetch");
|
|
expect(fetchCall?.[0]).toEqual([
|
|
"git",
|
|
"-C",
|
|
cwd,
|
|
"fetch",
|
|
"--quiet",
|
|
"origin",
|
|
binding.branch,
|
|
]);
|
|
const options = fetchCall?.[1];
|
|
if (typeof options !== "object") {
|
|
throw new Error("Expected options for Git fetch");
|
|
}
|
|
expect(options.timeoutMs).toBe(60_000);
|
|
// Project only fixture-owned keys so a failed assertion cannot dump the host environment.
|
|
const expectedEnv = {
|
|
...prepared?.localIdentityEnv,
|
|
GH_TOKEN: binding.token,
|
|
GITHUB_TOKEN: "",
|
|
};
|
|
const actualEnv = Object.fromEntries(
|
|
Object.keys(expectedEnv).map((key) => [key, options.baseEnv?.[key]]),
|
|
);
|
|
expect(actualEnv).toEqual(expectedEnv);
|
|
expect(JSON.stringify(prepared)).not.toContain(binding.token);
|
|
expect(process.env.GH_TOKEN).toBe("inherited-synthetic-token");
|
|
expect(process.env.GITHUB_TOKEN).toBe("inherited-synthetic-token");
|
|
const profileDir = prepared?.localIdentityEnv?.GH_CONFIG_DIR;
|
|
if (!profileDir) {
|
|
throw new Error("Expected a turn-owned GitHub profile");
|
|
}
|
|
await disposeWorkerGitHubEnvironment(path.join(root, "state"), "turn");
|
|
await expect(fs.access(profileDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("does not remove a newer profile when the previous turn finishes cleanup", async () => {
|
|
const previous = await prepare();
|
|
const current = await prepareWorkerGitHubEnvironment({
|
|
binding,
|
|
stateDir: path.join(root, "state"),
|
|
turnId: "next-turn",
|
|
cwd,
|
|
});
|
|
const previousProfile = previous?.localIdentityEnv.GH_CONFIG_DIR;
|
|
const currentProfile = current?.localIdentityEnv.GH_CONFIG_DIR;
|
|
expect(previousProfile).toBeTruthy();
|
|
expect(currentProfile).toBeTruthy();
|
|
expect(currentProfile).not.toBe(previousProfile);
|
|
if (!previousProfile || !currentProfile) {
|
|
throw new Error("Expected both turns' GitHub profiles");
|
|
}
|
|
expect(await fs.readFile(path.join(previousProfile, "hosts.yml"), "utf8")).toContain(
|
|
binding.token,
|
|
);
|
|
await disposeWorkerGitHubEnvironment(path.join(root, "state"), "turn");
|
|
expect(await fs.readFile(path.join(currentProfile, "hosts.yml"), "utf8")).toContain(
|
|
binding.token,
|
|
);
|
|
await disposeWorkerGitHubEnvironment(path.join(root, "state"), "next-turn");
|
|
await expect(fs.access(currentProfile)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("warns and continues without changing local files when origin cannot be fetched", async () => {
|
|
await fs.writeFile(path.join(cwd, filename), "unpublished work\n");
|
|
await fs.rm(path.join(root, "origin.git"), { recursive: true });
|
|
const before = await git(cwd, "status", "--porcelain");
|
|
|
|
expect(await prepare()).toMatchObject({ managedLocalIdentity: true });
|
|
|
|
expect((await git(cwd, "rev-parse", "HEAD")).trim()).toBe(initialHead);
|
|
expect(await git(cwd, "status", "--porcelain")).toBe(before);
|
|
expect(await fs.readFile(path.join(cwd, filename), "utf8")).toBe("unpublished work\n");
|
|
expect(warn).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("git fetch failed"));
|
|
expect(warn.mock.lastCall?.[0]).not.toContain(binding.token);
|
|
expect(warn.mock.lastCall?.[0]).not.toContain(origin);
|
|
});
|
|
});
|