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