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:
Peter Steinberger
2026-08-16 20:58:27 -07:00
committed by GitHub
parent d24cb13320
commit bf9b25ab7e
46 changed files with 1082 additions and 1904 deletions
+1 -1
View File
@@ -2332,7 +2332,7 @@ jobs:
sparse-checkout: |
scripts/ci-run-node-test-shard.mts
scripts/lib/direct-run.mjs
scripts/lib/local-heavy-check-runtime.mts
scripts/lib/local-check-runtime.mts
sparse-checkout-cone-mode: false
persist-credentials: false
@@ -19534,7 +19534,19 @@ public struct ChatErrorEvent: Codable, Sendable {
}
}
public struct UpdateStatusParams: Codable, Sendable {}
public struct UpdateStatusParams: Codable, Sendable {
public let refreshcheckout: Bool?
public init(
refreshcheckout: Bool? = nil)
{
self.refreshcheckout = refreshcheckout
}
private enum CodingKeys: String, CodingKey {
case refreshcheckout = "refreshCheckout"
}
}
public struct UpdateStatusResult: Codable, Sendable {
public let sentinel: AnyCodable
-1
View File
@@ -73,7 +73,6 @@ In a Codex worktree or linked/sparse checkout, agents avoid direct local
while heavy or dependency-missing plans delegate to Testbox.
- Explicit kept-lease broad proof: `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox.
- The wrapper's final `exitCode` and timing JSON are the command result. A delegated Blacksmith GitHub Actions run may show `cancelled` after a successful SSH command because the Testbox is stopped from outside the keepalive action; check the wrapper summary and command output before treating that as a failure.
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
## Core commands
@@ -7,6 +7,7 @@ import {
UpdateHoldParamsSchema,
UpdateHoldResultSchema,
UpdateScheduleStateSchema,
UpdateStatusParamsSchema,
UpdateStatusResultSchema,
} from "./config.js";
@@ -55,6 +56,12 @@ describe("ConfigSchemaLookupResultSchema", () => {
});
describe("update protocol schemas", () => {
it("accepts an optional explicit checkout refresh", () => {
expect(Value.Check(UpdateStatusParamsSchema, {})).toBe(true);
expect(Value.Check(UpdateStatusParamsSchema, { refreshCheckout: true })).toBe(true);
expect(Value.Check(UpdateStatusParamsSchema, { refreshCheckout: "yes" })).toBe(false);
});
it("accepts package and git schedule targets", () => {
expect(
Value.Check(UpdateScheduleStateSchema, {
@@ -60,8 +60,10 @@ export const ConfigSchemaLookupParamsSchema = closedObject({
path: ConfigSchemaLookupPathString,
});
/** Empty request payload for checking update/restart status. */
export const UpdateStatusParamsSchema = closedObject({});
/** Request payload for cached status or an explicit checkout refresh. */
export const UpdateStatusParamsSchema = closedObject({
refreshCheckout: Type.Optional(Type.Boolean()),
});
const UpdateCommitSchema = closedObject({
sha: NonEmptyString,
-8
View File
@@ -11,17 +11,9 @@ This directory owns local tooling, script wrappers, and generated-artifact helpe
- For changed-file verification, prefer `scripts/check-changed.mjs` and keep lane classification in `scripts/changed-lanes.mjs`. Use `node scripts/check-changed.mjs --dry-run [--staged|-- <files...>]` to inspect the plan before running anything expensive. Do not copy path-scope rules into new hooks or ad hoc CI snippets.
- For one/few lint files, prefer direct `node scripts/run-oxlint.mjs --tsconfig <matching config> <files...>` over sharded `pnpm lint`; `check-changed.mjs` owns this targeting for core, extension, and script diffs.
## Local Heavy-Check Lock
- Respect the local heavy-check lock behavior in `scripts/lib/local-heavy-check-runtime.mts`.
- Do not bypass that lock for real heavy commands just to make a local loop look faster.
- Metadata-only or explicitly narrow commands may skip the lock when the existing helper logic says that is safe.
- If you change the lock heuristics, add or update the narrow tests under `test/scripts/`.
## PR Prepare Gates
- `scripts/pr` serializes review, prepare, and merge operations per PR across linked worktrees; `scripts/pr gc` skips active or indeterminate locks. Its subcommand classification table is the canonical wrapper trust boundary: a mismatched local wrapper may run only a classified `advisory` subcommand with `--dev-wrapper` or `OPENCLAW_PR_DEV_WRAPPER=1`; classified `landing` subcommands always require canonical/origin-main wrapper code. A worktree whose wrapper differs from origin/main (stale base or wrapper-editing branch) loudly substitutes the canonical checkout's wrapper when that checkout is clean and byte-identical to fetched `refs/remotes/origin/main`; it refuses only when no anchor-matching wrapper is available. A successful command return is the trusted synchronous-completion contract: every PR-state-mutating child must be joined before returning, and such work must never daemonize or explicitly escape both the operation group and lock-notification FD. Release on clean exit requires the leader's completion marker; an escaped descendant that merely holds the notify pipe then produces a loud warned release instead of retention (#124583), while all failure shapes still retain. A failed command auto-releases only while its explicit pre-side-effect validation marker remains active; failures after mutation/tool launch, interruptions, and controller loss stay locked because detached children cannot be disproved. After verifying no child tools remain, use the reported exact-OID `scripts/pr lock-recover` command. Never bypass or delete these refs manually.
- `scripts/pr prepare-gates` holds the heavy-check lock for its whole local gate block (`scripts/pr-gates-lock.mts`), so concurrent gate runs across `.worktrees` queue as units instead of dying on child lock timeouts or vitest no-output watchdog kills.
- `OPENCLAW_PR_GATES_REMOTE=testbox` runs the full-suite `pnpm test` gate on a Blacksmith Testbox through `scripts/crabbox-wrapper.mjs` (same delegation as `check:changed`); `pnpm build`/`pnpm check` stay local. The `tbx_` lease id and Actions run URL land in `.local/gates.env` (`REMOTE_GATES_*`) and `.local/prep.md`. Use it for reviewed trusted code when a loaded host makes the local 88-shard run stall-kill; contributor/fork code stays on secretless CI or sanitized AWS unless a maintainer explicitly approves credentialed execution.
## Generated Outputs
+20 -35
View File
@@ -30,11 +30,7 @@ import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-fa
import { printTimingSummary } from "./lib/check-timing-summary.mts";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { runWithFailedTrailer } from "./lib/failed-trailer.mts";
import {
acquireLocalHeavyCheckLockSync,
resolveLocalHeavyCheckEnv,
withLocalHeavyCheckLockHeld,
} from "./lib/local-heavy-check-runtime.mts";
import { resolveLocalCheckEnv } from "./lib/local-check-runtime.mts";
import { runManagedCommand } from "./lib/managed-child-process.mts";
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mts";
import { createSparseTsgoSkipEnv } from "./lib/tsgo-sparse-guard.mts";
@@ -156,8 +152,8 @@ if (!isDirectRun()) {
await ensureChangedCheckRuntimeDependencies(["package.json"]);
}
export function createChangedCheckChildEnv(baseEnv: NodeJS.ProcessEnv = process.env) {
return withLocalHeavyCheckLockHeld(resolveLocalHeavyCheckEnv(baseEnv));
function createChangedCheckChildEnv(baseEnv: NodeJS.ProcessEnv = process.env) {
return resolveLocalCheckEnv(baseEnv);
}
function hasAndroidVersionSyncPath(paths: string[]) {
@@ -1047,41 +1043,30 @@ async function runChangedCheck(result: ChangedLaneResult, options: ChangedCheckR
return 0;
}
await ensureChangedCheckRuntimeDependencies(result.paths);
const baseEnv = resolveLocalHeavyCheckEnv(options.env ?? process.env);
const baseEnv = resolveLocalCheckEnv(options.env ?? process.env);
const childEnv = createChangedCheckChildEnv(baseEnv);
const plan = createChangedCheckPlan(result, {
...options,
env: childEnv,
});
const releaseLock = options.dryRun
? () => {}
: acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env: baseEnv,
toolName: "check:changed",
});
try {
printPlan(result, plan, options);
printPlan(result, plan, options);
if (options.dryRun) {
return 0;
}
const timings: ChangedCheckTiming[] = [];
for (const command of plan.commands) {
const status = await runPlanCommand(command, timings);
if (status !== 0) {
printSummary(timings, options);
return status;
}
}
printSummary(timings, options);
if (options.dryRun) {
return 0;
} finally {
releaseLock();
}
const timings: ChangedCheckTiming[] = [];
for (const command of plan.commands) {
const status = await runPlanCommand(command, timings);
if (status !== 0) {
printSummary(timings, options);
return status;
}
}
printSummary(timings, options);
return 0;
}
function sameArgs(left: string[], right: string[]) {
@@ -1132,7 +1117,7 @@ export function createPnpmManagedCommand<T extends ChangedCheckCommand>(
command: T,
env: NodeJS.ProcessEnv = process.env,
) {
const commandEnv = command.env ?? resolveLocalHeavyCheckEnv(env);
const commandEnv = command.env ?? resolveLocalCheckEnv(env);
if (isOpenEndedTruthyValue(commandEnv.CI) || isOpenEndedTruthyValue(commandEnv.GITHUB_ACTIONS)) {
const shimmedEnv = prependCorepackPnpmShim(commandEnv);
return {
@@ -1195,7 +1180,7 @@ async function runCommand(
status = await runManagedCommand({
bin: command.bin,
args: command.args,
env: command.env ?? resolveLocalHeavyCheckEnv(),
env: command.env ?? resolveLocalCheckEnv(),
});
} catch (error) {
console.error(error);
+1 -1
View File
@@ -1,6 +1,6 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { resolveRepoToolBinPath } from "./lib/local-heavy-check-runtime.mts";
import { resolveRepoToolBinPath } from "./lib/local-check-runtime.mts";
import { runManagedCommand } from "./lib/managed-child-process.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
+1 -1
View File
@@ -2,7 +2,7 @@
// Enforces core tsgo project boundaries and sparse-checkout safety.
import { spawnSync } from "node:child_process";
import { resolveRepoToolBinPath } from "./lib/local-heavy-check-runtime.mts";
import { resolveRepoToolBinPath } from "./lib/local-check-runtime.mts";
import { createManagedCommandInvocation } from "./lib/managed-child-process.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import {
+2 -17
View File
@@ -19,10 +19,6 @@ import type { Readable } from "node:stream";
import { StringDecoder } from "node:string_decoder";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import {
acquireLocalHeavyCheckLockSync,
withLocalHeavyCheckLockHeld,
} from "./lib/local-heavy-check-runtime.mts";
// Two concurrent plans halve the serial tail of packed jobs. Children run with
// inner test-projects parallelism 1 so a job never exceeds two Vitest runs;
@@ -146,9 +142,7 @@ export function buildChildEnv(
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
}
}
// This wrapper owns the shared lock, so every nested heavy-check wrapper must
// inherit that ownership rather than queueing behind its own parent.
return withLocalHeavyCheckLockHeld(childEnv);
return childEnv;
}
export function pruneFsModuleCache(root: string, maxBytes = FS_MODULE_CACHE_MAX_BYTES) {
@@ -392,14 +386,5 @@ if (isDirectRunUrl(process.argv[1], import.meta.url)) {
// Bins holding spawn/signal-timing suites are marked planConcurrency 1 by
// the planner; overlapping them with a sibling Vitest run causes flakes.
const planConcurrency = Number(process.env.OPENCLAW_NODE_TEST_PLAN_CONCURRENCY) || undefined;
const releaseLock = acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env: process.env,
toolName: "test",
});
try {
process.exitCode = await runShardPlans(plans, { concurrency: planConcurrency });
} finally {
releaseLock();
}
process.exitCode = await runShardPlans(plans, { concurrency: planConcurrency });
}
+1 -1
View File
@@ -6,7 +6,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { resolveRepoToolBinPath } from "./lib/local-heavy-check-runtime.mts";
import { resolveRepoToolBinPath } from "./lib/local-check-runtime.mts";
import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs";
import { outputTail } from "./lib/output-tail.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
@@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
const LOCK_POLL_MS = 500;
const LOCK_TIMEOUT_MS = 10 * 60 * 1000;
const OWNER_WRITE_GRACE_MS = 30 * 1000;
const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
type LockOwner = { pid?: unknown };
function isProcessAlive(pid: unknown) {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EPERM");
}
}
function shouldReclaimLock(lockDir: string, ownerPath: string) {
try {
const owner = JSON.parse(fs.readFileSync(ownerPath, "utf8")) as LockOwner;
return !isProcessAlive(owner.pid);
} catch {
try {
return Date.now() - fs.statSync(lockDir).mtimeMs >= OWNER_WRITE_GRACE_MS;
} catch {
return true;
}
}
}
export function acquireExtensionPackageBoundaryArtifactLockSync(rootDir: string) {
// The generated declarations live in this checkout. Keeping ownership beside
// them prevents same-worktree writers without coupling independent worktrees.
const lockDir = path.join(rootDir, "dist", ".extension-package-boundary-artifacts.lock");
const ownerPath = path.join(lockDir, "owner.json");
const startedAt = Date.now();
let reportedWait = false;
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
for (;;) {
try {
fs.mkdirSync(lockDir);
fs.writeFileSync(
ownerPath,
`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`,
"utf8",
);
let released = false;
const release = () => {
if (released) {
return;
}
released = true;
fs.rmSync(lockDir, { force: true, recursive: true });
};
process.once("exit", release);
return () => {
process.off("exit", release);
release();
};
} catch (error) {
if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) {
throw error;
}
if (shouldReclaimLock(lockDir, ownerPath)) {
fs.rmSync(lockDir, { force: true, recursive: true });
continue;
}
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
throw new Error(`timed out waiting for plugin package-boundary artifacts in ${rootDir}`, {
cause: error,
});
}
if (!reportedWait) {
console.error("[plugin package-boundary artifacts] waiting for the current writer...");
reportedWait = true;
}
Atomics.wait(SLEEP_BUFFER, 0, 0, LOCK_POLL_MS);
}
}
}
+284
View File
@@ -0,0 +1,284 @@
// Applies local resource policy for expensive check commands.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const GIB = 1024 ** 3;
const DEFAULT_LOCAL_GO_GC = "30";
const DEFAULT_LOCAL_GO_MAX_PROCS = 2;
const DEFAULT_LOCAL_GO_MEMORY_LIMIT = "3GiB";
const DEFAULT_LOCAL_TSGO_BUILD_INFO_FILE = ".artifacts/tsgo-cache/root.tsbuildinfo";
const DEFAULT_FAST_LOCAL_CHECK_MIN_MEMORY_BYTES = 48 * GIB;
const DEFAULT_FAST_LOCAL_CHECK_MIN_CPUS = 12;
type Env = NodeJS.ProcessEnv;
type Resources = {
logicalCpuCount: number;
totalMemoryBytes: number;
};
type LocalCheckMode = "auto" | "full" | "throttled";
type RepoToolOptions = {
cwd?: string;
fileExists?: (candidate: string) => boolean;
resolveCommonDir?: (cwd: string) => string | null;
};
/** Return whether local check safeguards are enabled for an environment. */
export function isLocalCheckEnabled(env: Env) {
const raw = env.OPENCLAW_LOCAL_CHECK?.trim().toLowerCase();
return raw !== "0" && raw !== "false";
}
function isCiLikeEnv(env: Env = process.env) {
return env.CI === "true" || env.GITHUB_ACTIONS === "true";
}
/** Ensure local check runs opt into safeguard environment outside CI. */
export function resolveLocalCheckEnv(env: Env = process.env) {
if (isCiLikeEnv(env) || isLocalCheckEnabled(env)) {
return env;
}
return {
...env,
OPENCLAW_LOCAL_CHECK: "1",
};
}
/** Resolve a repo tool from this worktree or the primary checkout's installed toolchain. */
export function resolveRepoToolBinPath(
toolName: string,
{
cwd = process.cwd(),
fileExists = fs.existsSync,
resolveCommonDir = resolveGitCommonDir,
}: RepoToolOptions = {},
) {
const localPath = path.resolve(cwd, "node_modules", ".bin", toolName);
if (fileExists(localPath)) {
return localPath;
}
const commonDir = resolveCommonDir(cwd);
if (!commonDir || path.basename(commonDir) !== ".git") {
return localPath;
}
// Linked worktrees share the primary checkout's .git directory. Its parent
// owns the installed toolchain that dependency-less worktrees can reuse.
const primaryPath = path.join(path.dirname(commonDir), "node_modules", ".bin", toolName);
return fileExists(primaryPath) ? primaryPath : localPath;
}
/** Link a dependency-less worktree to the primary checkout toolchain selected above. */
export function ensureRepoToolNodeModulesLink(
toolPath: string,
{
cwd = process.cwd(),
fileExists = fs.existsSync,
resolveCommonDir = resolveGitCommonDir,
symlink = fs.symlinkSync,
platform = process.platform,
}: RepoToolOptions & {
symlink?: typeof fs.symlinkSync;
platform?: NodeJS.Platform;
} = {},
) {
const localNodeModules = path.resolve(cwd, "node_modules");
if (fileExists(localNodeModules)) {
return localNodeModules;
}
const commonDir = resolveCommonDir(cwd);
if (!commonDir || path.basename(commonDir) !== ".git") {
return null;
}
const primaryNodeModules = path.join(path.dirname(commonDir), "node_modules");
const toolNodeModules = path.dirname(path.dirname(path.resolve(toolPath)));
if (toolNodeModules !== path.resolve(primaryNodeModules) || !fileExists(primaryNodeModules)) {
return null;
}
try {
// Match run-vitest.mjs's hydrated-toolchain behavior: keep one stable link
// so compilers can resolve imports from worktree source paths.
symlink(primaryNodeModules, localNodeModules, platform === "win32" ? "junction" : "dir");
} catch (error) {
// Another local runner may have installed the same stable link concurrently.
if (!fileExists(localNodeModules)) {
throw error;
}
}
return localNodeModules;
}
function hasFlag(args: string[], name: string) {
return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
}
function hasOxlintFormatArg(args: string[]) {
return args.some(
(arg) =>
arg === "--format" ||
arg.startsWith("--format=") ||
arg === "-f" ||
arg.startsWith("-f=") ||
(arg.startsWith("-f") && arg.length > 2),
);
}
/** Apply the shared memory and scheduler limits for Go-backed check helpers. */
function applyThrottledGoRuntimeEnv(env: Env, hostResources: Resources) {
if (!env.GOMAXPROCS) {
env.GOMAXPROCS = String(
Math.min(DEFAULT_LOCAL_GO_MAX_PROCS, Math.max(1, hostResources.logicalCpuCount)),
);
}
if (!env.GOGC) {
env.GOGC = DEFAULT_LOCAL_GO_GC;
}
if (!env.GOMEMLIMIT) {
env.GOMEMLIMIT = DEFAULT_LOCAL_GO_MEMORY_LIMIT;
}
}
/** Apply local tsgo defaults for declaration skipping, caching, throttling, and profiling. */
export function applyLocalTsgoPolicy(args: string[], env: Env, hostResources: Resources) {
const nextEnv = { ...env };
const nextArgs = [...args];
const defaultProjectRun = nextArgs.length === 0;
if (!hasFlag(nextArgs, "--declaration") && !nextArgs.includes("-d")) {
insertBeforeSeparator(nextArgs, "--declaration", "false");
}
if (!isLocalCheckEnabled(nextEnv)) {
return { env: nextEnv, args: nextArgs };
}
if (defaultProjectRun) {
insertBeforeSeparator(nextArgs, "--incremental");
insertBeforeSeparator(
nextArgs,
"--tsBuildInfoFile",
nextEnv.OPENCLAW_TSGO_BUILD_INFO_FILE ?? DEFAULT_LOCAL_TSGO_BUILD_INFO_FILE,
);
}
const resolvedHostResources = resolveHostResources(hostResources);
if (shouldThrottleLocalChecks(nextEnv, resolvedHostResources, "auto")) {
insertBeforeSeparator(nextArgs, "--singleThreaded");
insertBeforeSeparator(nextArgs, "--checkers", "1");
applyThrottledGoRuntimeEnv(nextEnv, resolvedHostResources);
}
if (nextEnv.OPENCLAW_TSGO_PPROF_DIR && !hasFlag(nextArgs, "--pprofDir")) {
insertBeforeSeparator(nextArgs, "--pprofDir", nextEnv.OPENCLAW_TSGO_PPROF_DIR);
}
return { env: nextEnv, args: nextArgs };
}
/** Apply local oxlint defaults for type-aware checking and throttled worker settings. */
export function applyLocalOxlintPolicy(args: string[], env: Env, hostResources: Resources) {
const nextEnv = { ...env };
const nextArgs = [...args];
insertBeforeSeparator(nextArgs, "--type-aware");
insertBeforeSeparator(nextArgs, "--tsconfig", "config/tsconfig/oxlint.json");
if (
!hasFlag(nextArgs, "--report-unused-disable-directives") &&
!hasFlag(nextArgs, "--report-unused-disable-directives-severity")
) {
insertBeforeSeparator(nextArgs, "--report-unused-disable-directives-severity", "error");
}
if (nextEnv.GITHUB_ACTIONS === "true" && !hasOxlintFormatArg(nextArgs)) {
insertBeforeSeparator(nextArgs, "--format", "stylish");
}
if (shouldThrottleLocalChecks(nextEnv, hostResources)) {
if (!hasFlag(nextArgs, "--threads")) {
insertBeforeSeparator(nextArgs, "--threads=1");
}
// Oxlint's thread flag does not govern the Go tsgolint helper.
applyThrottledGoRuntimeEnv(nextEnv, hostResources);
}
return { env: nextEnv, args: nextArgs };
}
function shouldThrottleLocalChecks(
env: Env,
hostResources: Resources | undefined,
defaultMode: LocalCheckMode = "throttled",
) {
if (!isLocalCheckEnabled(env)) {
return false;
}
const mode = readLocalCheckMode(env, defaultMode);
if (mode === "throttled") {
return true;
}
if (mode === "full") {
return false;
}
const resolvedHostResources = resolveHostResources(hostResources);
return (
resolvedHostResources.totalMemoryBytes < DEFAULT_FAST_LOCAL_CHECK_MIN_MEMORY_BYTES ||
resolvedHostResources.logicalCpuCount < DEFAULT_FAST_LOCAL_CHECK_MIN_CPUS
);
}
function resolveGitCommonDir(cwd: string) {
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status === 0) {
const raw = result.stdout.trim();
if (raw.length > 0) {
return path.resolve(cwd, raw);
}
}
return path.join(cwd, ".git");
}
function insertBeforeSeparator(args: string[], ...items: [string, ...string[]]) {
if (hasFlag(args, items[0])) {
return;
}
const separatorIndex = args.indexOf("--");
const insertIndex = separatorIndex === -1 ? args.length : separatorIndex;
args.splice(insertIndex, 0, ...items);
}
function readLocalCheckMode(env: Env, defaultMode: LocalCheckMode) {
const raw = env.OPENCLAW_LOCAL_CHECK_MODE?.trim().toLowerCase();
if (raw === "throttled" || raw === "low-memory") {
return "throttled";
}
if (raw === "full" || raw === "fast") {
return "full";
}
return defaultMode;
}
function resolveHostResources(hostResources: Resources | undefined) {
if (hostResources) {
return hostResources;
}
return {
totalMemoryBytes: os.totalmem(),
logicalCpuCount:
typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length,
};
}
-595
View File
@@ -1,595 +0,0 @@
// Applies local resource policy and process locks for expensive check commands.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const GIB = 1024 ** 3;
const DEFAULT_LOCAL_GO_GC = "30";
const DEFAULT_LOCAL_GO_MAX_PROCS = 2;
const DEFAULT_LOCAL_GO_MEMORY_LIMIT = "3GiB";
const DEFAULT_LOCAL_TSGO_BUILD_INFO_FILE = ".artifacts/tsgo-cache/root.tsbuildinfo";
const DEFAULT_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
const DEFAULT_LOCK_POLL_MS = 500;
const DEFAULT_LOCK_PROGRESS_MS = 15 * 1000;
const DEFAULT_STALE_LOCK_MS = 30 * 1000;
const DEFAULT_FAST_LOCAL_CHECK_MIN_MEMORY_BYTES = 48 * GIB;
const DEFAULT_FAST_LOCAL_CHECK_MIN_CPUS = 12;
const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
type Env = NodeJS.ProcessEnv;
type Resources = {
logicalCpuCount: number;
totalMemoryBytes: number;
};
type LocalCheckMode = "auto" | "full" | "throttled";
type RepoToolOptions = {
cwd?: string;
fileExists?: (candidate: string) => boolean;
resolveCommonDir?: (cwd: string) => string | null;
};
type LockOwner = Partial<Record<"cwd" | "pid" | "tool", unknown>>;
type LocalHeavyCheckLockParams = {
cwd: string;
env?: Env;
lockName?: string;
toolName: string;
};
/** Return whether local-heavy-check safeguards are enabled for an environment. */
export function isLocalCheckEnabled(env: Env) {
const raw = env.OPENCLAW_LOCAL_CHECK?.trim().toLowerCase();
return raw !== "0" && raw !== "false";
}
function isCiLikeEnv(env: Env = process.env) {
return env.CI === "true" || env.GITHUB_ACTIONS === "true";
}
/** Ensure local check runs opt into safeguard environment outside CI. */
export function resolveLocalHeavyCheckEnv(env: Env = process.env) {
if (isCiLikeEnv(env) || isLocalCheckEnabled(env)) {
return env;
}
return {
...env,
OPENCLAW_LOCAL_CHECK: "1",
};
}
/** Mark every nested heavy-check wrapper as covered by one parent-held lock. */
export function withLocalHeavyCheckLockHeld(env: Env): Env {
return {
...env,
OPENCLAW_OXLINT_SKIP_LOCK: "1",
OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1",
OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1",
};
}
/** Resolve a repo tool from this worktree or the primary checkout's installed toolchain. */
export function resolveRepoToolBinPath(
toolName: string,
{
cwd = process.cwd(),
fileExists = fs.existsSync,
resolveCommonDir = resolveGitCommonDir,
}: RepoToolOptions = {},
) {
const localPath = path.resolve(cwd, "node_modules", ".bin", toolName);
if (fileExists(localPath)) {
return localPath;
}
const commonDir = resolveCommonDir(cwd);
if (!commonDir || path.basename(commonDir) !== ".git") {
return localPath;
}
// Linked worktrees share the primary checkout's .git directory. Its parent
// owns the installed toolchain that dependency-less worktrees can reuse.
const primaryPath = path.join(path.dirname(commonDir), "node_modules", ".bin", toolName);
return fileExists(primaryPath) ? primaryPath : localPath;
}
/** Link a dependency-less worktree to the primary checkout toolchain selected above. */
export function ensureRepoToolNodeModulesLink(
toolPath: string,
{
cwd = process.cwd(),
fileExists = fs.existsSync,
resolveCommonDir = resolveGitCommonDir,
symlink = fs.symlinkSync,
platform = process.platform,
}: RepoToolOptions & {
symlink?: typeof fs.symlinkSync;
platform?: NodeJS.Platform;
} = {},
) {
const localNodeModules = path.resolve(cwd, "node_modules");
if (fileExists(localNodeModules)) {
return localNodeModules;
}
const commonDir = resolveCommonDir(cwd);
if (!commonDir || path.basename(commonDir) !== ".git") {
return null;
}
const primaryNodeModules = path.join(path.dirname(commonDir), "node_modules");
const toolNodeModules = path.dirname(path.dirname(path.resolve(toolPath)));
if (toolNodeModules !== path.resolve(primaryNodeModules) || !fileExists(primaryNodeModules)) {
return null;
}
try {
// Match run-vitest.mjs's hydrated-toolchain behavior: keep one stable link
// so compilers can resolve imports from worktree source paths.
symlink(primaryNodeModules, localNodeModules, platform === "win32" ? "junction" : "dir");
} catch (error) {
// Another local runner may have installed the same stable link concurrently.
if (!fileExists(localNodeModules)) {
throw error;
}
}
return localNodeModules;
}
function hasFlag(args: string[], name: string) {
return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
}
function hasOxlintFormatArg(args: string[]) {
return args.some(
(arg) =>
arg === "--format" ||
arg.startsWith("--format=") ||
arg === "-f" ||
arg.startsWith("-f=") ||
(arg.startsWith("-f") && arg.length > 2),
);
}
/** Apply the shared memory and scheduler limits for Go-backed check helpers. */
function applyThrottledGoRuntimeEnv(env: Env, hostResources: Resources) {
if (!env.GOMAXPROCS) {
env.GOMAXPROCS = String(
Math.min(DEFAULT_LOCAL_GO_MAX_PROCS, Math.max(1, hostResources.logicalCpuCount)),
);
}
if (!env.GOGC) {
env.GOGC = DEFAULT_LOCAL_GO_GC;
}
if (!env.GOMEMLIMIT) {
env.GOMEMLIMIT = DEFAULT_LOCAL_GO_MEMORY_LIMIT;
}
}
/** Apply local tsgo defaults for declaration skipping, caching, throttling, and profiling. */
export function applyLocalTsgoPolicy(args: string[], env: Env, hostResources: Resources) {
const nextEnv = { ...env };
const nextArgs = [...args];
const defaultProjectRun = nextArgs.length === 0;
if (!hasFlag(nextArgs, "--declaration") && !nextArgs.includes("-d")) {
insertBeforeSeparator(nextArgs, "--declaration", "false");
}
if (!isLocalCheckEnabled(nextEnv)) {
return { env: nextEnv, args: nextArgs };
}
if (defaultProjectRun) {
insertBeforeSeparator(nextArgs, "--incremental");
insertBeforeSeparator(
nextArgs,
"--tsBuildInfoFile",
nextEnv.OPENCLAW_TSGO_BUILD_INFO_FILE ?? DEFAULT_LOCAL_TSGO_BUILD_INFO_FILE,
);
}
const resolvedHostResources = resolveHostResources(hostResources);
if (shouldThrottleLocalHeavyChecks(nextEnv, resolvedHostResources, "auto")) {
insertBeforeSeparator(nextArgs, "--singleThreaded");
insertBeforeSeparator(nextArgs, "--checkers", "1");
applyThrottledGoRuntimeEnv(nextEnv, resolvedHostResources);
}
if (nextEnv.OPENCLAW_TSGO_PPROF_DIR && !hasFlag(nextArgs, "--pprofDir")) {
insertBeforeSeparator(nextArgs, "--pprofDir", nextEnv.OPENCLAW_TSGO_PPROF_DIR);
}
return { env: nextEnv, args: nextArgs };
}
/** Apply local oxlint defaults for type-aware checking and throttled worker settings. */
export function applyLocalOxlintPolicy(args: string[], env: Env, hostResources: Resources) {
const nextEnv = { ...env };
const nextArgs = [...args];
insertBeforeSeparator(nextArgs, "--type-aware");
insertBeforeSeparator(nextArgs, "--tsconfig", "config/tsconfig/oxlint.json");
if (
!hasFlag(nextArgs, "--report-unused-disable-directives") &&
!hasFlag(nextArgs, "--report-unused-disable-directives-severity")
) {
insertBeforeSeparator(nextArgs, "--report-unused-disable-directives-severity", "error");
}
if (nextEnv.GITHUB_ACTIONS === "true" && !hasOxlintFormatArg(nextArgs)) {
insertBeforeSeparator(nextArgs, "--format", "stylish");
}
if (shouldThrottleLocalHeavyChecks(nextEnv, hostResources)) {
if (!hasFlag(nextArgs, "--threads")) {
insertBeforeSeparator(nextArgs, "--threads=1");
}
// Oxlint's thread flag does not govern the Go tsgolint helper.
applyThrottledGoRuntimeEnv(nextEnv, hostResources);
}
return { env: nextEnv, args: nextArgs };
}
/** Decide whether an oxlint invocation needs the local heavy-check lock. */
export function shouldAcquireLocalHeavyCheckLockForOxlint(
args: string[],
{ cwd = process.cwd(), env = process.env }: { cwd?: string; env?: Env } = {},
) {
if (env.OPENCLAW_OXLINT_FORCE_LOCK === "1") {
return true;
}
if (
args.some(
(arg) =>
arg === "--help" ||
arg === "-h" ||
arg === "--version" ||
arg === "-V" ||
arg === "--rules" ||
arg === "--print-config" ||
arg === "--init",
)
) {
return false;
}
const separatorIndex = args.indexOf("--");
const candidateArgs = (() => {
if (separatorIndex !== -1) {
return args.slice(separatorIndex + 1);
}
const firstFlagIndex = args.findIndex((arg) => arg.startsWith("-"));
return firstFlagIndex === -1 ? args : args.slice(0, firstFlagIndex);
})();
const explicitTargets = candidateArgs.filter((arg) => arg.length > 0 && !arg.startsWith("-"));
if (explicitTargets.length === 0) {
return true;
}
return !explicitTargets.every((target) => {
try {
return fs.statSync(path.resolve(cwd, target)).isFile();
} catch {
return false;
}
});
}
/** Decide whether a tsgo invocation needs the local heavy-check lock. */
export function shouldAcquireLocalHeavyCheckLockForTsgo(args: string[], env: Env = process.env) {
if (env.OPENCLAW_TSGO_FORCE_LOCK === "1") {
return true;
}
return !args.some(
(arg) =>
arg === "--help" ||
arg === "-h" ||
arg === "--version" ||
arg === "-v" ||
arg === "--init" ||
arg === "--showConfig",
);
}
function shouldThrottleLocalHeavyChecks(
env: Env,
hostResources: Resources | undefined,
defaultMode: LocalCheckMode = "throttled",
) {
if (!isLocalCheckEnabled(env)) {
return false;
}
const mode = readLocalCheckMode(env, defaultMode);
if (mode === "throttled") {
return true;
}
if (mode === "full") {
return false;
}
const resolvedHostResources = resolveHostResources(hostResources);
return (
resolvedHostResources.totalMemoryBytes < DEFAULT_FAST_LOCAL_CHECK_MIN_MEMORY_BYTES ||
resolvedHostResources.logicalCpuCount < DEFAULT_FAST_LOCAL_CHECK_MIN_CPUS
);
}
/** Acquire a filesystem lock for one local heavy check and return its release callback. */
export function acquireLocalHeavyCheckLockSync(params: LocalHeavyCheckLockParams) {
const env = params.env ?? process.env;
if (!isLocalCheckEnabled(env)) {
return () => {};
}
const locksDir = resolveHeavyCheckLocksDir(params.cwd, env);
const lockDir = path.join(locksDir, `${params.lockName ?? "heavy-check"}.lock`);
const ownerPath = path.join(lockDir, "owner.json");
const timeoutMs = readPositiveInt(
env.OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS,
DEFAULT_LOCK_TIMEOUT_MS,
"OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS",
);
const pollMs = readPositiveInt(
env.OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS,
DEFAULT_LOCK_POLL_MS,
"OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS",
);
const progressMs = readPositiveInt(
env.OPENCLAW_HEAVY_CHECK_LOCK_PROGRESS_MS,
DEFAULT_LOCK_PROGRESS_MS,
"OPENCLAW_HEAVY_CHECK_LOCK_PROGRESS_MS",
);
const staleLockMs = readPositiveInt(
env.OPENCLAW_HEAVY_CHECK_STALE_LOCK_MS,
DEFAULT_STALE_LOCK_MS,
"OPENCLAW_HEAVY_CHECK_STALE_LOCK_MS",
);
const startedAt = Date.now();
let waitLogBudget = 1;
let lastProgressAt = startedAt;
const consumeInitialWaitLog = () => waitLogBudget-- > 0;
const consumeProgressLog = (now: number) => {
if (now - lastProgressAt < progressMs) {
return false;
}
lastProgressAt = now;
return true;
};
fs.mkdirSync(locksDir, { recursive: true });
if (!params.lockName) {
cleanupLegacyLockDirs(locksDir, staleLockMs);
}
for (;;) {
try {
fs.mkdirSync(lockDir);
writeOwnerFile(ownerPath, {
pid: process.pid,
tool: params.toolName,
cwd: params.cwd,
hostname: os.hostname(),
createdAt: new Date().toISOString(),
});
return () => {
fs.rmSync(lockDir, { recursive: true, force: true });
};
} catch (error) {
if (!isAlreadyExistsError(error)) {
throw error;
}
const owner = readOwnerFile(ownerPath);
if (shouldReclaimLock(owner, lockDir, staleLockMs)) {
fs.rmSync(lockDir, { recursive: true, force: true });
continue;
}
const elapsedMs = Date.now() - startedAt;
if (elapsedMs >= timeoutMs) {
const ownerLabel = describeOwner(owner);
throw new Error(
`[${params.toolName}] timed out waiting for the local heavy-check lock at ${lockDir}${
ownerLabel ? ` (${ownerLabel})` : ""
}. If no local heavy checks are still running, remove the stale lock and retry.`,
{ cause: error },
);
}
if (consumeInitialWaitLog()) {
const ownerLabel = describeOwner(owner);
console.error(
`[${params.toolName}] queued behind the local heavy-check lock${
ownerLabel ? ` held by ${ownerLabel}` : ""
}...`,
);
} else if (consumeProgressLog(Date.now())) {
const ownerLabel = describeOwner(owner);
console.error(
`[${params.toolName}] still waiting ${formatElapsedMs(elapsedMs)} for the local heavy-check lock${
ownerLabel ? ` held by ${ownerLabel}` : ""
}...`,
);
}
sleepSync(pollMs);
}
}
}
function resolveHeavyCheckLocksDir(cwd: string, env: Env) {
const lockScope = env.OPENCLAW_HEAVY_CHECK_LOCK_SCOPE?.trim().toLowerCase();
if (lockScope === "worktree") {
return path.join(resolveGitWorktreeRoot(cwd), ".artifacts", "openclaw-local-checks");
}
return path.join(resolveGitCommonDir(cwd), "openclaw-local-checks");
}
function resolveGitWorktreeRoot(cwd: string) {
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status === 0) {
const raw = result.stdout.trim();
if (raw.length > 0) {
return path.resolve(cwd, raw);
}
}
return cwd;
}
function resolveGitCommonDir(cwd: string) {
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status === 0) {
const raw = result.stdout.trim();
if (raw.length > 0) {
return path.resolve(cwd, raw);
}
}
return path.join(cwd, ".git");
}
function cleanupLegacyLockDirs(locksDir: string, staleLockMs: number) {
for (const legacyLockName of ["test"]) {
const legacyLockDir = path.join(locksDir, `${legacyLockName}.lock`);
if (!fs.existsSync(legacyLockDir)) {
continue;
}
const owner = readOwnerFile(path.join(legacyLockDir, "owner.json"));
if (shouldReclaimLock(owner, legacyLockDir, staleLockMs)) {
fs.rmSync(legacyLockDir, { recursive: true, force: true });
}
}
}
function insertBeforeSeparator(args: string[], ...items: [string, ...string[]]) {
if (hasFlag(args, items[0])) {
return;
}
const separatorIndex = args.indexOf("--");
const insertIndex = separatorIndex === -1 ? args.length : separatorIndex;
args.splice(insertIndex, 0, ...items);
}
function readLocalCheckMode(env: Env, defaultMode: LocalCheckMode) {
const raw = env.OPENCLAW_LOCAL_CHECK_MODE?.trim().toLowerCase();
if (raw === "throttled" || raw === "low-memory") {
return "throttled";
}
if (raw === "full" || raw === "fast") {
return "full";
}
return defaultMode;
}
function resolveHostResources(hostResources: Resources | undefined) {
if (hostResources) {
return hostResources;
}
return {
totalMemoryBytes: os.totalmem(),
logicalCpuCount:
typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length,
};
}
function readPositiveInt(rawValue: string | undefined, fallback: number, label: string) {
const text = rawValue?.trim();
if (!text) {
return fallback;
}
if (!/^\d+$/u.test(text)) {
throw new Error(`${label} must be a positive integer; got: ${rawValue}`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive integer; got: ${rawValue}`);
}
return parsed;
}
function writeOwnerFile(ownerPath: string, owner: unknown) {
fs.writeFileSync(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, "utf8");
}
function readOwnerFile(ownerPath: string): LockOwner | null {
try {
return JSON.parse(fs.readFileSync(ownerPath, "utf8"));
} catch {
return null;
}
}
function isAlreadyExistsError(error: unknown) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
}
function shouldReclaimLock(owner: LockOwner | null, lockDir: string, staleLockMs: number) {
if (owner && typeof owner.pid === "number") {
return !isProcessAlive(owner.pid);
}
try {
const stats = fs.statSync(lockDir);
return Date.now() - stats.mtimeMs >= staleLockMs;
} catch {
return true;
}
}
function isProcessAlive(pid: number) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EPERM");
}
}
function describeOwner(owner: LockOwner | null) {
if (!owner || typeof owner !== "object") {
return "";
}
const tool = typeof owner.tool === "string" ? owner.tool : "unknown-tool";
const pid = typeof owner.pid === "number" ? `pid ${owner.pid}` : "unknown pid";
const cwd = typeof owner.cwd === "string" ? owner.cwd : "unknown cwd";
return `${tool}, ${pid}, cwd ${cwd}`;
}
function formatElapsedMs(elapsedMs: number) {
if (elapsedMs < 1000) {
return `${elapsedMs}ms`;
}
const seconds = elapsedMs / 1000;
if (seconds < 60) {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainderSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainderSeconds}s`;
}
function sleepSync(ms: number) {
Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
}
-75
View File
@@ -1,75 +0,0 @@
// Holds the shared local heavy-check lock for a whole scripts/pr gate block so
// concurrent gate runs across .worktrees serialize before their first command
// instead of dying mid-test on child lock timeouts or no-output watchdog kills.
import fs from "node:fs";
import {
acquireLocalHeavyCheckLockSync,
resolveLocalHeavyCheckEnv,
} from "./lib/local-heavy-check-runtime.mts";
// A queued gate block legitimately waits out another full gate run; the
// 10-minute per-command default in the lock runtime is far too short for that.
const DEFAULT_GATE_LOCK_TIMEOUT_MS = 2 * 60 * 60 * 1000;
const PARENT_WATCH_INTERVAL_MS = 500;
function parseArgs(argv: string[]): { statusFile: string } {
const args = { statusFile: "" };
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--status-file") {
args.statusFile = argv[index + 1] ?? "";
index += 1;
continue;
}
throw new Error(`Unknown option: ${argv[index]}`);
}
if (!args.statusFile) {
throw new Error("Usage: node scripts/pr-gates-lock.mts --status-file <path>");
}
return args;
}
function main(): void {
const { statusFile } = parseArgs(process.argv.slice(2));
const baseEnv = resolveLocalHeavyCheckEnv(process.env);
const env = baseEnv.OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS
? baseEnv
: { ...baseEnv, OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS: String(DEFAULT_GATE_LOCK_TIMEOUT_MS) };
const parentPid = process.ppid;
const release = acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env,
toolName: "pr-gates",
});
let released = false;
const releaseOnce = () => {
if (released) {
return;
}
released = true;
release();
};
process.on("exit", releaseOnce);
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(signal, () => {
releaseOnce();
process.exit(0);
});
}
fs.writeFileSync(statusFile, "acquired\n");
// The owner-pid reclaim in the lock runtime already covers a SIGKILLed
// holder; this watch releases within half a second when the gate shell dies.
// ppid 1 also counts as dead: orphans reparent to init/launchd, and a
// helper that started orphaned has no gate block to hold the lock for.
setInterval(() => {
if (process.ppid !== parentPid || process.ppid === 1) {
releaseOnce();
process.exit(0);
}
}, PARENT_WATCH_INTERVAL_MS);
}
main();
-56
View File
@@ -122,59 +122,11 @@ resolve_pr_gates_remote_mode() {
esac
}
PR_GATES_LOCK_PID=""
PR_GATES_LOCK_STATUS_FILE=""
acquire_pr_gates_lock() {
# Serialize whole gate blocks across .worktrees on the shared heavy-check
# lock; a queued gate run waits here, before its first command, instead of
# dying on child lock timeouts or shard no-output watchdog kills mid-test.
if [ "${OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD:-}" = "1" ]; then
return 0
fi
PR_GATES_LOCK_STATUS_FILE=$(mktemp)
# Use the canonical helper: the PR branch under test may predate it.
local scripts_dir="${script_parent_dir:-}"
if [ -z "$scripts_dir" ]; then
scripts_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
fi
node "$scripts_dir/pr-gates-lock.mts" --status-file "$PR_GATES_LOCK_STATUS_FILE" &
PR_GATES_LOCK_PID=$!
while [ ! -s "$PR_GATES_LOCK_STATUS_FILE" ]; do
if ! kill -0 "$PR_GATES_LOCK_PID" 2>/dev/null; then
wait "$PR_GATES_LOCK_PID" 2>/dev/null || true
PR_GATES_LOCK_PID=""
echo "Failed to acquire the shared local heavy-check lock for prepare gates."
exit 1
fi
sleep 0.2
done
# Same held-lock contract check-changed uses for its children: gate stages
# must not re-acquire the lock the block holder already owns.
export OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD=1
export OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD=1
export OPENCLAW_OXLINT_SKIP_LOCK=1
}
prepare_local_gate_workspace() {
pin_worktree_bundled_plugins_dir
acquire_pr_gates_lock
bootstrap_deps_if_needed
}
release_pr_gates_lock() {
if [ -z "${PR_GATES_LOCK_PID:-}" ]; then
return 0
fi
kill "$PR_GATES_LOCK_PID" 2>/dev/null || true
wait "$PR_GATES_LOCK_PID" 2>/dev/null || true
PR_GATES_LOCK_PID=""
rm -f "$PR_GATES_LOCK_STATUS_FILE"
PR_GATES_LOCK_STATUS_FILE=""
unset OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD OPENCLAW_OXLINT_SKIP_LOCK
}
run_remote_testbox_full_test_gate() {
local label="$1"
local log_file="$2"
@@ -320,7 +272,6 @@ run_prepare_push_retry_gates() {
local remote_gates_run_url=""
if [ "$docs_only" = "true" ]; then
release_pr_gates_lock
gates_mode="docs_only"
# No test ran: carry the prior full-gates proof and how it was produced.
full_gates_head="${FULL_GATES_HEAD_SHA:-}"
@@ -328,7 +279,6 @@ run_prepare_push_retry_gates() {
remote_gates_lease_id="${REMOTE_GATES_LEASE_ID:-}"
remote_gates_run_url="${REMOTE_GATES_RUN_URL:-}"
elif [ "$gates_remote_mode" = "testbox" ]; then
release_pr_gates_lock
gates_mode="remote_testbox"
run_remote_testbox_full_test_gate \
"pnpm test (lease-retry, blacksmith-testbox)" \
@@ -342,7 +292,6 @@ run_prepare_push_retry_gates() {
echo "Remote testbox lease-retry gate stamp: $remote_gates_lease_id${remote_gates_run_url:+ ($remote_gates_run_url)}"
else
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
release_pr_gates_lock
fi
write_gates_env_stamp \
@@ -476,7 +425,6 @@ prepare_gates() {
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check
if [ "$docs_only" = "true" ]; then
release_pr_gates_lock
gates_mode="docs_only"
previous_full_gates_head=""
remote_gates_provider=""
@@ -484,9 +432,6 @@ prepare_gates() {
remote_gates_run_url=""
echo "Docs-only change detected with high confidence; skipping pnpm test."
elif [ "$gates_remote_mode" = "testbox" ]; then
# The full suite runs on a Blacksmith Testbox, so free the local lock
# for other heavy work while we wait on remote proof.
release_pr_gates_lock
gates_mode="remote_testbox"
echo "Running pnpm test on Blacksmith Testbox (OPENCLAW_PR_GATES_REMOTE=testbox)."
run_remote_testbox_full_test_gate \
@@ -512,7 +457,6 @@ prepare_gates() {
echo "Running pnpm test with host-aware scheduling defaults."
run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test
fi
release_pr_gates_lock
remote_gates_provider=""
remote_gates_lease_id=""
remote_gates_run_url=""
@@ -16,11 +16,12 @@ import {
MAX_TIMER_TIMEOUT_MS,
resolveTimerTimeoutMs,
} from "../packages/normalization-core/src/number-coercion.ts";
import { acquireExtensionPackageBoundaryArtifactLockSync } from "./lib/extension-package-boundary-artifact-lock.mts";
import {
ensureRepoToolNodeModulesLink,
isLocalCheckEnabled,
resolveRepoToolBinPath,
} from "./lib/local-heavy-check-runtime.mts";
} from "./lib/local-check-runtime.mts";
import { parsePositiveInt } from "./lib/numeric-options.mjs";
import {
listPluginSdkDeclarationOutputs,
@@ -989,7 +990,7 @@ export async function runNodeStepsInParallel(steps: NodeStep[]) {
}
/**
* Chooses serial or parallel artifact execution based on local heavy-check policy.
* Chooses serial or parallel artifact execution based on local check policy.
*/
export async function runNodeSteps(steps: NodeStep[], env: NodeJS.ProcessEnv = process.env) {
if (!isLocalCheckEnabled(env)) {
@@ -1108,7 +1109,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
prerequisiteSteps.push({
label: "plugin-sdk boundary dts",
args: [runTsgoScript, "-p", "tsconfig.plugin-sdk.dts.json", "--declaration", "true"],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: ROOT_BOUNDARY_TIMEOUT_MS,
stamp: {
path: ROOT_DTS_STAMP,
@@ -1127,7 +1127,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
prerequisiteSteps.push({
label: "plugin-sdk package boundary dts",
args: [runTsgoScript, "-p", "packages/plugin-sdk/tsconfig.json", "--declaration", "true"],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: ROOT_BOUNDARY_TIMEOUT_MS,
stamp: {
path: PACKAGE_DTS_STAMP,
@@ -1162,7 +1161,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
"--tsBuildInfoFile",
"dist/plugin-sdk/extensions/qa-channel/.tsbuildinfo",
],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: 300_000,
stamp: {
path: QA_CHANNEL_DTS_STAMP,
@@ -1196,7 +1194,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
"--tsBuildInfoFile",
"dist/plugin-sdk/extensions/memory-core/.tsbuildinfo",
],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: 300_000,
stamp: {
path: MEMORY_CORE_DTS_STAMP,
@@ -1230,7 +1227,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
"--tsBuildInfoFile",
"dist/plugin-sdk/extensions/matrix/.tsbuildinfo",
],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: 300_000,
stamp: {
path: MATRIX_DTS_STAMP,
@@ -1264,7 +1260,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
"--tsBuildInfoFile",
"dist/plugin-sdk/extensions/discord/.tsbuildinfo",
],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: 300_000,
stamp: {
path: DISCORD_DTS_STAMP,
@@ -1298,7 +1293,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
"--tsBuildInfoFile",
"dist/plugin-sdk/extensions/slack/.tsbuildinfo",
],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: 300_000,
stamp: {
path: SLACK_DTS_STAMP,
@@ -1332,7 +1326,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
"--tsBuildInfoFile",
"dist/plugin-sdk/extensions/whatsapp/.tsbuildinfo",
],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: 300_000,
stamp: {
path: WHATSAPP_DTS_STAMP,
@@ -1366,7 +1359,6 @@ async function main(argv: string[] = process.argv.slice(2)) {
"--tsBuildInfoFile",
"dist/plugin-sdk/extensions/telegram/.tsbuildinfo",
],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: 300_000,
stamp: {
path: TELEGRAM_DTS_STAMP,
@@ -1421,10 +1413,15 @@ async function main(argv: string[] = process.argv.slice(2)) {
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
process.exitCode = 1;
}
}
if (import.meta.main) {
await main();
const releaseArtifactLock = acquireExtensionPackageBoundaryArtifactLockSync(repoRoot);
try {
await main();
} finally {
releaseArtifactLock();
}
}
+24 -41
View File
@@ -6,12 +6,7 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
acquireLocalHeavyCheckLockSync,
applyLocalTsgoPolicy,
resolveRepoToolBinPath,
shouldAcquireLocalHeavyCheckLockForTsgo,
} from "./lib/local-heavy-check-runtime.mts";
import { applyLocalTsgoPolicy, resolveRepoToolBinPath } from "./lib/local-check-runtime.mts";
import { createManagedCommandInvocation } from "./lib/managed-child-process.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { TSGO_CORE_TEST_SHARDS, type TsgoCoreTestShard } from "./lib/tsgo-core-test-shards.mts";
@@ -170,43 +165,31 @@ function runTsgo(
typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length,
totalMemoryBytes: os.totalmem(),
});
const releaseLock = shouldAcquireLocalHeavyCheckLockForTsgo(finalArgs, env)
? acquireLocalHeavyCheckLockSync({
cwd: repoRoot,
env,
toolName: "tsgo-profile",
})
: () => {};
const startedAt = Date.now();
try {
const tsgo = createManagedCommandInvocation({
args: finalArgs,
bin: tsgoPath,
env,
});
const result = spawnSync(tsgo.command, tsgo.args, {
cwd: repoRoot,
env,
encoding: "utf8",
maxBuffer: params.maxBuffer ?? 128 * 1024 * 1024,
shell: tsgo.shell,
windowsVerbatimArguments: tsgo.windowsVerbatimArguments,
});
const elapsedMs = Date.now() - startedAt;
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
const output = [stdout, stderr].filter(Boolean).join("\n");
throw new Error(`${label} failed with exit code ${result.status ?? 1}\n${output}`);
}
return { elapsedMs, stdout, stderr };
} finally {
releaseLock();
const tsgo = createManagedCommandInvocation({
args: finalArgs,
bin: tsgoPath,
env,
});
const result = spawnSync(tsgo.command, tsgo.args, {
cwd: repoRoot,
env,
encoding: "utf8",
maxBuffer: params.maxBuffer ?? 128 * 1024 * 1024,
shell: tsgo.shell,
windowsVerbatimArguments: tsgo.windowsVerbatimArguments,
});
const elapsedMs = Date.now() - startedAt;
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
const output = [stdout, stderr].filter(Boolean).join("\n");
throw new Error(`${label} failed with exit code ${result.status ?? 1}\n${output}`);
}
return { elapsedMs, stdout, stderr };
}
function parseDiagnostics(output: string): Diagnostics {
+1 -1
View File
@@ -6,7 +6,7 @@ import { pathToFileURL } from "node:url";
import {
ensureRepoToolNodeModulesLink,
resolveRepoToolBinPath,
} from "./lib/local-heavy-check-runtime.mts";
} from "./lib/local-check-runtime.mts";
function run(command: string, args: string[], options: SpawnSyncOptions) {
const result = spawnSync(command, args, options);
+51 -78
View File
@@ -4,12 +4,10 @@ import fs, { type Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
acquireLocalHeavyCheckLockSync,
ensureRepoToolNodeModulesLink,
resolveLocalHeavyCheckEnv,
resolveLocalCheckEnv,
resolveRepoToolBinPath,
shouldAcquireLocalHeavyCheckLockForOxlint,
} from "./lib/local-heavy-check-runtime.mts";
} from "./lib/local-check-runtime.mts";
import { shouldPrepareExtensionPackageBoundaryArtifacts } from "./run-oxlint.mts";
const DEFAULT_WINDOWS_EXTENSION_CHUNK_SIZE = 8;
@@ -254,86 +252,62 @@ export async function main(
) {
const runner = path.resolve("scripts", "run-oxlint.mjs");
const shardArgs = parseShardRunnerArgs(extraArgs);
const env = resolveLocalHeavyCheckEnv(runtimeEnv);
const hasMetadataOnlyFlag = shardArgs.oxlintArgs.some((arg) =>
["--help", "-h", "--version", "-V", "--rules", "--print-config", "--init"].includes(arg),
const env = resolveLocalCheckEnv(runtimeEnv);
const shards = createOxlintShards({
cwd: process.cwd(),
env,
platform: process.platform,
splitCore: shardArgs.splitCore,
});
const selectedShards = selectCoreOxlintStripe(
filterOxlintShards(shards, shardArgs.only),
shardArgs.coreStripe,
);
const shouldAcquireParentLock =
!hasMetadataOnlyFlag ||
shouldAcquireLocalHeavyCheckLockForOxlint(shardArgs.oxlintArgs, {
cwd: process.cwd(),
env,
});
const releaseLock =
env.OPENCLAW_OXLINT_SKIP_LOCK === "1"
? () => {}
: shouldAcquireParentLock
? acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env,
toolName: "oxlint shards",
})
: () => {};
try {
const shards = createOxlintShards({
cwd: process.cwd(),
ensureRepoToolNodeModulesLink(resolveRepoToolBinPath("oxlint"));
const prepareResult = shouldPrepareExtensionPackageBoundaryArtifactsForShards(
selectedShards,
shardArgs.oxlintArgs,
)
? spawnSync(
process.execPath,
[
"--import",
"tsx",
path.resolve("scripts", "prepare-extension-package-boundary-artifacts.mts"),
],
{
stdio: "inherit",
env,
},
)
: undefined;
if (prepareResult?.error) {
throw prepareResult.error;
}
if (prepareResult && (prepareResult.status ?? 1) !== 0) {
process.exitCode = prepareResult.status ?? 1;
} else {
const shardConcurrency = resolveOxlintShardConcurrency({
env,
platform: process.platform,
splitCore: shardArgs.splitCore,
});
const selectedShards = selectCoreOxlintStripe(
filterOxlintShards(shards, shardArgs.only),
shardArgs.coreStripe,
const hostResources = resolveHostResources();
// stderr: stdout may carry machine-readable oxlint output for callers.
console.error(
`[oxlint] shard concurrency ${Math.max(1, Math.min(shardConcurrency, selectedShards.length))} ` +
`(cpus=${hostResources.logicalCpuCount}, memGB=${Math.round(hostResources.totalMemoryBytes / 1024 ** 3)})`,
);
ensureRepoToolNodeModulesLink(resolveRepoToolBinPath("oxlint"));
const prepareResult = shouldPrepareExtensionPackageBoundaryArtifactsForShards(
selectedShards,
shardArgs.oxlintArgs,
)
? spawnSync(
process.execPath,
[
"--import",
"tsx",
path.resolve("scripts", "prepare-extension-package-boundary-artifacts.mts"),
],
{
stdio: "inherit",
env,
},
)
: undefined;
if (prepareResult?.error) {
throw prepareResult.error;
}
if (prepareResult && (prepareResult.status ?? 1) !== 0) {
process.exitCode = prepareResult.status ?? 1;
} else {
const shardConcurrency = resolveOxlintShardConcurrency({
env,
platform: process.platform,
splitCore: shardArgs.splitCore,
});
const hostResources = resolveHostResources();
// stderr: stdout may carry machine-readable oxlint output for callers.
console.error(
`[oxlint] shard concurrency ${Math.max(1, Math.min(shardConcurrency, selectedShards.length))} ` +
`(cpus=${hostResources.logicalCpuCount}, memGB=${Math.round(hostResources.totalMemoryBytes / 1024 ** 3)})`,
);
const results = await runShards({
concurrency: Math.max(1, Math.min(shardConcurrency, selectedShards.length)),
entries: selectedShards,
env,
extraArgs: shardArgs.oxlintArgs,
runner,
});
process.exitCode = results.find((status) => status !== 0) ?? 0;
}
} finally {
releaseLock();
const results = await runShards({
concurrency: Math.max(1, Math.min(shardConcurrency, selectedShards.length)),
entries: selectedShards,
env,
extraArgs: shardArgs.oxlintArgs,
runner,
});
process.exitCode = results.find((status) => status !== 0) ?? 0;
}
}
@@ -540,7 +514,6 @@ export async function runShard({ env, extraArgs, runner, shard }: ShardRunnerOpt
detached: useProcessGroup,
env: {
...env,
OPENCLAW_OXLINT_SKIP_LOCK: "1",
OPENCLAW_OXLINT_SKIP_PREPARE: "1",
},
});
+18 -51
View File
@@ -1,16 +1,14 @@
// Runs oxlint with local heavy-check policy, sparse-checkout filtering, and
// Runs oxlint with local resource policy, sparse-checkout filtering, and
// plugin package-boundary artifact preparation when needed.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
acquireLocalHeavyCheckLockSync,
applyLocalOxlintPolicy,
resolveLocalHeavyCheckEnv,
resolveLocalCheckEnv,
resolveRepoToolBinPath,
shouldAcquireLocalHeavyCheckLockForOxlint,
} from "./lib/local-heavy-check-runtime.mts";
} from "./lib/local-check-runtime.mts";
import { createManagedCommandInvocation, runManagedCommand } from "./lib/managed-child-process.mts";
import { resolvePathEnvKey } from "./windows-cmd-helpers.mjs";
@@ -227,27 +225,14 @@ function resolveOxlintToolchainEnv(
}
async function prepareExtensionPackageBoundaryArtifacts(env: NodeJS.ProcessEnv) {
const releaseArtifactsLock = acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
const status = await runManagedCommand({
bin: process.execPath,
args: PREPARE_EXTENSION_BOUNDARY_ARGS,
env,
toolName: "extension-package-boundary-artifacts",
lockName: "extension-package-boundary-artifacts",
});
try {
const status = await runManagedCommand({
bin: process.execPath,
args: PREPARE_EXTENSION_BOUNDARY_ARGS,
env,
});
if (status !== 0) {
throw new Error(
`prepare-extension-package-boundary-artifacts failed with exit code ${status}`,
);
}
} finally {
releaseArtifactsLock();
if (status !== 0) {
throw new Error(`prepare-extension-package-boundary-artifacts failed with exit code ${status}`);
}
}
@@ -260,7 +245,7 @@ async function main(
) {
const focusedConfig = argv.includes(OPENCLAW_FOCUSED_CONFIG_FLAG);
const oxlintArgs = argv.filter((arg) => arg !== OPENCLAW_FOCUSED_CONFIG_FLAG);
const localEnv = resolveLocalHeavyCheckEnv(runtimeEnv);
const localEnv = resolveLocalCheckEnv(runtimeEnv);
// Focused configs are syntax-only guards; keep wrapper process handling
// without the broad type-aware policy or package artifact preparation.
const { args: policyArgs, env } = focusedConfig
@@ -292,34 +277,16 @@ async function main(
return;
}
const releaseLock =
env.OPENCLAW_OXLINT_SKIP_LOCK === "1" || focusedConfig
? () => {}
: shouldAcquireLocalHeavyCheckLockForOxlint(finalArgs, {
cwd: process.cwd(),
env,
})
? acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env,
toolName: "oxlint",
})
: () => {};
try {
if (needsArtifactPreparation) {
await prepareExtensionPackageBoundaryArtifacts(env);
}
const status = await runManagedCommand({
bin: oxlintPath,
args: finalArgs,
env: resolveOxlintToolchainEnv(oxlintPath, env),
});
process.exitCode = status;
} finally {
releaseLock();
if (needsArtifactPreparation) {
await prepareExtensionPackageBoundaryArtifacts(env);
}
const status = await runManagedCommand({
bin: oxlintPath,
args: finalArgs,
env: resolveOxlintToolchainEnv(oxlintPath, env),
});
process.exitCode = status;
}
if (import.meta.main) {
+1 -1
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import {
ensureRepoToolNodeModulesLink,
resolveRepoToolBinPath,
} from "./lib/local-heavy-check-runtime.mts";
} from "./lib/local-check-runtime.mts";
const stylelintPath = resolveRepoToolBinPath("stylelint");
ensureRepoToolNodeModulesLink(stylelintPath);
+20 -35
View File
@@ -1,13 +1,10 @@
#!/usr/bin/env node
// Run bounded test graphs in fresh processes so one shard's checker heap cannot
// accumulate while the next shard loads. Hold one lock across the full sequence.
// accumulate while the next shard loads.
import { spawn, type ChildProcess } from "node:child_process";
import path from "node:path";
import {
acquireLocalHeavyCheckLockSync,
resolveLocalHeavyCheckEnv,
} from "./lib/local-heavy-check-runtime.mts";
import { resolveLocalCheckEnv } from "./lib/local-check-runtime.mts";
import { signalExitCode } from "./lib/managed-child-process.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import {
@@ -48,15 +45,7 @@ if (concurrencyFlagIndex >= 0) {
process.exit(1);
}
}
const env = resolveLocalHeavyCheckEnv(process.env);
const releaseLock =
env.OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD === "1"
? () => {}
: acquireLocalHeavyCheckLockSync({
cwd: repoRoot,
env,
toolName: "tsgo:core:test",
});
const env = resolveLocalCheckEnv(process.env);
function runShard(config: string): Promise<number> {
return new Promise((resolve, reject) => {
@@ -65,7 +54,7 @@ function runShard(config: string): Promise<number> {
[path.join(repoRoot, "scripts/run-tsgo.mjs"), "-b", config, "--builders", "1"],
{
cwd: repoRoot,
env: { ...env, OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
env,
stdio: "inherit",
},
);
@@ -76,26 +65,22 @@ function runShard(config: string): Promise<number> {
});
}
try {
const queue = [...shards];
let failureCode = 0;
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
for (;;) {
const shard = queue.shift();
// Stop draining after the first failure so the exit stays prompt.
if (!shard || failureCode !== 0) {
return;
}
const code = await runShard(shard.config);
if (code !== 0 && failureCode === 0) {
failureCode = code;
}
const queue = [...shards];
let failureCode = 0;
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
for (;;) {
const shard = queue.shift();
// Stop draining after the first failure so the exit stays prompt.
if (!shard || failureCode !== 0) {
return;
}
const code = await runShard(shard.config);
if (code !== 0 && failureCode === 0) {
failureCode = code;
}
});
await Promise.all(workers);
if (failureCode !== 0) {
process.exitCode = failureCode;
}
} finally {
releaseLock();
});
await Promise.all(workers);
if (failureCode !== 0) {
process.exitCode = failureCode;
}
+30 -46
View File
@@ -1,17 +1,15 @@
// Runs tsgo through local heavy-check policy and sparse-checkout guards.
// Runs tsgo through local resource policy and sparse-checkout guards.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readFlagValue } from "./lib/arg-utils.mts";
import {
acquireLocalHeavyCheckLockSync,
applyLocalTsgoPolicy,
ensureRepoToolNodeModulesLink,
resolveLocalHeavyCheckEnv,
resolveLocalCheckEnv,
resolveRepoToolBinPath,
shouldAcquireLocalHeavyCheckLockForTsgo,
} from "./lib/local-heavy-check-runtime.mts";
} from "./lib/local-check-runtime.mts";
import { createManagedCommandInvocation } from "./lib/managed-child-process.mts";
import {
getSparseTsgoGuardError,
@@ -26,7 +24,7 @@ function main(): void {
};
const { args: finalArgs, env } = applyLocalTsgoPolicy(
process.argv.slice(2),
resolveLocalHeavyCheckEnv(process.env),
resolveLocalCheckEnv(process.env),
hostResources,
);
@@ -36,49 +34,35 @@ function main(): void {
fs.mkdirSync(path.dirname(path.resolve(tsBuildInfoFile)), { recursive: true });
}
const sparseGuardError = getSparseTsgoGuardError(finalArgs, { cwd: process.cwd() });
const releaseLock =
sparseGuardError ||
env.OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD === "1" ||
!shouldAcquireLocalHeavyCheckLockForTsgo(finalArgs, env)
? () => {}
: acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env,
toolName: "tsgo",
});
try {
if (sparseGuardError) {
console.error(sparseGuardError);
if (shouldSkipSparseTsgoGuardError(env)) {
console.error("[tsgo] skipping sparse-missing project because OPENCLAW_TSGO_SPARSE_SKIP=1");
process.exitCode = 0;
} else {
process.exitCode = 1;
}
if (sparseGuardError) {
console.error(sparseGuardError);
if (shouldSkipSparseTsgoGuardError(env)) {
console.error("[tsgo] skipping sparse-missing project because OPENCLAW_TSGO_SPARSE_SKIP=1");
process.exitCode = 0;
} else {
ensureRepoToolNodeModulesLink(tsgoPath);
const tsgo = createManagedCommandInvocation({
args: finalArgs,
bin: tsgoPath,
env,
});
const result = spawnSync(tsgo.command, tsgo.args, {
stdio: "inherit",
env,
shell: tsgo.shell,
windowsVerbatimArguments: tsgo.windowsVerbatimArguments,
});
if (result.error) {
throw result.error;
}
process.exitCode = result.status ?? 1;
process.exitCode = 1;
}
} finally {
releaseLock();
return;
}
ensureRepoToolNodeModulesLink(tsgoPath);
const tsgo = createManagedCommandInvocation({
args: finalArgs,
bin: tsgoPath,
env,
});
const result = spawnSync(tsgo.command, tsgo.args, {
stdio: "inherit",
env,
shell: tsgo.shell,
windowsVerbatimArguments: tsgo.windowsVerbatimArguments,
});
if (result.error) {
throw result.error;
}
process.exitCode = result.status ?? 1;
}
if (import.meta.main) {
+1 -39
View File
@@ -5,10 +5,6 @@ import fs from "node:fs";
import { performance } from "node:perf_hooks";
import pMap from "p-map";
import { formatMs } from "./lib/check-timing-summary.mts";
import {
acquireLocalHeavyCheckLockSync,
withLocalHeavyCheckLockHeld,
} from "./lib/local-heavy-check-runtime.mts";
import {
isCiLikeEnv,
resolveLocalFullSuiteProfile,
@@ -42,7 +38,6 @@ import {
resolveParallelFullSuiteConcurrency,
resolveChangedTestTargetPlanForArgs,
resolveChangedTargetArgs,
shouldAcquireLocalHeavyCheckLock,
shouldRetryVitestNoOutputTimeout,
type FailedVitestShard,
type VitestRunSpec as BaseVitestRunSpec,
@@ -59,19 +54,6 @@ type VitestCommandOutcome = {
type ShardTiming = NonNullable<ReturnType<typeof createShardTimingSample>>;
// Keep this shim so `pnpm test -- src/foo.test.ts` still forwards filters
// cleanly instead of leaking pnpm's passthrough sentinel to Vitest.
let releaseLock = () => {};
let lockReleased = false;
const releaseLockOnce = () => {
if (lockReleased) {
return;
}
lockReleased = true;
releaseLock();
};
function isWrapperMetadataRequest(args: string[]) {
for (const arg of args) {
if (arg === "--") {
@@ -193,7 +175,6 @@ async function runLoggedVitestSpec(spec: VitestRunSpec) {
}
if (result.signal) {
console.error(`[test] ${spec.config} exited by signal ${result.signal}`);
releaseLockOnce();
process.kill(process.pid, result.signal);
return null;
}
@@ -312,7 +293,7 @@ async function main() {
baseEnv,
cwd: process.cwd(),
});
let runSpecs = applyDefaultMultiSpecVitestCachePaths(
const runSpecs = applyDefaultMultiSpecVitestCachePaths(
applyDefaultVitestNoOutputTimeout(
applyFullExtensionsHeapBudget(rawRunSpecs, { env: baseEnv }),
{
@@ -328,21 +309,6 @@ async function main() {
return;
}
const acquiresLocalHeavyCheckLock = shouldAcquireLocalHeavyCheckLock(runSpecs, baseEnv);
releaseLock = acquiresLocalHeavyCheckLock
? acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env: baseEnv,
toolName: "test",
})
: () => {};
if (acquiresLocalHeavyCheckLock || baseEnv.OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD === "1") {
runSpecs = runSpecs.map((spec) => ({
...spec,
env: withLocalHeavyCheckLockHeld(spec.env),
}));
}
const isFullSuiteRun =
targetArgs.length === 0 &&
changedTargetArgs === null &&
@@ -390,7 +356,6 @@ async function main() {
for (const line of formatFailedShardDigest(failures)) {
console.error(line);
}
releaseLockOnce();
if (parallelExitCode !== 0) {
process.exitCode = parallelExitCode;
}
@@ -412,7 +377,6 @@ async function main() {
exitCode = exitCode || result.code;
if (spec.continueOnFailure !== true) {
printTestSummary("failed", timings.length, performance.now() - suiteStartedAt);
releaseLockOnce();
process.exitCode = result.code;
return;
}
@@ -425,7 +389,6 @@ async function main() {
performance.now() - suiteStartedAt,
);
releaseLockOnce();
if (exitCode !== 0) {
process.exitCode = exitCode;
}
@@ -444,7 +407,6 @@ function printTestSummary(
}
main().catch((error: unknown) => {
releaseLockOnce();
console.error(error);
process.exitCode = 1;
});
-25
View File
@@ -4223,31 +4223,6 @@ function filterPlansForContractIncludeFile(plans: VitestRunPlan[], env: NodeJS.P
});
}
export function shouldAcquireLocalHeavyCheckLock(
runSpecs: Array<Pick<VitestRunSpec, "config" | "includePatterns" | "watchMode">>,
env = process.env,
) {
if (env.OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD === "1") {
return false;
}
if (env.OPENCLAW_TEST_PROJECTS_FORCE_LOCK === "1") {
return true;
}
const runSpec = runSpecs.length === 1 ? runSpecs[0] : undefined;
if (!runSpec) {
return true;
}
return !(
(runSpec.config === TOOLING_VITEST_CONFIG ||
runSpec.config === TOOLING_ISOLATED_VITEST_CONFIG) &&
!runSpec.watchMode &&
Array.isArray(runSpec.includePatterns) &&
runSpec.includePatterns.length > 0
);
}
function expandVitestIncludePatterns(includePatterns: string[], cwd: string) {
const candidateFiles = includePatterns.some(isGlobTarget)
? listExplicitTestTargetFilesForCwd(cwd)
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateChannel } from "../../infra/update-channels.js";
type TestUpdateAvailable = {
currentVersion: string;
@@ -15,9 +16,10 @@ type TestUpdateSchedule =
| import("../../../packages/gateway-protocol/src/index.js").UpdateScheduleState
| null;
const checkUpdateStatusMock = vi.hoisted(() => vi.fn());
const versionMock = vi.hoisted(() => ({ value: "1.0.0" }));
const getUpdateAvailableMock = vi.hoisted(() => vi.fn<() => TestUpdateAvailable>(() => null));
const getUpdateEffectiveChannelMock = vi.hoisted(() =>
vi.fn<() => Promise<UpdateChannel>>(async () => "stable"),
);
const getUpdateScheduleMock = vi.hoisted(() => vi.fn<() => TestUpdateSchedule>(() => null));
const refreshGatewayUpdateStatusMock = vi.hoisted(() => vi.fn(async () => {}));
const getLatestUpdateRestartSentinelMock = vi.hoisted(() =>
@@ -27,29 +29,13 @@ const refreshLatestUpdateRestartSentinelMock = vi.hoisted(() =>
vi.fn<() => Promise<TestUpdateSentinel>>(async () => null),
);
vi.mock("../../infra/openclaw-root.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/openclaw-root.js")>(
"../../infra/openclaw-root.js",
);
return { ...actual, resolveOpenClawPackageRoot: async () => "/tmp/openclaw" };
});
vi.mock("../../infra/update-check.js", () => ({
checkUpdateStatus: checkUpdateStatusMock,
}));
vi.mock("../../infra/update-startup.js", () => ({
getUpdateAvailable: getUpdateAvailableMock,
getUpdateEffectiveChannel: getUpdateEffectiveChannelMock,
getUpdateSchedule: getUpdateScheduleMock,
refreshGatewayUpdateStatus: refreshGatewayUpdateStatusMock,
}));
vi.mock("../../version.js", () => ({
get VERSION() {
return versionMock.value;
},
}));
vi.mock("../server-restart-sentinel.js", () => ({
getLatestUpdateRestartSentinel: getLatestUpdateRestartSentinelMock,
refreshLatestUpdateRestartSentinel: refreshLatestUpdateRestartSentinelMock,
@@ -60,10 +46,10 @@ vi.mock("./validation.js", () => ({
}));
beforeEach(() => {
versionMock.value = "1.0.0";
checkUpdateStatusMock.mockReset();
getUpdateAvailableMock.mockReset();
getUpdateAvailableMock.mockReturnValue(null);
getUpdateEffectiveChannelMock.mockReset();
getUpdateEffectiveChannelMock.mockResolvedValue("stable");
getUpdateScheduleMock.mockReset();
getUpdateScheduleMock.mockReturnValue(null);
refreshGatewayUpdateStatusMock.mockReset();
@@ -75,13 +61,8 @@ beforeEach(() => {
});
describe("update.status effective channel", () => {
it("reports a verified configless extended-stable package channel", async () => {
versionMock.value = "2026.6.33";
checkUpdateStatusMock.mockResolvedValueOnce({
root: "/tmp/openclaw",
installKind: "package",
packageManager: "npm",
});
it("reports the lifecycle-owned channel before the startup schedule is ready", async () => {
getUpdateEffectiveChannelMock.mockResolvedValueOnce("extended-stable");
const { updateHandlers } = await import("./update.js");
const respond = vi.fn();
@@ -99,6 +80,90 @@ describe("update.status effective channel", () => {
true,
expect.objectContaining({ effectiveChannel: "extended-stable" }),
);
expect(refreshGatewayUpdateStatusMock).not.toHaveBeenCalled();
});
it("prefers the current config channel over the startup schedule", async () => {
getUpdateScheduleMock.mockReturnValueOnce({ channel: "beta", autoEnabled: true });
const { updateHandlers } = await import("./update.js");
const respond = vi.fn();
const handler = updateHandlers["update.status"];
if (!handler) {
throw new Error("update.status handler is unavailable");
}
await handler({
params: {},
respond,
context: { getRuntimeConfig: () => ({ update: { channel: "dev" } }) },
} as never);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ effectiveChannel: "dev" }),
);
expect(getUpdateEffectiveChannelMock).not.toHaveBeenCalled();
});
it("scopes explicit checkout refreshes to the current config identity", async () => {
const { updateHandlers } = await import("./update.js");
const handler = updateHandlers["update.status"];
if (!handler) {
throw new Error("update.status handler is unavailable");
}
const config = { update: { channel: "dev" as const } };
const context = { getRuntimeConfig: () => config };
await handler({ params: {}, respond: vi.fn(), context } as never);
expect(refreshGatewayUpdateStatusMock).not.toHaveBeenCalled();
let settleRefresh: (() => void) | undefined;
refreshGatewayUpdateStatusMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
settleRefresh = resolve;
}),
);
const first = handler({
params: { refreshCheckout: true },
respond: vi.fn(),
context,
} as never);
const second = handler({
params: { refreshCheckout: true },
respond: vi.fn(),
context,
} as never);
await vi.waitFor(() => expect(refreshGatewayUpdateStatusMock).toHaveBeenCalledTimes(1));
await handler({
params: { refreshCheckout: true },
respond: vi.fn(),
context: { getRuntimeConfig: () => ({ update: { channel: "beta" } }) },
} as never);
expect(refreshGatewayUpdateStatusMock).toHaveBeenCalledTimes(2);
settleRefresh?.();
await Promise.all([first, second]);
await handler({ params: { refreshCheckout: true }, respond: vi.fn(), context } as never);
expect(refreshGatewayUpdateStatusMock).toHaveBeenCalledTimes(3);
});
it("keeps status available when install identity initialization fails", async () => {
getUpdateEffectiveChannelMock.mockRejectedValueOnce(new Error("probe failed"));
const warn = vi.fn();
const { updateHandlers } = await import("./update.js");
const respond = vi.fn();
const handler = updateHandlers["update.status"];
if (!handler) {
throw new Error("update.status handler is unavailable");
}
await handler({ params: {}, respond, context: { logGateway: { warn } } } as never);
expect(warn).toHaveBeenCalledWith("update.status install identity failed: probe failed");
expect(respond).toHaveBeenCalledWith(true, { sentinel: null, updateAvailable: null });
});
it("refreshes the latest update sentinel before responding", async () => {
@@ -138,6 +203,7 @@ describe("update.status effective channel", () => {
schedule: expect.objectContaining({ channel: "beta" }),
}),
);
expect(getUpdateEffectiveChannelMock).not.toHaveBeenCalled();
});
it("falls back to the cached update sentinel when refresh fails", async () => {
+31 -33
View File
@@ -32,7 +32,6 @@ import {
normalizeUpdateChannel,
resolveEffectiveUpdateChannel,
} from "../../infra/update-channels.js";
import { checkUpdateStatus } from "../../infra/update-check.js";
import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "../../infra/update-control-plane-sentinel.js";
import { devUpdateTargetFromGitCampaign } from "../../infra/update-dev-target.js";
import { resolveUpdateInstallRoot } from "../../infra/update-install-root.js";
@@ -54,6 +53,7 @@ import {
import { resolveUpdateInstallSurface, runGatewayUpdate } from "../../infra/update-runner.js";
import {
getUpdateAvailable,
getUpdateEffectiveChannel,
getUpdateSchedule,
refreshGatewayUpdateStatus,
} from "../../infra/update-startup.js";
@@ -86,30 +86,22 @@ function tryResolveProcessCwd(): string | undefined {
}
}
async function resolveGatewayEffectiveUpdateChannel(
configChannel: ReturnType<typeof normalizeUpdateChannel>,
) {
const invocationCwd = tryResolveProcessCwd();
const root = await resolveOpenClawPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
...(invocationCwd ? { cwd: invocationCwd } : {}),
});
const status = await checkUpdateStatus({
root,
timeoutMs: 2500,
fetchGit: false,
includeRegistry: false,
});
if (status.installKind === "unknown") {
return null;
// Explicit callers share only active checkout work for the exact config snapshot.
// Reloaded config must never join work started under an older snapshot.
const updateStatusCheckoutRefreshes = new WeakMap<OpenClawConfig, Promise<void>>();
function refreshUpdateStatusCheckout(config: OpenClawConfig): Promise<void> {
const current = updateStatusCheckoutRefreshes.get(config);
if (current) {
return current;
}
return resolveEffectiveUpdateChannel({
configChannel,
currentVersion: VERSION,
installKind: status.installKind,
git: status.git,
}).channel;
const refresh = refreshGatewayUpdateStatus(config).finally(() => {
if (updateStatusCheckoutRefreshes.get(config) === refresh) {
updateStatusCheckoutRefreshes.delete(config);
}
});
updateStatusCheckoutRefreshes.set(config, refresh);
return refresh;
}
async function readPreUpdateConfigForPostCoreFinalize(): Promise<
@@ -182,22 +174,28 @@ export const updateHandlers: GatewayRequestHandlers = {
);
sentinel = getLatestUpdateRestartSentinel();
}
const configChannel = context?.getRuntimeConfig
? normalizeUpdateChannel(context.getRuntimeConfig().update?.channel)
: null;
if (context?.getRuntimeConfig) {
const config = context?.getRuntimeConfig?.();
const configChannel = normalizeUpdateChannel(config?.update?.channel);
if (params.refreshCheckout === true && config) {
try {
await refreshGatewayUpdateStatus(context.getRuntimeConfig());
await refreshUpdateStatusCheckout(config);
} catch (err) {
context.logGateway?.warn(
context?.logGateway?.warn(
`update.status checkout refresh failed: ${formatUpdateRunErrorMessage(err)}`,
);
}
}
const schedule = getUpdateSchedule();
const effectiveChannel = await resolveGatewayEffectiveUpdateChannel(configChannel).catch(
() => null,
);
let effectiveChannel = configChannel ?? normalizeUpdateChannel(schedule?.channel);
if (!effectiveChannel) {
try {
effectiveChannel = await getUpdateEffectiveChannel();
} catch (err) {
context?.logGateway?.warn(
`update.status install identity failed: ${formatUpdateRunErrorMessage(err)}`,
);
}
}
const result = {
sentinel,
updateAvailable: getUpdateAvailable(),
+21 -2
View File
@@ -32,6 +32,11 @@ const hoisted = vi.hoisted(() => {
const startupHookEvent = { type: "gateway", action: "startup", sessionKey: "gateway:startup" };
const createInternalHookEvent = vi.fn(() => startupHookEvent);
const triggerInternalHook = vi.fn(async () => {});
const initializeGatewayUpdateStatus = vi.fn(async () => ({
root: null,
status: { root: null, installKind: "unknown" as const, packageManager: "unknown" as const },
installReceipt: null,
}));
const scheduleGatewayUpdateCheck = vi.fn(() => () => {});
const startGatewayTailscaleExposure = vi.fn(async () => null);
const logGatewayStartup = vi.fn();
@@ -95,6 +100,7 @@ const hoisted = vi.hoisted(() => {
startupHookEvent,
createInternalHookEvent,
triggerInternalHook,
initializeGatewayUpdateStatus,
scheduleGatewayUpdateCheck,
startGatewayTailscaleExposure,
logGatewayStartup,
@@ -200,6 +206,7 @@ vi.mock("./server-startup-log.js", () => ({
}));
vi.mock("../infra/update-startup.js", () => ({
initializeGatewayUpdateStatus: hoisted.initializeGatewayUpdateStatus,
scheduleGatewayUpdateCheck: hoisted.scheduleGatewayUpdateCheck,
}));
@@ -475,6 +482,7 @@ describe("startGatewayPostAttachRuntime", () => {
hoisted.hasInternalHookListeners.mockReturnValue(false);
hoisted.createInternalHookEvent.mockClear();
hoisted.triggerInternalHook.mockClear();
hoisted.initializeGatewayUpdateStatus.mockClear();
hoisted.scheduleGatewayUpdateCheck.mockClear();
hoisted.startGatewayTailscaleExposure.mockClear();
hoisted.logGatewayStartup.mockClear();
@@ -985,6 +993,14 @@ describe("startGatewayPostAttachRuntime", () => {
it("starts the gateway update check after post-attach returns", async () => {
const events: string[] = [];
const stopUpdateCheck = vi.fn();
const initializeGatewayUpdateStatus = vi.fn(async () => {
events.push("install-identity");
return {
root: null,
status: { root: null, installKind: "unknown" as const, packageManager: "unknown" as const },
installReceipt: null,
};
});
const scheduleGatewayUpdateCheck = vi.fn(async () => {
events.push("update-check");
return stopUpdateCheck;
@@ -997,6 +1013,7 @@ describe("startGatewayPostAttachRuntime", () => {
const result = await startGatewayPostAttachRuntime(
createPostAttachParams(),
createPostAttachRuntimeDeps({
initializeGatewayUpdateStatus,
refreshLatestUpdateRestartSentinel: vi.fn(async () => null),
scheduleGatewayUpdateCheck,
startGatewaySidecars: startGatewaySidecarsItem,
@@ -1004,13 +1021,14 @@ describe("startGatewayPostAttachRuntime", () => {
);
events.push("returned");
expect(initializeGatewayUpdateStatus).toHaveBeenCalledTimes(1);
expect(scheduleGatewayUpdateCheck).not.toHaveBeenCalled();
expect(events).toEqual(["sidecars", "returned"]);
expect(events).toEqual(["sidecars", "install-identity", "returned"]);
await waitForGatewayTestState(() => {
expect(scheduleGatewayUpdateCheck).toHaveBeenCalledTimes(1);
});
expect(events).toEqual(["sidecars", "returned", "update-check"]);
expect(events).toEqual(["sidecars", "install-identity", "returned", "update-check"]);
result.stopGatewayUpdateCheck();
expect(stopUpdateCheck).toHaveBeenCalledTimes(1);
@@ -3480,6 +3498,7 @@ function createPostAttachRuntimeDeps(
getGlobalHookRunner: vi.fn(() => null),
logGatewayStartup: hoisted.logGatewayStartup,
refreshLatestUpdateRestartSentinel: hoisted.refreshLatestUpdateRestartSentinel,
initializeGatewayUpdateStatus: hoisted.initializeGatewayUpdateStatus,
scheduleGatewayUpdateCheck: hoisted.scheduleGatewayUpdateCheck,
startGatewaySidecars: vi.fn(async () => ({ pluginServices: null, postReadySidecars: [] })),
warmSystemCa: vi.fn(async () => {}),
+14 -1
View File
@@ -10,7 +10,10 @@ import { hasConfiguredInternalHooks } from "../hooks/configured.js";
import { isTruthyEnvValue } from "../infra/env.js";
import type { GatewayActiveWorkInspectors } from "../infra/gateway-active-work.js";
import { hasRestartSentinel } from "../infra/restart-sentinel.js";
import type { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
import type {
initializeGatewayUpdateStatus,
scheduleGatewayUpdateCheck,
} from "../infra/update-startup.js";
import type { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { PluginHookGatewayCronService } from "../plugins/hook-types.js";
import type { loadOpenClawPlugins } from "../plugins/loader.js";
@@ -925,6 +928,7 @@ type GatewayPostAttachRuntimeDeps = {
refreshLatestUpdateRestartSentinel: () => Awaitable<
ReturnType<typeof refreshLatestUpdateRestartSentinel>
>;
initializeGatewayUpdateStatus: () => ReturnType<typeof initializeGatewayUpdateStatus>;
scheduleGatewayUpdateCheck: (
...args: Parameters<typeof scheduleGatewayUpdateCheck>
) => Awaitable<ReturnType<typeof scheduleGatewayUpdateCheck>>;
@@ -942,6 +946,8 @@ const defaultGatewayPostAttachRuntimeDeps: GatewayPostAttachRuntimeDeps = {
logGatewayStartup: async (params) =>
(await import("./server-startup-log.js")).logGatewayStartup(params),
refreshLatestUpdateRestartSentinel: refreshLatestUpdateRestartSentinelIfPresent,
initializeGatewayUpdateStatus: async () =>
(await import("../infra/update-startup.js")).initializeGatewayUpdateStatus(),
scheduleGatewayUpdateCheck: async (...args) =>
(await import("../infra/update-startup.js")).scheduleGatewayUpdateCheck(...args),
startGatewaySidecars,
@@ -1003,6 +1009,13 @@ function createDeferredGatewayUpdateCheck(params: {
return;
}
started = true;
// Install identity is process-stable and must be ready before clients can
// select a channel; registry and upstream discovery stay post-ready.
void params.runtimeDeps.initializeGatewayUpdateStatus().catch((err: unknown) => {
if (!stopped) {
params.log.warn(`gateway update status failed to initialize: ${String(err)}`);
}
});
// Update checks are intentionally post-attach so startup logging, sidecars,
// and Tailscale exposure are not serialized behind network I/O.
void (async () => {
+83 -19
View File
@@ -161,6 +161,7 @@ describe("update-startup", () => {
let runGatewayUpdateCheck: (typeof import("./update-startup.js"))["runGatewayUpdateCheck"];
let scheduleGatewayUpdateCheck: (typeof import("./update-startup.js"))["scheduleGatewayUpdateCheck"];
let getUpdateAvailable: (typeof import("./update-startup.js"))["getUpdateAvailable"];
let getUpdateEffectiveChannel: (typeof import("./update-startup.js"))["getUpdateEffectiveChannel"];
let getUpdateSchedule: (typeof import("./update-startup.js"))["getUpdateSchedule"];
let resetUpdateAvailableStateForTest: (typeof import("./update-startup.js"))["resetUpdateAvailableStateForTest"];
let loaded = false;
@@ -265,6 +266,7 @@ describe("update-startup", () => {
runGatewayUpdateCheck,
scheduleGatewayUpdateCheck,
getUpdateAvailable,
getUpdateEffectiveChannel,
getUpdateSchedule,
resetUpdateAvailableStateForTest,
} = await import("./update-startup.js"));
@@ -305,6 +307,64 @@ describe("update-startup", () => {
resetUpdateAvailableStateForTest();
});
it("exposes the installed-version channel before the schedule cache is ready", async () => {
versionMock.value = "2026.6.33";
mockPackageInstallStatus();
expect(getUpdateSchedule()).toBeNull();
await expect(getUpdateEffectiveChannel()).resolves.toBe("extended-stable");
});
it("retries install identity initialization after a failed probe", async () => {
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
vi.mocked(checkUpdateStatus).mockRejectedValueOnce(new Error("probe failed"));
await expect(getUpdateEffectiveChannel()).rejects.toThrow("probe failed");
mockPackageInstallStatus();
await expect(getUpdateEffectiveChannel()).resolves.toBe("stable");
expect(checkUpdateStatus).toHaveBeenCalledTimes(2);
});
it("coalesces configless Git identity before the schedule cache is ready", async () => {
let releaseStatus: ((status: UpdateCheckResult) => void) | undefined;
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
vi.mocked(checkUpdateStatus).mockImplementationOnce(
() =>
new Promise<UpdateCheckResult>((resolve) => {
releaseStatus = resolve;
}),
);
const first = getUpdateEffectiveChannel();
const second = getUpdateEffectiveChannel();
await vi.advanceTimersByTimeAsync(0);
expect(checkUpdateStatus).toHaveBeenCalledTimes(1);
releaseStatus?.({
root: "/opt/openclaw",
installKind: "git",
packageManager: "pnpm",
git: {
root: "/opt/openclaw",
sha: "current-sha",
tag: null,
branch: "main",
upstream: "origin/main",
upstreamSource: "tracking",
upstreamSha: "upstream-sha",
commitAtMs: null,
dirty: false,
ahead: 0,
behind: 0,
fetchOk: false,
},
});
await expect(Promise.all([first, second])).resolves.toEqual(["dev", "dev"]);
await expect(getUpdateEffectiveChannel()).resolves.toBe("dev");
expect(checkUpdateStatus).toHaveBeenCalledTimes(1);
});
function mockPackageUpdateStatus(tag = "latest", version = "2.0.0") {
mockPackageInstallStatus();
mockNpmChannelTag(tag, version);
@@ -586,49 +646,41 @@ describe("update-startup", () => {
channel: "stable" as const,
persistedTag: undefined,
expectedTag: "latest",
preflightsInstallKind: false,
},
{
channel: "stable" as const,
persistedTag: "latest",
expectedTag: "latest",
preflightsInstallKind: false,
},
{
channel: "beta" as const,
persistedTag: "beta",
expectedTag: "beta",
preflightsInstallKind: false,
},
{
channel: "beta" as const,
persistedTag: "latest",
expectedTag: "latest",
preflightsInstallKind: false,
},
{
channel: "extended-stable" as const,
persistedTag: "extended-stable",
expectedTag: "extended-stable",
preflightsInstallKind: true,
},
{
channel: "dev" as const,
persistedTag: "dev",
expectedTag: "dev",
preflightsInstallKind: true,
},
])(
"hydrates $channel cached availability from its compatible $expectedTag tag",
async ({ channel, persistedTag, expectedTag, preflightsInstallKind }) => {
async ({ channel, persistedTag, expectedTag }) => {
writePersistedUpdateCheckState({
lastCheckedAt: new Date(Date.now()).toISOString(),
lastAvailableVersion: "2.0.0",
lastAvailableTag: persistedTag,
});
if (preflightsInstallKind) {
mockPackageInstallStatus();
}
mockPackageInstallStatus();
const onUpdateAvailableChange = vi.fn();
await runGatewayUpdateCheck({
@@ -639,7 +691,7 @@ describe("update-startup", () => {
onUpdateAvailableChange,
});
expect(checkUpdateStatus).toHaveBeenCalledTimes(preflightsInstallKind ? 1 : 0);
expect(checkUpdateStatus).toHaveBeenCalledTimes(1);
expect(resolveNpmChannelTag).not.toHaveBeenCalled();
expect(onUpdateAvailableChange).toHaveBeenCalledWith({
currentVersion: "1.0.0",
@@ -668,9 +720,7 @@ describe("update-startup", () => {
lastAvailableVersion: "2.0.0",
lastAvailableTag: persistedTag,
});
if (channel === "dev") {
mockPackageInstallStatus();
}
mockPackageInstallStatus();
const onUpdateAvailableChange = vi.fn();
await runGatewayUpdateCheck({
@@ -681,7 +731,7 @@ describe("update-startup", () => {
onUpdateAvailableChange,
});
expect(checkUpdateStatus).toHaveBeenCalledTimes(channel === "dev" ? 1 : 0);
expect(checkUpdateStatus).toHaveBeenCalledTimes(1);
expect(resolveNpmChannelTag).not.toHaveBeenCalled();
expect(onUpdateAvailableChange).not.toHaveBeenCalled();
expect(getUpdateAvailable()).toBeNull();
@@ -1047,6 +1097,20 @@ describe("update-startup", () => {
});
});
it("keeps a configless Git install on dev after schedule population", async () => {
mockDevGitStatus({ behind: 0 });
await runGatewayUpdateCheck({
cfg: {},
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
});
expect(getUpdateSchedule()?.channel).toBe("dev");
await expect(getUpdateEffectiveChannel()).resolves.toBe("dev");
});
it("skips all extended-stable work in Nix mode", async () => {
const runAutoUpdate = createAutoUpdateSuccessMock();
@@ -1611,11 +1675,11 @@ describe("update-startup", () => {
isNixMode: false,
});
await vi.advanceTimersByTimeAsync(0);
expect(checkUpdateStatus).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 - 1);
expect(checkUpdateStatus).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(checkUpdateStatus).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 - 1);
expect(checkUpdateStatus).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(checkUpdateStatus).toHaveBeenCalledTimes(3);
stop();
process.env.NODE_ENV = previousNodeEnv;
});
+36 -18
View File
@@ -117,6 +117,7 @@ export type {
let updateAvailableCache: UpdateAvailable | null = null;
let updateScheduleCache: UpdateScheduleState | null = null;
let installStatusInitialization: ReturnType<typeof resolveStartupInstallStatus> | null = null;
export function getUpdateAvailable(): UpdateAvailable | null {
return updateAvailableCache;
@@ -126,9 +127,19 @@ export function getUpdateSchedule(): UpdateScheduleState | null {
return updateScheduleCache;
}
export async function getUpdateEffectiveChannel(): Promise<UpdateChannel> {
const { status } = await initializeGatewayUpdateStatus();
return resolveEffectiveUpdateChannel({
currentVersion: VERSION,
installKind: status.installKind,
git: status.git,
}).channel;
}
export function resetUpdateAvailableStateForTest(): void {
updateAvailableCache = null;
updateScheduleCache = null;
installStatusInitialization = null;
gatewayUpdateCampaign.resetForTest();
}
@@ -591,6 +602,21 @@ async function resolveStartupInstallStatus(checkDevGit: boolean) {
return { root, status, installReceipt };
}
/** Starts the process-stable local install inspection owned by the update lifecycle. */
export function initializeGatewayUpdateStatus(): ReturnType<typeof resolveStartupInstallStatus> {
if (installStatusInitialization) {
return installStatusInitialization;
}
const initialization = resolveStartupInstallStatus(false);
installStatusInitialization = initialization;
void initialization.catch(() => {
if (installStatusInitialization === initialization) {
installStatusInitialization = null;
}
});
return initialization;
}
type GitScheduleStatus = NonNullable<NonNullable<UpdateScheduleState["install"]>["git"]>;
function gitCommitsMatch(left: string, right: string): boolean {
@@ -822,10 +848,12 @@ export async function runGatewayUpdateCheck(params: {
const autoDisabledByEnv = isTruthyEnvValue(process.env.OPENCLAW_NO_AUTO_UPDATE);
const autoDisabledByExternalSupervisor = isGatewayExternallySupervised();
const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false;
const initializedInstallStatus = await initializeGatewayUpdateStatus();
const potentialChannel = resolveEffectiveUpdateChannel({
configChannel,
currentVersion: VERSION,
installKind: "package",
installKind: initializedInstallStatus.status.installKind,
git: initializedInstallStatus.status.git,
}).channel;
const potentialAutoDesired =
(potentialChannel === "stable" || potentialChannel === "beta" || potentialChannel === "dev") &&
@@ -849,21 +877,15 @@ export async function runGatewayUpdateCheck(params: {
});
return;
}
const mightUseInstalledExtendedStableChannel =
configChannel === null && potentialChannel === "extended-stable";
let installStatus: Awaited<ReturnType<typeof resolveStartupInstallStatus>> | undefined;
if (
configChannel === "extended-stable" ||
configChannel === "dev" ||
mightUseInstalledExtendedStableChannel
) {
installStatus = await resolveStartupInstallStatus(configChannel === "dev");
let installStatus = initializedInstallStatus;
if (potentialChannel === "dev" && installStatus.status.installKind === "git") {
installStatus = await resolveStartupInstallStatus(true);
}
const configuredChannel = resolveEffectiveUpdateChannel({
configChannel,
currentVersion: VERSION,
installKind: installStatus?.status.installKind ?? "unknown",
git: installStatus?.status.git,
installKind: installStatus.status.installKind,
git: installStatus.status.git,
}).channel;
const autoDesired =
(configuredChannel === "stable" ||
@@ -938,10 +960,7 @@ export async function runGatewayUpdateCheck(params: {
return;
}
if ((configuredChannel === "extended-stable" || configuredChannel === "dev") && !installStatus) {
installStatus = await resolveStartupInstallStatus(configuredChannel === "dev");
}
if (installStatus && (configuredChannel === "extended-stable" || configuredChannel === "dev")) {
if (configuredChannel === "extended-stable" || configuredChannel === "dev") {
setUpdateScheduleCache({
next: withInstallStatus(
updateScheduleCache ?? initialSchedule,
@@ -953,7 +972,7 @@ export async function runGatewayUpdateCheck(params: {
onUpdateScheduleChange: params.onUpdateScheduleChange,
});
}
if (configuredChannel === "extended-stable" && installStatus) {
if (configuredChannel === "extended-stable") {
if (installStatus.status.installKind !== "package") {
updateCampaign.clear();
setUpdateAvailableCache({
@@ -1032,7 +1051,6 @@ export async function runGatewayUpdateCheck(params: {
}
}
installStatus ??= await resolveStartupInstallStatus(false);
const { root, status, installReceipt } = installStatus;
setUpdateScheduleCache({
next: withInstallStatus(
-49
View File
@@ -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"),
);
@@ -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);
});
});
+6 -239
View File
@@ -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"]);
});
});
-18
View File
@@ -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", () => {
+1 -1
View File
@@ -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,
},
);
-75
View File
@@ -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 = [
+5 -1
View File
@@ -37,7 +37,11 @@ describe("application update campaign overlays", () => {
await overlays.refreshUpdateStatus();
expect(request).toHaveBeenCalledWith("update.status", {}, { timeoutMs: 5_000 });
expect(request).toHaveBeenCalledWith(
"update.status",
{ refreshCheckout: true },
{ timeoutMs: 5_000 },
);
expect(overlays.snapshot.updateSchedule?.install?.git).toEqual({
status: "behind",
commitsBehind: 12,
+5 -2
View File
@@ -117,9 +117,12 @@ export type UpdateRunResponse = {
async function requestUpdateRestartStatus(
client: Pick<GatewayBrowserClient, "request">,
timeoutMs: number,
request: { refreshCheckout?: true } = {},
): Promise<UpdateRestartStatusResponse | null> {
try {
return await client.request<UpdateRestartStatusResponse>("update.status", {}, { timeoutMs });
return await client.request<UpdateRestartStatusResponse>("update.status", request, {
timeoutMs,
});
} catch {
return null;
}
@@ -138,7 +141,7 @@ export function createUpdateStatusRefresher(params: {
if (!client || !params.canRefresh()) {
return;
}
const response = await requestUpdateRestartStatus(client, 5_000);
const response = await requestUpdateRestartStatus(client, 5_000, { refreshCheckout: true });
if (response && params.isCurrent(client, epoch)) {
params.onStatus(response);
}
@@ -2,92 +2,70 @@ const SESSION_EVENT_REFRESH_DEBOUNCE_MS = 200;
const SESSION_EVENT_REFRESH_MAX_WAIT_MS = 1_000;
type SessionEventRefreshCoordinatorOptions = {
canRefresh: () => boolean;
active: boolean;
refresh: () => Promise<void>;
};
/** Canonical bounded event refresh policy shared by session-list owners. */
export function createSessionEventRefreshCoordinator(
options: SessionEventRefreshCoordinatorOptions,
) {
let timer: ReturnType<typeof globalThis.setTimeout> | null = null;
let deadline: number | null = null;
let inFlight: Promise<void> | null = null;
let trailing = false;
let generation = 0;
let disposed = false;
export function createSessionEventRefreshCoordinator({
active: initialActive,
refresh,
}: SessionEventRefreshCoordinatorOptions) {
let active = initialActive;
let timer: ReturnType<typeof setTimeout> | 0 = 0;
let deadline = 0;
// Hidden/page-exit lifecycle holds one authoritative refresh bit. Resume
// redeems it once without starting network work during teardown.
let queued = false;
const clearTimer = () => {
if (timer !== null) {
globalThis.clearTimeout(timer);
timer = null;
}
deadline = null;
clearTimeout(timer);
timer = 0;
deadline = 0;
};
const start = () => {
if (disposed || !options.canRefresh()) {
timer = 0;
deadline = 0;
if (!active) {
queued = true;
return;
}
if (inFlight) {
trailing = true;
return;
}
const operationGeneration = generation;
const operation = options.refresh().catch(() => undefined);
const pending = operation.finally(() => {
if (generation !== operationGeneration || inFlight !== pending) {
return;
}
inFlight = null;
if (trailing) {
trailing = false;
start();
}
});
inFlight = pending;
queued = false;
void refresh().catch(() => {});
};
const absorb = () => {
clearTimer();
queued = false;
};
return {
schedule() {
if (disposed || !options.canRefresh()) {
if (!active) {
clearTimer();
queued = true;
return;
}
const now = Date.now();
deadline ??= now + SESSION_EVENT_REFRESH_MAX_WAIT_MS;
if (timer !== null) {
globalThis.clearTimeout(timer);
}
const delay = Math.min(SESSION_EVENT_REFRESH_DEBOUNCE_MS, Math.max(0, deadline - now));
timer = globalThis.setTimeout(() => {
timer = null;
deadline = null;
start();
}, delay);
deadline ||= now + SESSION_EVENT_REFRESH_MAX_WAIT_MS;
clearTimeout(timer);
const delay = Math.min(SESSION_EVENT_REFRESH_DEBOUNCE_MS, deadline - now);
timer = setTimeout(start, delay);
},
flush() {
if (timer === null) {
setActive(next: boolean, markDirty = false) {
active = next;
if (next) {
if (queued) {
start();
}
return;
}
queued ||= markDirty || timer !== 0;
clearTimer();
start();
},
absorb() {
clearTimer();
trailing = false;
},
reset() {
clearTimer();
trailing = false;
inFlight = null;
generation += 1;
},
dispose() {
clearTimer();
trailing = false;
inFlight = null;
generation += 1;
disposed = true;
},
absorb,
reset: absorb,
dispose: absorb,
};
}
+115 -9
View File
@@ -58,6 +58,31 @@ function createHarness(request: GatewayBrowserClient["request"]) {
return { sessions, emitEvent: (event: GatewayEventFrame) => eventListener?.(event) };
}
function installPageLifecycle() {
const documentEvents = new EventTarget();
const pageEvents = new EventTarget();
let visibilityState: DocumentVisibilityState = "visible";
Object.defineProperty(documentEvents, "visibilityState", {
configurable: true,
get: () => visibilityState,
});
vi.stubGlobal("document", documentEvents);
vi.stubGlobal("addEventListener", pageEvents.addEventListener.bind(pageEvents));
vi.stubGlobal("removeEventListener", pageEvents.removeEventListener.bind(pageEvents));
return {
setVisibility(next: DocumentVisibilityState) {
visibilityState = next;
documentEvents.dispatchEvent(new Event("visibilitychange"));
},
pageHide() {
pageEvents.dispatchEvent(new Event("pagehide"));
},
pageShow() {
pageEvents.dispatchEvent(new Event("pageshow"));
},
};
}
describe("event-driven session list refresh", () => {
it("refreshes exact managed queries by agent and retains appended dashboard windows", async () => {
vi.useFakeTimers();
@@ -527,29 +552,110 @@ describe("event-driven session list refresh", () => {
}
});
it("flushes a pending event refresh synchronously on dispose", async () => {
it("defers a queued filtered refresh when the page hides during its active request", async () => {
vi.useFakeTimers();
const request = vi.fn(async (method: string) => {
const page = installPageLifecycle();
const activeRefresh = deferred<SessionsListResult>();
let filteredCalls = 0;
const request = vi.fn(async (method: string, params?: { archived?: string }) => {
if (method !== "sessions.list") {
throw new Error(`Unexpected request: ${method}`);
}
return sessionsResult(1);
if (params?.archived !== "all") {
return sessionsResult(0);
}
filteredCalls += 1;
return filteredCalls === 2 ? await activeRefresh.promise : sessionsResult(filteredCalls);
});
const { sessions, emitEvent } = createHarness(
request as unknown as GatewayBrowserClient["request"],
);
const unsubscribe = sessions.subscribeList({ agentId: "main", archivedFilter: "all" }, vi.fn());
try {
await sessions.refresh({ force: true });
emitEvent(sessionChangedEvent("agent:main:pending"));
sessions.dispose();
await sessions.refreshList({ agentId: "main", archivedFilter: "all", force: true });
emitEvent(sessionChangedEvent("agent:main:first"));
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS);
expect(filteredCalls).toBe(2);
expect(request).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS * 2);
expect(request).toHaveBeenCalledTimes(2);
emitEvent(sessionChangedEvent("agent:main:queued"));
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS);
page.setVisibility("hidden");
activeRefresh.resolve(sessionsResult(2));
await vi.advanceTimersByTimeAsync(0);
expect(filteredCalls).toBe(2);
page.setVisibility("visible");
await vi.advanceTimersByTimeAsync(0);
expect(filteredCalls).toBe(3);
} finally {
activeRefresh.resolve(sessionsResult(2));
unsubscribe();
sessions.dispose();
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
it("holds canonical and filtered event refreshes while hidden and catches up once", async () => {
vi.useFakeTimers();
const page = installPageLifecycle();
const request = vi.fn(async (method: string) => {
if (method !== "sessions.list") {
throw new Error(`Unexpected request: ${method}`);
}
return sessionsResult(1, [{ key: "agent:main:pending", kind: "direct", updatedAt: 0 }]);
});
const { sessions, emitEvent } = createHarness(
request as unknown as GatewayBrowserClient["request"],
);
const unsubscribe = sessions.subscribeList({ agentId: "main", archivedFilter: "all" }, vi.fn());
try {
await sessions.refresh({ agentId: "main", force: true });
await sessions.refreshList({ agentId: "main", archivedFilter: "all", force: true });
emitEvent(sessionChangedEvent("agent:main:pending"));
page.setVisibility("hidden");
emitEvent({
type: "event",
event: "sessions.changed",
payload: {
sessionKey: "agent:main:pending",
reason: "update",
key: "agent:main:pending",
kind: "direct",
updatedAt: 2,
archived: true,
archivedAt: 2,
},
});
expect(sessions.state.result?.sessions).toEqual([]);
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_MAX_WAIT_MS * 2);
expect(request).toHaveBeenCalledTimes(2);
page.setVisibility("visible");
page.pageShow();
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(4);
page.setVisibility("hidden");
page.pageHide();
page.pageShow();
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(4);
page.setVisibility("visible");
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(6);
sessions.dispose();
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS * 2);
expect(request).toHaveBeenCalledTimes(6);
} finally {
unsubscribe();
sessions.dispose();
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
});
+26 -18
View File
@@ -92,6 +92,9 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
let lastListOptions: SessionListOptions = {};
let hasForegroundListOptions = false;
let hasSeededListOptions = false;
const observesPageLifecycle =
typeof document !== "undefined" && typeof globalThis.addEventListener === "function";
let pageActive = !observesPageLifecycle || document.visibilityState !== "hidden";
const managedLists = new Map<string, ManagedSessionList>();
const publishManagedList = (entry: ManagedSessionList, snapshot: SessionListSnapshot): void => {
@@ -114,10 +117,7 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
snapshot: { result: null, agentId: null, loading: false, error: null },
listeners: new Set(),
coordinator: createSessionEventRefreshCoordinator({
canRefresh: () =>
managedLists.get(key) === entry &&
entry.listeners.size > 0 &&
host.connection.capture() !== null,
active: pageActive,
refresh: () => refreshManagedList(entry, { append: false }),
}),
pending: null,
@@ -192,8 +192,9 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
if (!isCurrent()) {
return;
}
next = entry.queued;
const queued = entry.queued;
entry.queued = null;
next = pageActive ? queued : null;
}
};
const pending = drain().finally(() => {
@@ -351,6 +352,9 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
if (!eventRefreshQueued) {
return null;
}
if (!pageActive) {
return null;
}
eventRefreshQueued = false;
return { ...lastListOptions, force: true };
};
@@ -408,25 +412,32 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
eventRefreshQueued = true;
return inFlight;
}
eventRefreshQueued = false;
return startRefresh({ ...lastListOptions, force: true });
};
const eventRefreshCoordinator = createSessionEventRefreshCoordinator({
canRefresh: () => host.connection.capture() !== null,
active: pageActive,
refresh: refreshFromEvent,
});
const flushEventRefresh = () => eventRefreshCoordinator.flush();
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
flushEventRefresh();
const handlePageLifecycle = (event: Event) => {
const markDirty = event.type === "pagehide";
pageActive = !markDirty && document.visibilityState !== "hidden";
eventRefreshCoordinator.setActive(pageActive, markDirty || inFlight !== null);
for (const entry of managedLists.values()) {
entry.coordinator.setActive(pageActive, markDirty || entry.pending !== null);
}
};
const observesPageLifecycle =
typeof document !== "undefined" && typeof globalThis.addEventListener === "function";
const updatePageLifecycleListeners = (add: boolean) => {
const method = add ? "addEventListener" : "removeEventListener";
document[method]("visibilitychange", handlePageLifecycle);
globalThis[method]("pagehide", handlePageLifecycle);
globalThis[method]("pageshow", handlePageLifecycle);
};
if (observesPageLifecycle) {
document.addEventListener("visibilitychange", handleVisibilityChange);
globalThis.addEventListener("pagehide", flushEventRefresh);
updatePageLifecycleListeners(true);
}
const refreshReplacement = (agentId?: string | null): Promise<void> => {
@@ -559,12 +570,9 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
}
},
dispose() {
// Flush before disposal so page-exit events start the trailing canonical list.
flushEventRefresh();
eventRefreshCoordinator.dispose();
if (observesPageLifecycle) {
document.removeEventListener("visibilitychange", handleVisibilityChange);
globalThis.removeEventListener("pagehide", flushEventRefresh);
updatePageLifecycleListeners(false);
}
inFlight = null;
queuedExplicitRefresh = null;
@@ -349,6 +349,20 @@ describe("chat pane native history pagination", () => {
expect(scrollToOffset).not.toHaveBeenCalled();
});
it("keeps a failed older load blocked across a layout-induced scroll", async () => {
const request = vi.fn(async () => {
throw new Error("history unavailable");
});
const { pane, thread } = createNativeShowEarlierPane(request);
pane.transcriptScrollTop = 500;
await pane.showEarlierMessages();
pane.handleTranscriptScroll({ currentTarget: thread, target: thread } as unknown as Event);
expect(pane.historyAutoLoadBlocked).toBe(true);
expect(request).toHaveBeenCalledOnce();
});
it("joins an in-flight canonical load before revealing its earlier window", async () => {
const deferred = createDeferred<{
messages: unknown[];
+4
View File
@@ -369,6 +369,10 @@ export abstract class ChatPaneHistory extends ChatPaneReplyNavigation {
} catch (error) {
if (generation === this.olderLoadGeneration) {
state.lastError = formatUiError(error);
// Loading-row removal can emit a layout scroll. Align the tracker so it
// cannot masquerade as renewed user intent and consume the manual retry.
this.transcriptScrollTop =
this.querySelector<HTMLElement>(".chat-thread")?.scrollTop ?? null;
}
} finally {
if (generation === this.olderLoadGeneration) {