From a4c860a70cdcd120ef6ff76c244bd0794089f156 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 5 May 2026 16:05:35 -0700 Subject: [PATCH] fix(update): avoid lint-blocked dev installs (#77181) --- CHANGELOG.md | 1 + docs/cli/update.md | 2 +- scripts/run-oxlint-shards.mjs | 13 ++++- src/infra/update-runner.test.ts | 98 ++++++++++++++++++++++++++++++++- src/infra/update-runner.ts | 20 ++++++- test/scripts/run-oxlint.test.ts | 7 +++ 6 files changed, 135 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc283323ade..8b8153ca856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai - CLI/channels: skip config, proxy, channel-option catalog, banner-config, and plugin startup bootstrap for the bare `openclaw channels` parent-help command, so it exits promptly after printing help instead of loading configured channel plugins. Thanks @vincentkoc. - CLI/gateway: pause non-TTY stdin after full CLI command completion and stop `openclaw agent` from falling back to embedded mode after gateway request/auth failures, so parent help commands exit cleanly and scoped delivery probes surface the real Gateway error immediately. Thanks @vincentkoc. - Gateway/model catalog: cache empty read-only model catalog results until reload, so TUI and control-plane refresh loops cannot hammer plugin metadata reads when no usable models are currently discovered. Thanks @vincentkoc. +- CLI/update: make dev-channel preflight lint opt-in and constrained when enabled, so `openclaw update --channel dev` no longer walks back otherwise-good main commits when Ubuntu hosts OOM-kill or fail parallel oxlint shards. Thanks @vincentkoc. - Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting. - Google Meet: log the concrete agent-mode TTS provider, model, voice, output format, and sample rate after speech synthesis, so Meet logs show which voice backend spoke each reply. - Google Meet: log the resolved audio provider model when starting Chrome and paired-node Meet talk-back bridges, so agent-mode joins show the STT model and bidi joins show the realtime voice model. diff --git a/docs/cli/update.md b/docs/cli/update.md index 31fec6186a9..592f1fb0291 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -148,7 +148,7 @@ manually. Dev only. - Runs lint and TypeScript build in a temp worktree. If the tip fails, walks back up to 10 commits to find the newest clean build. + Runs the TypeScript build in a temp worktree. If the tip fails, walks back up to 10 commits to find the newest buildable commit. Set `OPENCLAW_UPDATE_PREFLIGHT_LINT=1` to also run lint during this preflight; lint runs in constrained serial mode because user update hosts are often smaller than CI runners. Rebases onto the selected commit (dev only). diff --git a/scripts/run-oxlint-shards.mjs b/scripts/run-oxlint-shards.mjs index 3be13d89142..39a3e21658c 100644 --- a/scripts/run-oxlint-shards.mjs +++ b/scripts/run-oxlint-shards.mjs @@ -35,9 +35,20 @@ const shards = [ }, ]; -const results = await Promise.all(shards.map((shard) => runShard(shard))); +const runSerial = process.env.OPENCLAW_OXLINT_SHARDS_SERIAL === "1"; +const results = runSerial + ? await runShardsSerial(shards) + : await Promise.all(shards.map((shard) => runShard(shard))); process.exitCode = results.find((status) => status !== 0) ?? 0; +async function runShardsSerial(entries) { + const results = []; + for (const shard of entries) { + results.push(await runShard(shard)); + } + return results; +} + async function runShard(shard) { console.error(`[oxlint:${shard.name}] starting`); const child = spawn(process.execPath, [runner, ...shard.args, ...extraArgs], { diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 659c9119a6a..1f2a7514b9a 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -614,11 +614,107 @@ describe("runGatewayUpdate", () => { expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true); expect(calls).toContain("pnpm install"); expect(calls).toContain("pnpm build"); - expect(calls).toContain("pnpm lint"); + expect(calls).not.toContain("pnpm lint"); expect(calls).toContain("pnpm ui:build"); expect(pnpmEnvPaths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true); }); + it("runs dev preflight lint in constrained mode when explicitly enabled", async () => { + await setupGitPackageManagerFixture(); + const calls: string[] = []; + const lintEnv: NodeJS.ProcessEnv[] = []; + const upstreamSha = "upstream123"; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + + const runCommand = async ( + argv: string[], + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { + const key = argv.join(" "); + calls.push(key); + + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { stdout: "abc123", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) { + return { stdout: "main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) { + return { stdout: "origin/main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse @{upstream}`) { + return { stdout: upstreamSha, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) { + return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 }; + } + if (key === "pnpm --version") { + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) && + key.endsWith(` ${upstreamSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 }; + } + if ( + key.startsWith("git -C /tmp/") && + preflightPrefixPattern.test(key) && + key.includes(" checkout --detach ") && + key.endsWith(upstreamSha) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install" || key === "pnpm build" || key === "pnpm ui:build") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm lint") { + lintEnv.push(options?.env ?? {}); + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} worktree prune`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rebase ${upstreamSha}`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === doctorCommand) { + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await withEnvAsync({ OPENCLAW_UPDATE_PREFLIGHT_LINT: "1" }, async () => + runWithCommand(runCommand, { channel: "dev" }), + ); + + expect(result.status).toBe("ok"); + expect(calls).toContain("pnpm lint"); + expect(lintEnv).toHaveLength(1); + expect(lintEnv[0]).toMatchObject({ + OPENCLAW_LOCAL_CHECK: "1", + OPENCLAW_LOCAL_CHECK_MODE: "throttled", + OPENCLAW_OXLINT_SHARDS_SERIAL: "1", + }); + }); + it("retries windows pnpm git installs with --ignore-scripts for dev updates", async () => { await setupGitPackageManagerFixture(); const calls: string[] = []; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 8fc4410bb31..1387502622c 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -176,6 +176,12 @@ const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktr const PREFLIGHT_CLEANUP_TIMEOUT_MS = 60_000; const WINDOWS_PREFLIGHT_BASE_DIR = "ocu"; const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096; +const DEV_PREFLIGHT_LINT_ENV: NodeJS.ProcessEnv = { + OPENCLAW_LOCAL_CHECK: "1", + OPENCLAW_LOCAL_CHECK_MODE: "throttled", + OPENCLAW_OXLINT_SHARDS_SERIAL: "1", +}; +const DEV_PREFLIGHT_LINT_OPT_IN_ENV = "OPENCLAW_UPDATE_PREFLIGHT_LINT"; function normalizeDir(value?: string | null) { if (!value) { @@ -566,8 +572,16 @@ function mergeCommandEnvironments( }; } -function shouldRunDevPreflightLint(): boolean { - return process.platform !== "win32"; +function shouldRunDevPreflightLint(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env[DEV_PREFLIGHT_LINT_OPT_IN_ENV]?.trim().toLowerCase(); + return value === "1" || value === "true"; +} + +function resolveDevPreflightLintEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { + return { + ...env, + ...DEV_PREFLIGHT_LINT_ENV, + }; } function normalizeFallbackFailureReason(stepName: string): NonNullable { @@ -1042,7 +1056,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< `preflight lint (${shortSha})`, managerScriptArgs(manager.manager, "lint"), worktreeDir, - manager.env, + resolveDevPreflightLintEnv(manager.env), ), ); steps.push(lintStep); diff --git a/test/scripts/run-oxlint.test.ts b/test/scripts/run-oxlint.test.ts index 319dd3d3203..d440fc027c8 100644 --- a/test/scripts/run-oxlint.test.ts +++ b/test/scripts/run-oxlint.test.ts @@ -34,6 +34,13 @@ describe("run-oxlint", () => { expect(shardedLintRunner).toContain('OPENCLAW_OXLINT_SKIP_PREPARE: "1"'); }); + it("lets dev update preflight run oxlint shards serially", () => { + const shardedLintRunner = readFileSync("scripts/run-oxlint-shards.mjs", "utf8"); + + expect(shardedLintRunner).toContain("OPENCLAW_OXLINT_SHARDS_SERIAL"); + expect(shardedLintRunner).toContain("runShardsSerial"); + }); + it("filters tracked targets missing from sparse checkouts", () => { const result = filterSparseMissingOxlintTargets( ["--tsconfig", "config/tsconfig/oxlint.core.json", "src", "ui", "packages", "--threads=1"],