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"],