From bf9b25ab7e5059ea4d862bbe9e8144fd342d7156 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 20:58:27 -0700 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 2 +- .../OpenClawProtocol/GatewayModels.swift | 14 +- docs/reference/test.md | 1 - .../src/schema/config.test.ts | 7 + .../gateway-protocol/src/schema/config.ts | 6 +- scripts/AGENTS.md | 8 - scripts/check-changed.mts | 55 +- scripts/check-duplicates.mts | 2 +- scripts/check-tsgo-core-boundary.mts | 2 +- scripts/ci-run-node-test-shard.mts | 19 +- scripts/format-docs.mts | 2 +- ...tension-package-boundary-artifact-lock.mts | 86 +++ scripts/lib/local-check-runtime.mts | 284 +++++++++ scripts/lib/local-heavy-check-runtime.mts | 595 ------------------ scripts/pr-gates-lock.mts | 75 --- scripts/pr-lib/gates.sh | 56 -- ...e-extension-package-boundary-artifacts.mts | 23 +- scripts/profile-tsgo.mts | 65 +- scripts/run-lint.mts | 2 +- scripts/run-oxlint-shards.mts | 129 ++-- scripts/run-oxlint.mts | 69 +- scripts/run-stylelint.mts | 2 +- scripts/run-tsgo-core-test-shards.mts | 55 +- scripts/run-tsgo.mts | 76 +-- scripts/test-projects.mts | 40 +- scripts/test-projects.test-support.mts | 25 - .../update-effective-channel.test.ts | 122 +++- src/gateway/server-methods/update.ts | 64 +- .../server-startup-post-attach.test.ts | 23 +- src/gateway/server-startup-post-attach.ts | 15 +- src/infra/update-startup.test.ts | 102 ++- src/infra/update-startup.ts | 54 +- test/scripts/changed-lanes.test.ts | 49 -- test/scripts/ci-run-node-test-shard.test.ts | 3 - ...me.test.ts => local-check-runtime.test.ts} | 209 +----- test/scripts/pr-prepare-gates.test.ts | 245 +------- test/scripts/run-oxlint.test.ts | 18 - test/scripts/run-tsgo.test.ts | 2 +- test/scripts/test-projects.test.ts | 75 --- ui/src/app/overlays-update-campaign.test.ts | 6 +- ui/src/app/update-overlay-helpers.ts | 7 +- .../lib/sessions/event-refresh-coordinator.ts | 106 ++-- .../lib/sessions/index.event-refresh.test.ts | 124 +++- ui/src/lib/sessions/session-roster-refresh.ts | 44 +- ui/src/pages/chat/chat-pane-history.test.ts | 14 + ui/src/pages/chat/chat-pane-history.ts | 4 + 46 files changed, 1082 insertions(+), 1904 deletions(-) create mode 100644 scripts/lib/extension-package-boundary-artifact-lock.mts create mode 100644 scripts/lib/local-check-runtime.mts delete mode 100644 scripts/lib/local-heavy-check-runtime.mts delete mode 100644 scripts/pr-gates-lock.mts rename test/scripts/{local-heavy-check-runtime.test.ts => local-check-runtime.test.ts} (61%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c17b3b64671..3a1cafe724f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 8ba7c4b517e8..3bf25bb28686 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/docs/reference/test.md b/docs/reference/test.md index d5cc6b37bfc0..f058387b2a00 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -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 `: 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 diff --git a/packages/gateway-protocol/src/schema/config.test.ts b/packages/gateway-protocol/src/schema/config.test.ts index f4376cffbc49..ca7b45a15b87 100644 --- a/packages/gateway-protocol/src/schema/config.test.ts +++ b/packages/gateway-protocol/src/schema/config.test.ts @@ -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, { diff --git a/packages/gateway-protocol/src/schema/config.ts b/packages/gateway-protocol/src/schema/config.ts index 02197f698b82..fa9f81e1674d 100644 --- a/packages/gateway-protocol/src/schema/config.ts +++ b/packages/gateway-protocol/src/schema/config.ts @@ -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, diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 1831ba1c24c7..02aa790b92b0 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -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|-- ]` 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 ` 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 diff --git a/scripts/check-changed.mts b/scripts/check-changed.mts index 4d44ec45ca6f..150e6a5d90be 100644 --- a/scripts/check-changed.mts +++ b/scripts/check-changed.mts @@ -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( 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); diff --git a/scripts/check-duplicates.mts b/scripts/check-duplicates.mts index 1d4e3af99af0..2e5e2fb9fec8 100644 --- a/scripts/check-duplicates.mts +++ b/scripts/check-duplicates.mts @@ -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); diff --git a/scripts/check-tsgo-core-boundary.mts b/scripts/check-tsgo-core-boundary.mts index 97e31e16cc23..0da6e97f62e3 100644 --- a/scripts/check-tsgo-core-boundary.mts +++ b/scripts/check-tsgo-core-boundary.mts @@ -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 { diff --git a/scripts/ci-run-node-test-shard.mts b/scripts/ci-run-node-test-shard.mts index f5a0c3f417c6..a2a18f0ad870 100644 --- a/scripts/ci-run-node-test-shard.mts +++ b/scripts/ci-run-node-test-shard.mts @@ -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 }); } diff --git a/scripts/format-docs.mts b/scripts/format-docs.mts index 382b36fa7d7b..aceb259d09c8 100644 --- a/scripts/format-docs.mts +++ b/scripts/format-docs.mts @@ -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"; diff --git a/scripts/lib/extension-package-boundary-artifact-lock.mts b/scripts/lib/extension-package-boundary-artifact-lock.mts new file mode 100644 index 000000000000..fd61c89f0753 --- /dev/null +++ b/scripts/lib/extension-package-boundary-artifact-lock.mts @@ -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); + } + } +} diff --git a/scripts/lib/local-check-runtime.mts b/scripts/lib/local-check-runtime.mts new file mode 100644 index 000000000000..8f79b26cea04 --- /dev/null +++ b/scripts/lib/local-check-runtime.mts @@ -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, + }; +} diff --git a/scripts/lib/local-heavy-check-runtime.mts b/scripts/lib/local-heavy-check-runtime.mts deleted file mode 100644 index b266e0a5f46d..000000000000 --- a/scripts/lib/local-heavy-check-runtime.mts +++ /dev/null @@ -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>; - -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); -} diff --git a/scripts/pr-gates-lock.mts b/scripts/pr-gates-lock.mts deleted file mode 100644 index a17917904012..000000000000 --- a/scripts/pr-gates-lock.mts +++ /dev/null @@ -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 "); - } - 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(); diff --git a/scripts/pr-lib/gates.sh b/scripts/pr-lib/gates.sh index a4d71ceb40eb..14ba8b499eaf 100644 --- a/scripts/pr-lib/gates.sh +++ b/scripts/pr-lib/gates.sh @@ -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="" diff --git a/scripts/prepare-extension-package-boundary-artifacts.mts b/scripts/prepare-extension-package-boundary-artifacts.mts index 73ed9281b880..36294cd0d15c 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mts +++ b/scripts/prepare-extension-package-boundary-artifacts.mts @@ -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(); + } } diff --git a/scripts/profile-tsgo.mts b/scripts/profile-tsgo.mts index 1166243a111e..26ad8912d0f7 100644 --- a/scripts/profile-tsgo.mts +++ b/scripts/profile-tsgo.mts @@ -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 { diff --git a/scripts/run-lint.mts b/scripts/run-lint.mts index f3553f87271f..79e8821d00d7 100644 --- a/scripts/run-lint.mts +++ b/scripts/run-lint.mts @@ -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); diff --git a/scripts/run-oxlint-shards.mts b/scripts/run-oxlint-shards.mts index f517d4ce2e2f..c5b619ba0201 100644 --- a/scripts/run-oxlint-shards.mts +++ b/scripts/run-oxlint-shards.mts @@ -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", }, }); diff --git a/scripts/run-oxlint.mts b/scripts/run-oxlint.mts index 18846b61bc75..0626f7ae5fc2 100644 --- a/scripts/run-oxlint.mts +++ b/scripts/run-oxlint.mts @@ -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) { diff --git a/scripts/run-stylelint.mts b/scripts/run-stylelint.mts index fbca2bc9d20c..c7e8de4617fc 100644 --- a/scripts/run-stylelint.mts +++ b/scripts/run-stylelint.mts @@ -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); diff --git a/scripts/run-tsgo-core-test-shards.mts b/scripts/run-tsgo-core-test-shards.mts index 4316c266910c..db079d07073f 100644 --- a/scripts/run-tsgo-core-test-shards.mts +++ b/scripts/run-tsgo-core-test-shards.mts @@ -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 { return new Promise((resolve, reject) => { @@ -65,7 +54,7 @@ function runShard(config: string): Promise { [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 { }); } -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; } diff --git a/scripts/run-tsgo.mts b/scripts/run-tsgo.mts index f7a136be0d08..565d79d4a9c9 100644 --- a/scripts/run-tsgo.mts +++ b/scripts/run-tsgo.mts @@ -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) { diff --git a/scripts/test-projects.mts b/scripts/test-projects.mts index 2a9040dcf99f..20b82fd20439 100644 --- a/scripts/test-projects.mts +++ b/scripts/test-projects.mts @@ -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>; -// 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; }); diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index a974610cda5f..a85b1ab26915 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -4223,31 +4223,6 @@ function filterPlansForContractIncludeFile(plans: VitestRunPlan[], env: NodeJS.P }); } -export function shouldAcquireLocalHeavyCheckLock( - runSpecs: Array>, - 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) diff --git a/src/gateway/server-methods/update-effective-channel.test.ts b/src/gateway/server-methods/update-effective-channel.test.ts index c7854ceaa93c..5eae27311f4d 100644 --- a/src/gateway/server-methods/update-effective-channel.test.ts +++ b/src/gateway/server-methods/update-effective-channel.test.ts @@ -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>(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>(async () => null), ); -vi.mock("../../infra/openclaw-root.js", async () => { - const actual = await vi.importActual( - "../../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((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 () => { diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index 58411fa69ab3..4eb0aa648225 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -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, -) { - 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>(); + +function refreshUpdateStatusCheckout(config: OpenClawConfig): Promise { + 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(), diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 2fc98b3c76c9..c1b1c189249e 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -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 () => {}), diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index ab61600a0c5d..7d207997e056 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -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 >; + initializeGatewayUpdateStatus: () => ReturnType; scheduleGatewayUpdateCheck: ( ...args: Parameters ) => Awaitable>; @@ -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 () => { diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 74759cfc8a2b..608a4605e79d 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -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((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; }); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index b1f7482b1e97..552f08079a8e 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -117,6 +117,7 @@ export type { let updateAvailableCache: UpdateAvailable | null = null; let updateScheduleCache: UpdateScheduleState | null = null; +let installStatusInitialization: ReturnType | 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 { + 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 { + if (installStatusInitialization) { + return installStatusInitialization; + } + const initialization = resolveStartupInstallStatus(false); + installStatusInitialization = initialization; + void initialization.catch(() => { + if (installStatusInitialization === initialization) { + installStatusInitialization = null; + } + }); + return initialization; +} + type GitScheduleStatus = NonNullable["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> | 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( diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index ef9ec44706b1..69bc5063e661 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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", diff --git a/test/scripts/ci-run-node-test-shard.test.ts b/test/scripts/ci-run-node-test-shard.test.ts index f3bd72afc2cd..c3f932bf9556 100644 --- a/test/scripts/ci-run-node-test-shard.test.ts +++ b/test/scripts/ci-run-node-test-shard.test.ts @@ -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"), ); diff --git a/test/scripts/local-heavy-check-runtime.test.ts b/test/scripts/local-check-runtime.test.ts similarity index 61% rename from test/scripts/local-heavy-check-runtime.test.ts rename to test/scripts/local-check-runtime.test.ts index d8079ab7303b..4e18ccdddcae 100644 --- a/test/scripts/local-heavy-check-runtime.test.ts +++ b/test/scripts/local-check-runtime.test.ts @@ -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 = {}) { 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); - }); }); diff --git a/test/scripts/pr-prepare-gates.test.ts b/test/scripts/pr-prepare-gates.test.ts index 1db46b10bc11..f24e5752c7d7 100644 --- a/test/scripts/pr-prepare-gates.test.ts +++ b/test/scripts/pr-prepare-gates.test.ts @@ -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 { - 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 { - 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 { - const stderr = child.stderr; - if (!stderr) { - throw new Error("child stderr is not piped"); - } - stderr.setEncoding("utf8"); - let output = ""; - return await new Promise((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"]); }); }); diff --git a/test/scripts/run-oxlint.test.ts b/test/scripts/run-oxlint.test.ts index 761874358a72..7a8cc56e4330 100644 --- a/test/scripts/run-oxlint.test.ts +++ b/test/scripts/run-oxlint.test.ts @@ -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", () => { diff --git a/test/scripts/run-tsgo.test.ts b/test/scripts/run-tsgo.test.ts index c9e3d7ac98b0..b1bfaf14a3e4 100644 --- a/test/scripts/run-tsgo.test.ts +++ b/test/scripts/run-tsgo.test.ts @@ -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, }, ); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index a6f178e4ce75..d7eb84463fbe 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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 = [ diff --git a/ui/src/app/overlays-update-campaign.test.ts b/ui/src/app/overlays-update-campaign.test.ts index 68ef16a4cde4..c779ca46ebff 100644 --- a/ui/src/app/overlays-update-campaign.test.ts +++ b/ui/src/app/overlays-update-campaign.test.ts @@ -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, diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index 04e3a7cb621f..a34f4a5044b0 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -117,9 +117,12 @@ export type UpdateRunResponse = { async function requestUpdateRestartStatus( client: Pick, timeoutMs: number, + request: { refreshCheckout?: true } = {}, ): Promise { try { - return await client.request("update.status", {}, { timeoutMs }); + return await client.request("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); } diff --git a/ui/src/lib/sessions/event-refresh-coordinator.ts b/ui/src/lib/sessions/event-refresh-coordinator.ts index ce6277c88196..fc8113267b11 100644 --- a/ui/src/lib/sessions/event-refresh-coordinator.ts +++ b/ui/src/lib/sessions/event-refresh-coordinator.ts @@ -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; }; /** Canonical bounded event refresh policy shared by session-list owners. */ -export function createSessionEventRefreshCoordinator( - options: SessionEventRefreshCoordinatorOptions, -) { - let timer: ReturnType | null = null; - let deadline: number | null = null; - let inFlight: Promise | null = null; - let trailing = false; - let generation = 0; - let disposed = false; +export function createSessionEventRefreshCoordinator({ + active: initialActive, + refresh, +}: SessionEventRefreshCoordinatorOptions) { + let active = initialActive; + let timer: ReturnType | 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, }; } diff --git a/ui/src/lib/sessions/index.event-refresh.test.ts b/ui/src/lib/sessions/index.event-refresh.test.ts index e8de5974fc51..0e2fd42fdbf2 100644 --- a/ui/src/lib/sessions/index.event-refresh.test.ts +++ b/ui/src/lib/sessions/index.event-refresh.test.ts @@ -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(); + 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(); } }); }); diff --git a/ui/src/lib/sessions/session-roster-refresh.ts b/ui/src/lib/sessions/session-roster-refresh.ts index cdebeb5126e6..7bfe04aa25ac 100644 --- a/ui/src/lib/sessions/session-roster-refresh.ts +++ b/ui/src/lib/sessions/session-roster-refresh.ts @@ -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(); 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 => { @@ -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; diff --git a/ui/src/pages/chat/chat-pane-history.test.ts b/ui/src/pages/chat/chat-pane-history.test.ts index d241d6ea2f65..e7ecaa09153d 100644 --- a/ui/src/pages/chat/chat-pane-history.test.ts +++ b/ui/src/pages/chat/chat-pane-history.test.ts @@ -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[]; diff --git a/ui/src/pages/chat/chat-pane-history.ts b/ui/src/pages/chat/chat-pane-history.ts index 67b3cf264829..80216d77ab2f 100644 --- a/ui/src/pages/chat/chat-pane-history.ts +++ b/ui/src/pages/chat/chat-pane-history.ts @@ -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(".chat-thread")?.scrollTop ?? null; } } finally { if (generation === this.olderLoadGeneration) {