fix(dev): classify dirty-tree watch invalidations

This commit is contained in:
Vincent Koc
2026-03-31 17:54:05 +09:00
parent 622bdfdad1
commit cd8d0881ed
3 changed files with 179 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { resolveBuildRequirement } from "./run-node.mjs";
const DEFAULTS = {
outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"),
@@ -370,6 +371,32 @@ function fail(message) {
console.error(`FAIL: ${message}`);
}
function detectWatchBuildReason(stdout, stderr) {
const combined = `${stdout}\n${stderr}`;
const match = combined.match(/Building TypeScript \(dist is stale: ([a-z_]+)/);
return match?.[1] ?? null;
}
function buildRunNodeDeps(env) {
const cwd = process.cwd();
return {
cwd,
env,
fs,
spawnSync,
distRoot: path.join(cwd, "dist"),
distEntry: path.join(cwd, "dist", "/entry.js"),
buildStampPath: path.join(cwd, "dist", ".buildstamp"),
sourceRoots: ["src", "extensions"].map((sourceRoot) => ({
name: sourceRoot,
path: path.join(cwd, sourceRoot),
})),
configFiles: ["tsconfig.json", "package.json", "tsdown.config.ts"].map((filePath) =>
path.join(cwd, filePath),
),
};
}
async function main() {
const options = parseArgs(process.argv.slice(2));
ensureDir(options.outputDir);
@@ -377,6 +404,29 @@ async function main() {
runCheckedCommand("pnpm", ["build"]);
}
const preflightBuildRequirement = resolveBuildRequirement(buildRunNodeDeps(process.env));
if (
preflightBuildRequirement.shouldBuild &&
preflightBuildRequirement.reason === "dirty_watched_tree"
) {
const summary = {
windowMs: options.windowMs,
invalidated: true,
invalidationReason: preflightBuildRequirement.reason,
invalidationMessage:
"gateway-watch-regression cannot run on a dirty watched tree because run-node will intentionally rebuild during the watch window.",
};
fs.writeFileSync(
path.join(options.outputDir, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
console.log(JSON.stringify(summary, null, 2));
fail(
"gateway-watch-regression invalid local run: dirty watched source tree would force a rebuild inside the watch window",
);
process.exit(1);
}
const preDir = path.join(options.outputDir, "pre");
const pre = writeSnapshot(preDir);
@@ -397,14 +447,17 @@ async function main() {
const watchTriggeredBuild =
fs
.readFileSync(watchResult.stderrPath, "utf8")
.includes("Building TypeScript (dist is stale).") ||
fs
.readFileSync(watchResult.stdoutPath, "utf8")
.includes("Building TypeScript (dist is stale).");
.includes("Building TypeScript (dist is stale") ||
fs.readFileSync(watchResult.stdoutPath, "utf8").includes("Building TypeScript (dist is stale");
const watchBuildReason = detectWatchBuildReason(
fs.readFileSync(watchResult.stdoutPath, "utf8"),
fs.readFileSync(watchResult.stderrPath, "utf8"),
);
const summary = {
windowMs: options.windowMs,
watchTriggeredBuild,
watchBuildReason,
cpuMs,
cpuWarnMs: options.cpuWarnMs,
cpuFailMs: options.cpuFailMs,
@@ -426,6 +479,11 @@ async function main() {
console.log(JSON.stringify(summary, null, 2));
const failures = [];
if (watchTriggeredBuild && watchBuildReason === "dirty_watched_tree") {
failures.push(
"gateway:watch invalid local run: dirty watched source tree forced a rebuild during the watch window",
);
}
if (distRuntimeFileGrowth > options.distRuntimeFileGrowthMax) {
failures.push(
`dist-runtime file growth ${distRuntimeFileGrowth} exceeded max ${options.distRuntimeFileGrowthMax}`,
@@ -452,9 +510,11 @@ async function main() {
for (const message of failures) {
fail(message);
}
fail(
"Possible duplicate dist-runtime graph regression: this can reintroduce split runtime personalities where plugins and core observe different global state, including Telegram missing /voice, /phone, or /pair.",
);
if (!failures.every((message) => message.includes("dirty watched source tree"))) {
fail(
"Possible duplicate dist-runtime graph regression: this can reintroduce split runtime personalities where plugins and core observe different global state, including Telegram missing /voice, /phone, or /pair.",
);
}
process.exit(1);
}

View File

@@ -194,48 +194,62 @@ const hasSourceMtimeChanged = (stampMtime, deps) => {
return latestSourceMtime != null && latestSourceMtime > stampMtime;
};
const shouldBuild = (deps) => {
export const resolveBuildRequirement = (deps) => {
if (deps.env.OPENCLAW_FORCE_BUILD === "1") {
return true;
return { shouldBuild: true, reason: "force_build" };
}
const stamp = readBuildStamp(deps);
if (stamp.mtime == null) {
return true;
return { shouldBuild: true, reason: "missing_build_stamp" };
}
if (statMtime(deps.distEntry, deps.fs) == null) {
return true;
return { shouldBuild: true, reason: "missing_dist_entry" };
}
for (const filePath of deps.configFiles) {
const mtime = statMtime(filePath, deps.fs);
if (mtime != null && mtime > stamp.mtime) {
return true;
return { shouldBuild: true, reason: "config_newer" };
}
}
const currentHead = resolveGitHead(deps);
if (currentHead && !stamp.head) {
return true;
return { shouldBuild: true, reason: "build_stamp_missing_head" };
}
if (currentHead && stamp.head && currentHead !== stamp.head) {
return true;
return { shouldBuild: true, reason: "git_head_changed" };
}
if (currentHead) {
const dirty = hasDirtySourceTree(deps);
if (dirty === true) {
return true;
return { shouldBuild: true, reason: "dirty_watched_tree" };
}
if (dirty === false) {
return false;
return { shouldBuild: false, reason: "clean" };
}
}
if (hasSourceMtimeChanged(stamp.mtime, deps)) {
return true;
return { shouldBuild: true, reason: "source_mtime_newer" };
}
return false;
return { shouldBuild: false, reason: "clean" };
};
const BUILD_REASON_LABELS = {
force_build: "forced by OPENCLAW_FORCE_BUILD",
missing_build_stamp: "build stamp missing",
missing_dist_entry: "dist entry missing",
config_newer: "config newer than build stamp",
build_stamp_missing_head: "build stamp missing git head",
git_head_changed: "git head changed",
dirty_watched_tree: "dirty watched source tree",
source_mtime_newer: "source mtime newer than build stamp",
clean: "clean",
};
const formatBuildReason = (reason) => BUILD_REASON_LABELS[reason] ?? reason;
const logRunner = (message, deps) => {
if (deps.env.OPENCLAW_RUNNER_LOG === "0") {
return;
@@ -307,14 +321,18 @@ export async function runNodeMain(params = {}) {
}));
deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath));
if (!shouldBuild(deps)) {
const buildRequirement = resolveBuildRequirement(deps);
if (!buildRequirement.shouldBuild) {
if (!syncRuntimeArtifacts(deps)) {
return 1;
}
return await runOpenClaw(deps);
}
logRunner("Building TypeScript (dist is stale).", deps);
logRunner(
`Building TypeScript (dist is stale: ${buildRequirement.reason} - ${formatBuildReason(buildRequirement.reason)}).`,
deps,
);
const buildCmd = deps.execPath;
const buildArgs = compilerArgs;
const build = deps.spawn(buildCmd, buildArgs, {