mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(gateway): prevent control-plane polling stalls (#124891)
* fix(gateway): avoid repeated control-plane scans * fix(tooling): allow concurrent worktree validation * fix(ci): refresh protocol and runner inputs * perf(ui): defer hidden session refreshes * fix(ui): resolve session refresh lint failure * fix(update): preserve pre-cache update channel * fix(update): normalize cached update channel * fix(gateway): lifecycle-cache update install identity * fix(ui): preserve manual history retry after layout scroll * test(codex): repair side-question tool schema fixture
This commit is contained in:
committed by
GitHub
parent
d24cb13320
commit
bf9b25ab7e
@@ -21,7 +21,6 @@ import {
|
||||
changedCheckLocalDependenciesReady,
|
||||
changedCheckRequiresRemote,
|
||||
cleanupCorepackPnpmShimDir,
|
||||
createChangedCheckChildEnv,
|
||||
createChangedCheckPlan,
|
||||
createPnpmManagedCommand,
|
||||
createTargetedCoreLintCommand,
|
||||
@@ -740,9 +739,6 @@ describe("scripts/changed-lanes", () => {
|
||||
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core:test");
|
||||
expect(plan.commands.find((command) => command.args[0] === "tsgo:core")?.env).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_SPARSE_SKIP: "1",
|
||||
});
|
||||
expect(plan.commands.find((command) => command.name === "lint core changed file")).toEqual({
|
||||
@@ -756,9 +752,6 @@ describe("scripts/changed-lanes", () => {
|
||||
],
|
||||
env: {
|
||||
PATH: "/usr/bin",
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1074,23 +1067,11 @@ describe("scripts/changed-lanes", () => {
|
||||
|
||||
expect(plan.commands.find((command) => command.args[0] === "tsgo:core")?.env).toEqual({
|
||||
OPENCLAW_LOCAL_CHECK: "1",
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_SPARSE_SKIP: "1",
|
||||
PATH: "/usr/bin",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks changed-check children as covered by the parent heavy-check lock", () => {
|
||||
expect(createChangedCheckChildEnv({ PATH: "/usr/bin" })).toEqual({
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
PATH: "/usr/bin",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs CI changed-check children through Corepack pnpm", () => {
|
||||
const command = createPnpmManagedCommand(
|
||||
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
|
||||
@@ -1331,36 +1312,6 @@ describe("scripts/changed-lanes", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("runs changed-check lint lanes under the parent heavy-check lock", () => {
|
||||
const result = detectChangedLanes(["extensions/lmstudio/src/api.ts"]);
|
||||
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
||||
const lintCommand = plan.commands.find(
|
||||
(command) => command.name === "lint extension changed file",
|
||||
);
|
||||
|
||||
expect(lintCommand?.env).toEqual({
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
PATH: "/usr/bin",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs changed-check app tests under the parent heavy-check lock", () => {
|
||||
const result = detectChangedLanes([
|
||||
"apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
]);
|
||||
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
||||
const testCommand = plan.commands.find((command) => command.args[0] === "test:macos:ci");
|
||||
|
||||
expect(testCommand?.env).toEqual({
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
PATH: "/usr/bin",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "routes core test-only changes to core test lanes only",
|
||||
|
||||
@@ -110,9 +110,6 @@ describe("scripts/ci-run-node-test-shard.mts", () => {
|
||||
expect(childEnv.IGNORED).toBeUndefined();
|
||||
expect(childEnv.OPENCLAW_VITEST_SHARD_NAME).toBe("g");
|
||||
expect(childEnv.OPENCLAW_TEST_PROJECTS_PARALLEL).toBe("1");
|
||||
expect(childEnv.OPENCLAW_OXLINT_SKIP_LOCK).toBe("1");
|
||||
expect(childEnv.OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD).toBe("1");
|
||||
expect(childEnv.OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD).toBe("1");
|
||||
expect(childEnv.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toBe(
|
||||
path.join(scratchDir, "vitest-cache-3"),
|
||||
);
|
||||
|
||||
+9
-200
@@ -1,20 +1,16 @@
|
||||
// Local Heavy Check Runtime tests cover local heavy check runtime script behavior.
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
// Local Check Runtime tests cover local check runtime script behavior.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireLocalHeavyCheckLockSync,
|
||||
applyLocalOxlintPolicy,
|
||||
applyLocalTsgoPolicy,
|
||||
ensureRepoToolNodeModulesLink,
|
||||
resolveLocalHeavyCheckEnv,
|
||||
resolveLocalCheckEnv,
|
||||
resolveRepoToolBinPath,
|
||||
shouldAcquireLocalHeavyCheckLockForOxlint,
|
||||
shouldAcquireLocalHeavyCheckLockForTsgo,
|
||||
withLocalHeavyCheckLockHeld,
|
||||
} from "../../scripts/lib/local-heavy-check-runtime.mts";
|
||||
} from "../../scripts/lib/local-check-runtime.mts";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
@@ -43,19 +39,7 @@ function makeEnv(overrides: Record<string, string | undefined> = {}) {
|
||||
return env;
|
||||
}
|
||||
|
||||
describe("local-heavy-check-runtime", () => {
|
||||
it("marks every nested heavy-check wrapper as covered by the parent lock", () => {
|
||||
const baseEnv = { BASE: "1" };
|
||||
|
||||
expect(withLocalHeavyCheckLockHeld(baseEnv)).toEqual({
|
||||
BASE: "1",
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
});
|
||||
expect(baseEnv).toEqual({ BASE: "1" });
|
||||
});
|
||||
|
||||
describe("local-check-runtime", () => {
|
||||
it("resolves repo tools from the primary checkout for dependency-less worktrees", () => {
|
||||
const primaryRoot = createTempDir("openclaw-primary-checkout-");
|
||||
const cwd = path.join(primaryRoot, ".codex", "worktrees", "task", "openclaw");
|
||||
@@ -124,12 +108,12 @@ describe("local-heavy-check-runtime", () => {
|
||||
expect(fs.lstatSync(localNodeModules).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
|
||||
it("reenables local heavy-check policy for local wrapper entrypoints", () => {
|
||||
expect(resolveLocalHeavyCheckEnv({ OPENCLAW_LOCAL_CHECK: "0", PATH: "/usr/bin" })).toEqual({
|
||||
it("reenables local check policy for local wrapper entrypoints", () => {
|
||||
expect(resolveLocalCheckEnv({ OPENCLAW_LOCAL_CHECK: "0", PATH: "/usr/bin" })).toEqual({
|
||||
OPENCLAW_LOCAL_CHECK: "1",
|
||||
PATH: "/usr/bin",
|
||||
});
|
||||
expect(resolveLocalHeavyCheckEnv({ OPENCLAW_LOCAL_CHECK: "false", PATH: "/usr/bin" })).toEqual({
|
||||
expect(resolveLocalCheckEnv({ OPENCLAW_LOCAL_CHECK: "false", PATH: "/usr/bin" })).toEqual({
|
||||
OPENCLAW_LOCAL_CHECK: "1",
|
||||
PATH: "/usr/bin",
|
||||
});
|
||||
@@ -137,7 +121,7 @@ describe("local-heavy-check-runtime", () => {
|
||||
|
||||
it("preserves local-check disablement in CI", () => {
|
||||
expect(
|
||||
resolveLocalHeavyCheckEnv({
|
||||
resolveLocalCheckEnv({
|
||||
CI: "true",
|
||||
OPENCLAW_LOCAL_CHECK: "0",
|
||||
PATH: "/usr/bin",
|
||||
@@ -306,29 +290,6 @@ describe("local-heavy-check-runtime", () => {
|
||||
expect(env.GOMEMLIMIT).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips the heavy-check lock for tsgo metadata commands", () => {
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--help"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["-h"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--version"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["-v"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--init"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--showConfig"])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the heavy-check lock for real tsgo runs", () => {
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo([])).toBe(true);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForTsgo(["--extendedDiagnostics"])).toBe(true);
|
||||
});
|
||||
|
||||
it("allows forcing the tsgo lock back on", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLockForTsgo(
|
||||
["--help"],
|
||||
makeEnv({ OPENCLAW_TSGO_FORCE_LOCK: "1" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes local oxlint runs onto one thread on constrained hosts", () => {
|
||||
const { args, env } = applyLocalOxlintPolicy([], makeEnv(), CONSTRAINED_HOST);
|
||||
|
||||
@@ -398,7 +359,6 @@ describe("local-heavy-check-runtime", () => {
|
||||
CAPTURE_PATH: capturePath,
|
||||
OPENCLAW_LOCAL_CHECK: "1",
|
||||
OPENCLAW_LOCAL_CHECK_MODE: "throttled",
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_OXLINT_SKIP_PREPARE: "1",
|
||||
};
|
||||
delete env.GOMAXPROCS;
|
||||
@@ -463,155 +423,4 @@ describe("local-heavy-check-runtime", () => {
|
||||
expect(args).not.toContain("stylish");
|
||||
},
|
||||
);
|
||||
|
||||
it("skips the heavy-check lock for explicit oxlint file targets", () => {
|
||||
const cwd = createTempDir("openclaw-oxlint-lock-skip-");
|
||||
const target = path.join(cwd, "sample.ts");
|
||||
fs.writeFileSync(target, "export const ok = true;\n", "utf8");
|
||||
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLockForOxlint(["--type-aware", "--", "sample.ts"], { cwd }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips the heavy-check lock for oxlint metadata commands", () => {
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--help"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["-h"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--version"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["-V"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--rules"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--print-config"])).toBe(false);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--init"])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the heavy-check lock for directory targets and broad oxlint runs", () => {
|
||||
const cwd = createTempDir("openclaw-oxlint-lock-keep-");
|
||||
fs.mkdirSync(path.join(cwd, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, "src", "sample.ts"), "export const ok = true;\n", "utf8");
|
||||
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--type-aware", "--", "src"], { cwd })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldAcquireLocalHeavyCheckLockForOxlint(["--type-aware"], { cwd })).toBe(true);
|
||||
});
|
||||
|
||||
it("allows forcing the oxlint lock back on", () => {
|
||||
const cwd = createTempDir("openclaw-oxlint-lock-force-");
|
||||
fs.writeFileSync(path.join(cwd, "sample.ts"), "export const ok = true;\n", "utf8");
|
||||
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLockForOxlint(["--type-aware", "--", "sample.ts"], {
|
||||
cwd,
|
||||
env: makeEnv({ OPENCLAW_OXLINT_FORCE_LOCK: "1" }),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reclaims stale local heavy-check locks from dead pids", () => {
|
||||
const cwd = createTempDir("openclaw-local-heavy-check-");
|
||||
const commonDir = path.join(cwd, ".git");
|
||||
const lockDir = path.join(commonDir, "openclaw-local-checks", "heavy-check.lock");
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(lockDir, "owner.json"),
|
||||
`${JSON.stringify({
|
||||
pid: 999_999_999,
|
||||
tool: "tsgo",
|
||||
cwd,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const release = acquireLocalHeavyCheckLockSync({
|
||||
cwd,
|
||||
env: makeEnv(),
|
||||
toolName: "oxlint",
|
||||
});
|
||||
|
||||
const owner = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
|
||||
expect(owner.pid).toBe(process.pid);
|
||||
expect(owner.tool).toBe("oxlint");
|
||||
|
||||
release();
|
||||
expect(fs.existsSync(lockDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a worktree-local heavy-check lock when explicitly requested", () => {
|
||||
const repoRoot = createTempDir("openclaw-local-heavy-check-worktree-");
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
const cwd = path.join(repoRoot, "nested", "tooling");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const commonLockDir = path.join(repoRoot, ".git", "openclaw-local-checks", "heavy-check.lock");
|
||||
const worktreeLockDir = path.join(
|
||||
repoRoot,
|
||||
".artifacts",
|
||||
"openclaw-local-checks",
|
||||
"heavy-check.lock",
|
||||
);
|
||||
const nestedLockDir = path.join(cwd, ".artifacts", "openclaw-local-checks", "heavy-check.lock");
|
||||
|
||||
const release = acquireLocalHeavyCheckLockSync({
|
||||
cwd,
|
||||
env: makeEnv({ OPENCLAW_HEAVY_CHECK_LOCK_SCOPE: "worktree" }),
|
||||
toolName: "check:changed",
|
||||
});
|
||||
|
||||
const owner = JSON.parse(fs.readFileSync(path.join(worktreeLockDir, "owner.json"), "utf8"));
|
||||
expect(owner.tool).toBe("check:changed");
|
||||
expect(fs.existsSync(worktreeLockDir)).toBe(true);
|
||||
expect(fs.existsSync(commonLockDir)).toBe(false);
|
||||
expect(fs.existsSync(nestedLockDir)).toBe(false);
|
||||
|
||||
release();
|
||||
expect(fs.existsSync(worktreeLockDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed heavy-check lock timing env values", () => {
|
||||
const cwd = createTempDir("openclaw-local-heavy-check-malformed-env-");
|
||||
|
||||
expect(() =>
|
||||
acquireLocalHeavyCheckLockSync({
|
||||
cwd,
|
||||
env: makeEnv({ OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS: "10ms" }),
|
||||
toolName: "oxlint",
|
||||
}),
|
||||
).toThrow("OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS must be a positive integer; got: 10ms");
|
||||
expect(() =>
|
||||
acquireLocalHeavyCheckLockSync({
|
||||
cwd,
|
||||
env: makeEnv({ OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS: "0" }),
|
||||
toolName: "oxlint",
|
||||
}),
|
||||
).toThrow("OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS must be a positive integer; got: 0");
|
||||
});
|
||||
|
||||
it("cleans up stale legacy test locks when acquiring the shared heavy-check lock", () => {
|
||||
const cwd = createTempDir("openclaw-local-heavy-check-legacy-");
|
||||
const commonDir = path.join(cwd, ".git");
|
||||
const locksDir = path.join(commonDir, "openclaw-local-checks");
|
||||
const legacyLockDir = path.join(locksDir, "test.lock");
|
||||
const heavyCheckLockDir = path.join(locksDir, "heavy-check.lock");
|
||||
fs.mkdirSync(legacyLockDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(legacyLockDir, "owner.json"),
|
||||
`${JSON.stringify({
|
||||
pid: 999_999_999,
|
||||
tool: "test",
|
||||
cwd,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const release = acquireLocalHeavyCheckLockSync({
|
||||
cwd,
|
||||
env: makeEnv(),
|
||||
toolName: "oxlint",
|
||||
});
|
||||
|
||||
expect(fs.existsSync(legacyLockDir)).toBe(false);
|
||||
expect(fs.existsSync(heavyCheckLockDir)).toBe(true);
|
||||
|
||||
release();
|
||||
expect(fs.existsSync(heavyCheckLockDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,18 @@
|
||||
// Covers the scripts/pr prepare-gates remote testbox mode and the
|
||||
// cross-worktree gate lock that serializes whole gate blocks.
|
||||
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
|
||||
// Covers the scripts/pr prepare-gates remote testbox mode.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const gateLockHelperPath = join(repoRoot, "scripts", "pr-gates-lock.mts");
|
||||
|
||||
const tempDirs = createTempDirTracker();
|
||||
const children: ChildProcess[] = [];
|
||||
|
||||
function makeLockRepoDir(): string {
|
||||
const dir = tempDirs.make("openclaw-pr-gates-lock-");
|
||||
mkdirSync(join(dir, ".git"), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function heavyCheckLockDir(repoDir: string): string {
|
||||
return join(repoDir, ".git", "openclaw-local-checks", "heavy-check.lock");
|
||||
}
|
||||
|
||||
function sanitizedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
// check:changed and gate runs export these to children; drop ambient copies
|
||||
// so lock and mode behavior under test only sees explicit overrides.
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
delete env.OPENCLAW_PR_GATES_REMOTE;
|
||||
delete env.OPENCLAW_TESTBOX;
|
||||
delete env.OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD;
|
||||
delete env.OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD;
|
||||
delete env.OPENCLAW_OXLINT_SKIP_LOCK;
|
||||
delete env.OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS;
|
||||
delete env.OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS;
|
||||
return { ...env, ...overrides };
|
||||
}
|
||||
|
||||
@@ -70,16 +50,6 @@ function runGatesBash(
|
||||
);
|
||||
}
|
||||
|
||||
function spawnGateLockHolder(repoDir: string, statusFile: string, env: NodeJS.ProcessEnv = {}) {
|
||||
const child = spawn(process.execPath, [gateLockHelperPath, "--status-file", statusFile], {
|
||||
cwd: repoDir,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: sanitizedEnv(env),
|
||||
});
|
||||
children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function makeRetryRepo(): { repoDir: string; stubBin: string; headSha: string } {
|
||||
const dir = tempDirs.make("openclaw-pr-gates-retry-");
|
||||
const repoDir = join(dir, "repo");
|
||||
@@ -242,70 +212,7 @@ function prepareSyncHeadStubs(): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
async function waitForExit(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
child.once("exit", resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForStderr(
|
||||
child: ChildProcess,
|
||||
expected: string,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const stderr = child.stderr;
|
||||
if (!stderr) {
|
||||
throw new Error("child stderr is not piped");
|
||||
}
|
||||
stderr.setEncoding("utf8");
|
||||
let output = "";
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
stderr.off("data", onData);
|
||||
child.off("exit", onExit);
|
||||
};
|
||||
const onData = (chunk: string) => {
|
||||
output += chunk;
|
||||
if (output.includes(expected)) {
|
||||
cleanup();
|
||||
resolve(output);
|
||||
}
|
||||
};
|
||||
const onExit = () => {
|
||||
cleanup();
|
||||
reject(new Error(`child exited before writing ${JSON.stringify(expected)}: ${output}`));
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`timed out waiting for ${JSON.stringify(expected)}: ${output}`));
|
||||
}, timeoutMs);
|
||||
stderr.on("data", onData);
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const child of children.splice(0)) {
|
||||
child.kill("SIGKILL");
|
||||
await waitForExit(child);
|
||||
}
|
||||
afterEach(() => {
|
||||
tempDirs.cleanup();
|
||||
});
|
||||
|
||||
@@ -936,7 +843,6 @@ describe("prepare gate stamp transitions", () => {
|
||||
"changelog_required_for_changed_files() { return 1; }",
|
||||
"prepare_local_gate_workspace() { :; }",
|
||||
"run_quiet_logged() { :; }",
|
||||
"release_pr_gates_lock() { :; }",
|
||||
"prepare_gates 4242",
|
||||
"cat .local/gates.env",
|
||||
].join("\n"),
|
||||
@@ -994,91 +900,12 @@ describe("prepare gate stamp transitions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pr-gates-lock helper", () => {
|
||||
it("acquires the shared heavy-check lock and releases it on SIGTERM", async () => {
|
||||
const repoDir = makeLockRepoDir();
|
||||
const statusFile = join(repoDir, "status");
|
||||
const holder = spawnGateLockHolder(repoDir, statusFile);
|
||||
|
||||
expect(await waitFor(() => existsSync(statusFile), 5_000)).toBe(true);
|
||||
expect(existsSync(heavyCheckLockDir(repoDir))).toBe(true);
|
||||
|
||||
holder.kill("SIGTERM");
|
||||
await waitForExit(holder);
|
||||
expect(await waitFor(() => !existsSync(heavyCheckLockDir(repoDir)), 5_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("queues behind an existing holder and acquires after it exits", async () => {
|
||||
const repoDir = makeLockRepoDir();
|
||||
const firstStatus = join(repoDir, "status-first");
|
||||
const secondStatus = join(repoDir, "status-second");
|
||||
|
||||
const first = spawnGateLockHolder(repoDir, firstStatus);
|
||||
expect(await waitFor(() => existsSync(firstStatus), 5_000)).toBe(true);
|
||||
|
||||
const second = spawnGateLockHolder(repoDir, secondStatus, {
|
||||
OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS: "50",
|
||||
});
|
||||
await waitForStderr(second, "queued behind the local heavy-check lock", 5_000);
|
||||
expect(existsSync(secondStatus)).toBe(false);
|
||||
|
||||
first.kill("SIGTERM");
|
||||
await waitForExit(first);
|
||||
expect(await waitFor(() => existsSync(secondStatus), 5_000)).toBe(true);
|
||||
|
||||
second.kill("SIGTERM");
|
||||
await waitForExit(second);
|
||||
expect(await waitFor(() => !existsSync(heavyCheckLockDir(repoDir)), 5_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails instead of holding when the wait times out", async () => {
|
||||
const repoDir = makeLockRepoDir();
|
||||
const lockDir = heavyCheckLockDir(repoDir);
|
||||
mkdirSync(lockDir, { recursive: true });
|
||||
// Owner pid must be alive or the helper reclaims the stale lock.
|
||||
writeFileSync(
|
||||
join(lockDir, "owner.json"),
|
||||
`${JSON.stringify({ pid: process.pid, tool: "test-holder", cwd: repoDir })}\n`,
|
||||
);
|
||||
|
||||
const statusFile = join(repoDir, "status");
|
||||
const holder = spawnGateLockHolder(repoDir, statusFile, {
|
||||
OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS: "200",
|
||||
OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS: "50",
|
||||
});
|
||||
await waitForExit(holder);
|
||||
|
||||
expect(holder.exitCode).not.toBe(0);
|
||||
expect(existsSync(statusFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the lock when the parent process dies", async () => {
|
||||
const repoDir = makeLockRepoDir();
|
||||
const statusFile = join(repoDir, "status");
|
||||
const parent = spawn(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`node '${gateLockHelperPath}' --status-file '${statusFile}' 2>/dev/null & ` +
|
||||
`while [ ! -s '${statusFile}' ]; do sleep 0.05; done`,
|
||||
],
|
||||
{ cwd: repoDir, stdio: "ignore", env: sanitizedEnv() },
|
||||
);
|
||||
children.push(parent);
|
||||
await waitForExit(parent);
|
||||
|
||||
expect(existsSync(statusFile)).toBe(true);
|
||||
expect(await waitFor(() => !existsSync(heavyCheckLockDir(repoDir)), 8_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gates.sh gate lock plumbing", () => {
|
||||
it("acquires the block lock before dependency bootstrap", () => {
|
||||
describe("gates.sh local gate workspace", () => {
|
||||
it("pins the worktree before dependency bootstrap", () => {
|
||||
const result = runGatesBash(
|
||||
[
|
||||
"events=$(mktemp)",
|
||||
'pin_worktree_bundled_plugins_dir() { echo pin >> "$events"; }',
|
||||
'acquire_pr_gates_lock() { echo lock >> "$events"; }',
|
||||
'bootstrap_deps_if_needed() { echo bootstrap >> "$events"; }',
|
||||
"prepare_local_gate_workspace",
|
||||
'cat "$events"',
|
||||
@@ -1086,66 +913,6 @@ describe("gates.sh gate lock plumbing", () => {
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim().split("\n")).toEqual(["pin", "lock", "bootstrap"]);
|
||||
});
|
||||
|
||||
it("exports the held-lock contract while holding and clears it on release", () => {
|
||||
const repoDir = makeLockRepoDir();
|
||||
const result = runGatesBash(
|
||||
[
|
||||
"acquire_pr_gates_lock",
|
||||
'echo "held=${OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD:-unset},${OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD:-unset},${OPENCLAW_OXLINT_SKIP_LOCK:-unset}"',
|
||||
"jq -r .tool .git/openclaw-local-checks/heavy-check.lock/owner.json",
|
||||
"release_pr_gates_lock",
|
||||
'echo "released=${OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD:-unset}"',
|
||||
'[ -d .git/openclaw-local-checks/heavy-check.lock ] && echo "lock=held" || echo "lock=free"',
|
||||
].join("\n"),
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("held=1,1,1");
|
||||
expect(result.stdout).toContain("pr-gates");
|
||||
expect(result.stdout).toContain("released=unset");
|
||||
expect(result.stdout).toContain("lock=free");
|
||||
});
|
||||
|
||||
it("skips acquisition when a parent already holds the lock", () => {
|
||||
const repoDir = makeLockRepoDir();
|
||||
const result = runGatesBash(
|
||||
[
|
||||
"acquire_pr_gates_lock",
|
||||
'[ -d .git/openclaw-local-checks/heavy-check.lock ] && echo "lock=held" || echo "lock=free"',
|
||||
'echo "helper_pid=${PR_GATES_LOCK_PID:-none}"',
|
||||
].join("\n"),
|
||||
{ cwd: repoDir, env: { OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1" } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("lock=free");
|
||||
expect(result.stdout).toContain("helper_pid=none");
|
||||
});
|
||||
|
||||
it("fails the gate run when the lock wait times out", () => {
|
||||
const repoDir = makeLockRepoDir();
|
||||
const lockDir = heavyCheckLockDir(repoDir);
|
||||
mkdirSync(lockDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(lockDir, "owner.json"),
|
||||
`${JSON.stringify({ pid: process.pid, tool: "test-holder", cwd: repoDir })}\n`,
|
||||
);
|
||||
|
||||
const result = runGatesBash("acquire_pr_gates_lock", {
|
||||
cwd: repoDir,
|
||||
env: {
|
||||
OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS: "200",
|
||||
OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS: "50",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain(
|
||||
"Failed to acquire the shared local heavy-check lock for prepare gates.",
|
||||
);
|
||||
expect(result.stdout.trim().split("\n")).toEqual(["pin", "bootstrap"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,20 +267,6 @@ describe("run-oxlint", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("holds one parent heavy-check lock for sharded lint runs", () => {
|
||||
const shardedLintRunner = readFileSync("scripts/run-oxlint-shards.mts", "utf8");
|
||||
const skipLockIndex = shardedLintRunner.indexOf('env.OPENCLAW_OXLINT_SKIP_LOCK === "1"');
|
||||
const lockIndex = shardedLintRunner.indexOf("acquireLocalHeavyCheckLockSync({");
|
||||
const childSkipIndex = shardedLintRunner.indexOf('OPENCLAW_OXLINT_SKIP_LOCK: "1"');
|
||||
|
||||
expect(shardedLintRunner).toContain("resolveLocalHeavyCheckEnv");
|
||||
expect(shardedLintRunner).toContain("shouldAcquireLocalHeavyCheckLockForOxlint");
|
||||
expect(skipLockIndex).toBeGreaterThan(-1);
|
||||
expect(lockIndex).toBeGreaterThan(-1);
|
||||
expect(lockIndex).toBeGreaterThan(skipLockIndex);
|
||||
expect(childSkipIndex).toBeGreaterThan(lockIndex);
|
||||
});
|
||||
|
||||
it("serializes broad oxlint shards on constrained local hosts", () => {
|
||||
expect(shouldSerializeShards({})).toBe(true);
|
||||
});
|
||||
@@ -624,16 +610,12 @@ describe("run-oxlint", () => {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE: "worktree",
|
||||
OPENCLAW_LOCAL_CHECK: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).not.toContain("[oxlint:");
|
||||
expect(existsSync(join(tempDir, ".artifacts/openclaw-local-checks/heavy-check.lock"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the full extension shard when Windows extension dirs are unavailable", () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("run-tsgo sparse guard", () => {
|
||||
{
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
formatNoChangedTestTargetLines,
|
||||
listFullExtensionVitestProjectConfigs,
|
||||
orderFullSuiteSpecsForParallelRun,
|
||||
shouldAcquireLocalHeavyCheckLock,
|
||||
resolveChangedTestTargetPlanForArgs,
|
||||
resolveChangedTestTargetPlan,
|
||||
resolveChangedTargetArgs,
|
||||
@@ -2891,80 +2890,6 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("scripts/test-projects local heavy-check lock", () => {
|
||||
const localCheckEnv = () => ({
|
||||
...process.env,
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: undefined,
|
||||
OPENCLAW_TEST_PROJECTS_FORCE_LOCK: undefined,
|
||||
});
|
||||
|
||||
it("skips the lock for a single scoped tooling run", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLock(
|
||||
[
|
||||
{
|
||||
config: "test/vitest/vitest.tooling.config.ts",
|
||||
includePatterns: ["test/scripts/gh-read.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
],
|
||||
localCheckEnv(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the lock for non-tooling runs", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLock(
|
||||
[
|
||||
{
|
||||
config: "test/vitest/vitest.unit.config.ts",
|
||||
includePatterns: ["src/infra/vitest-config.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
],
|
||||
localCheckEnv(),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("skips the lock when a parent changed gate already holds it", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLock(
|
||||
[
|
||||
{
|
||||
config: "test/vitest/vitest.unit.config.ts",
|
||||
includePatterns: ["src/infra/vitest-config.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
...localCheckEnv(),
|
||||
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows forcing the lock back on", () => {
|
||||
expect(
|
||||
shouldAcquireLocalHeavyCheckLock(
|
||||
[
|
||||
{
|
||||
config: "test/vitest/vitest.tooling.config.ts",
|
||||
includePatterns: ["test/scripts/gh-read.test.ts"],
|
||||
watchMode: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
...localCheckEnv(),
|
||||
OPENCLAW_TEST_PROJECTS_FORCE_LOCK: "1",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts/test-projects full-suite sharding", () => {
|
||||
it("interleaves heavy and light configs for cold parallel full-suite runs", () => {
|
||||
const specs = [
|
||||
|
||||
Reference in New Issue
Block a user