Improve execution preflight diagnostics
This commit is contained in:
39
docs/EXECUTION_HEALTH.md
Normal file
39
docs/EXECUTION_HEALTH.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 执行健康与恢复顺序
|
||||||
|
|
||||||
|
这份报告只记录当前可复验的执行阻塞,不替代 `docs/EXECUTION_QUEUE.md`、parent
|
||||||
|
manifest 或任务卡。先运行廉价预检,再运行 Blender/WASM 或浏览器命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/web/check-execution-health.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
预检会一次性检查当前 queue、parent `nextTask`、task index、catalog 唯一 active
|
||||||
|
记录、紧凑任务卡,以及当前 gap 的 fixture/generator。它在发现阻塞时退出码为 `2`,
|
||||||
|
不会启动 Blender、加载 WASM 或写入任何证据。
|
||||||
|
|
||||||
|
## 当前基线(2026-08-24)
|
||||||
|
|
||||||
|
- `npm --prefix web run test:task-context`:通过,约 `708 ms`。
|
||||||
|
- `npm --prefix web run test:context-governance`:失败,约 `732 ms`;index/catalog
|
||||||
|
active 仍是 `M16-GAP-00281`,queue 已指向 `M16-GAP-00282`。
|
||||||
|
- `node tools/web/check-generated-gap.mjs --task M16-GAP-00282`:预检失败,约
|
||||||
|
`131 ms`;任务仍是 `pending`,fixture 和 generator 均不存在。
|
||||||
|
- `npm --prefix web test`:约 `13.5 s`,`398/417` 通过;19 个失败来自历史
|
||||||
|
manifest 的 artifact SHA-256 漂移,不能通过放宽校验解决。
|
||||||
|
- `npm --prefix web run typecheck`:约 `15.7 s`,通过。
|
||||||
|
- `npm --prefix web run lint`:约 `12.8 s`,通过。
|
||||||
|
|
||||||
|
## 恢复顺序
|
||||||
|
|
||||||
|
1. 先修复 queue、plan、catalog、index 的单一指针来源,并重新生成 index;不得手工
|
||||||
|
修改 active 状态或 offset。
|
||||||
|
2. 为当前任务生成非空紧凑卡:
|
||||||
|
`node tools/web/generate-task-card.mjs --task M16-GAP-00282`。
|
||||||
|
3. 只有任务变为 `active` 且 fixture/generator 真实存在后,才运行专项 gap 命令。
|
||||||
|
4. 历史 manifest hash 漂移需要不可变的源快照或经审计的重签流程;禁止在 checker 中
|
||||||
|
忽略漂移,也不要把 `CANCELLED`、metadata-only 或 negative-only 证据计为功能完成。
|
||||||
|
5. 专项任务通过后才运行浏览器验收;浏览器门永久限制为 Chromium。
|
||||||
|
|
||||||
|
`tools/web/check-generated-gap.mjs` 现在先执行同一套轻量预检,并把状态、缺失输入
|
||||||
|
和修复方向打印成单行诊断;只有预检通过才动态加载 WASM。这避免无效任务浪费启动时间,
|
||||||
|
也让失败停在真正的输入问题上。
|
||||||
37
docs/tasks/M16-GAP-00282.md
Normal file
37
docs/tasks/M16-GAP-00282.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# M16-GAP-00282: operator:asset.screenshot_preview LOCAL_EXACT slice
|
||||||
|
|
||||||
|
- task: M16-GAP-00282
|
||||||
|
- parent: M16-GAP-00281
|
||||||
|
- status: in_progress
|
||||||
|
- gap: operator:asset.screenshot_preview
|
||||||
|
- ownerFamily: OPERATOR
|
||||||
|
- targetImplementationClass: LOCAL_EXACT
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
Make the same minimal fixture produce observable operator:asset.screenshot_preview data in Blender desktop and WASM/Main, with save/reopen stability. Change only this gap; do not expand to other data-blocks, editors, or browsers.
|
||||||
|
|
||||||
|
## 输入与范围
|
||||||
|
|
||||||
|
- Fixture: `tests/files/web/generated/M16-GAP-00282-operator-asset.screenshot_preview.blend`
|
||||||
|
- Generator: `tools/web/generated/M16-GAP-00282.py`
|
||||||
|
- Production: `blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp`
|
||||||
|
- Checker: `tools/web/check-generated-gap.mjs`
|
||||||
|
- Do: field read, desktop/WASM comparison, save/reopen, structured report.
|
||||||
|
- Do not: other gaps, Firefox/WebKit, or full editor behavior.
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/M16-GAP-00282.py -- tests/files/web/generated/M16-GAP-00282-operator-asset.screenshot_preview.blend
|
||||||
|
npm --prefix web run test:generated-gap -- --task M16-GAP-00282
|
||||||
|
node tools/web/check-generated-gap.mjs --task M16-GAP-00282
|
||||||
|
|
||||||
|
Malformed/unsupported, cancellation, duplicate, over-budget, or hash-drift cases must keep in_progress; they must not change Main revision or advance nextTask.
|
||||||
|
|
||||||
|
## 交付与回滚
|
||||||
|
|
||||||
|
- Reports: `tests/golden/M16-GAP-00282/`
|
||||||
|
- Status: `docs/status/M16-GAP-00282.md`
|
||||||
|
- Manifest: `tests/golden/M16-GAP-00282/manifest.json`
|
||||||
|
- Handoff: `node tools/web/check-task-context.mjs --task M16-GAP-00282 --write`
|
||||||
|
- Rollback: remove this task production entry, fixture, tests, reports, manifest, status, and context; restore the parent as queue tail without rewriting parent evidence.
|
||||||
80
tools/web/check-execution-health.mjs
Normal file
80
tools/web/check-execution-health.mjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
buildTaskContext,
|
||||||
|
parseQueue,
|
||||||
|
readIndexedTask,
|
||||||
|
verifyTaskIndex,
|
||||||
|
} from "./task-context-lib.mjs";
|
||||||
|
import { collectGeneratedGapPreflight } from "./generated-gap-preflight.mjs";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const requested = process.argv.indexOf("--task");
|
||||||
|
const requestedTask = requested >= 0 ? process.argv[requested + 1] : undefined;
|
||||||
|
const issues = [];
|
||||||
|
const add = (code, detail) => issues.push({ code, detail });
|
||||||
|
const relative = (file) => path.relative(repoRoot, file).replaceAll(path.sep, "/");
|
||||||
|
|
||||||
|
let queue;
|
||||||
|
let index;
|
||||||
|
let entry;
|
||||||
|
try {
|
||||||
|
queue = parseQueue();
|
||||||
|
index = verifyTaskIndex();
|
||||||
|
const task = requestedTask ?? queue.currentTask;
|
||||||
|
const checkingCurrent = requestedTask === undefined;
|
||||||
|
entry = readIndexedTask(task);
|
||||||
|
if (!entry) add("TASK_NOT_INDEXED", task);
|
||||||
|
|
||||||
|
const parentPath = path.resolve(repoRoot, queue.parentManifest);
|
||||||
|
if (checkingCurrent && !fs.existsSync(parentPath)) add("PARENT_MANIFEST_MISSING", queue.parentManifest);
|
||||||
|
else if (checkingCurrent) {
|
||||||
|
const parent = JSON.parse(fs.readFileSync(parentPath, "utf8"));
|
||||||
|
if (parent.nextTask !== queue.currentTask) add("PARENT_NEXT_TASK_MISMATCH", `${parent.nextTask ?? "NONE"}!=${queue.currentTask}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index) {
|
||||||
|
if (checkingCurrent && index.activeTask !== queue.currentTask) add("INDEX_ACTIVE_TASK_MISMATCH", `${index.activeTask ?? "NONE"}!=${queue.currentTask}`);
|
||||||
|
const catalogPath = path.resolve(repoRoot, index.catalog.path);
|
||||||
|
const active = fs.readFileSync(catalogPath, "utf8").trimEnd().split("\n").filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line)).filter((record) => record.state === "active");
|
||||||
|
if (checkingCurrent && active.length !== 1) add("CATALOG_ACTIVE_TASK_COUNT", `count=${active.length}`);
|
||||||
|
else if (checkingCurrent && active[0].id !== queue.currentTask) add("CATALOG_ACTIVE_TASK_MISMATCH", `${active[0].id}!=${queue.currentTask}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardPath = path.join(repoRoot, "docs/tasks", `${task}.md`);
|
||||||
|
if (!fs.existsSync(cardPath) || fs.statSync(cardPath).size === 0) add("TASK_CARD_MISSING", relative(cardPath));
|
||||||
|
else if (checkingCurrent) {
|
||||||
|
const context = buildTaskContext(task);
|
||||||
|
if (context.context.task !== task) add("TASK_CONTEXT_MISMATCH", `${context.context.task}!=${task}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const issue of collectGeneratedGapPreflight({ root: repoRoot, task: requestedTask ?? queue.currentTask, entry })) {
|
||||||
|
add(issue.code, issue.detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
add("HEALTH_CHECK_ERROR", error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: "WEB_EXECUTION_HEALTH",
|
||||||
|
status: issues.length === 0 ? "PASS" : "BLOCKED",
|
||||||
|
queueTask: queue?.currentTask ?? null,
|
||||||
|
checkedTask: requestedTask ?? queue?.currentTask ?? null,
|
||||||
|
indexActiveTask: index?.activeTask ?? null,
|
||||||
|
issueCount: issues.length,
|
||||||
|
issues,
|
||||||
|
remediation: [
|
||||||
|
...(issues.some(({ code }) => ["INDEX_ACTIVE_TASK_MISMATCH", "CATALOG_ACTIVE_TASK_MISMATCH", "CATALOG_ACTIVE_TASK_COUNT", "PARENT_NEXT_TASK_MISMATCH"].includes(code))
|
||||||
|
? ["repair the queue/index/catalog pointer through the repository generator"] : []),
|
||||||
|
...(issues.some(({ code }) => code === "TASK_CARD_MISSING")
|
||||||
|
? ["generate the compact task card before running a gap checker"] : []),
|
||||||
|
...(issues.some(({ code }) => ["TASK_NOT_RUNNABLE", "INPUT_MISSING", "INPUT_NOT_FILE"].includes(code))
|
||||||
|
? ["do not start Blender or load WASM until the task state and inputs are ready"] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||||
|
if (issues.length !== 0) process.exitCode = 2;
|
||||||
@@ -4,13 +4,21 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import factory from "../../web/app/src/vendor/blender/web_engine.js";
|
|
||||||
import { readIndexedTask, verifyTaskIndex } from "./task-context-lib.mjs";
|
import { readIndexedTask, verifyTaskIndex } from "./task-context-lib.mjs";
|
||||||
|
import { collectGeneratedGapPreflight, formatGeneratedGapPreflight } from "./generated-gap-preflight.mjs";
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const task = process.argv[process.argv.indexOf("--task") + 1];
|
const taskArgument = process.argv.indexOf("--task");
|
||||||
|
const task = taskArgument >= 0 ? process.argv[taskArgument + 1] : undefined;
|
||||||
verifyTaskIndex();
|
verifyTaskIndex();
|
||||||
const entry = readIndexedTask(task); assert.ok(entry); assert.ok(["active", "completed"].includes(entry.state));
|
const entry = readIndexedTask(task);
|
||||||
|
const preflightIssues = collectGeneratedGapPreflight({ root, task, entry });
|
||||||
|
if (preflightIssues.length !== 0) {
|
||||||
|
process.stderr.write(`${formatGeneratedGapPreflight(task, preflightIssues)}\n`);
|
||||||
|
process.exitCode = 2;
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
const wasmBinary = fs.readFileSync(path.join(root, "web/app/src/vendor/blender/web_engine.wasm"));
|
const wasmBinary = fs.readFileSync(path.join(root, "web/app/src/vendor/blender/web_engine.wasm"));
|
||||||
|
const factory = async (options) => (await import("../../web/app/src/vendor/blender/web_engine.js")).default(options);
|
||||||
const open = (engine, handle, input) => { const pointer = engine._malloc(input.byteLength); engine.HEAPU8.set(input, pointer); try { assert.equal(engine._web_engine_open_blend(handle, pointer, input.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); } finally { engine._free(pointer); } };
|
const open = (engine, handle, input) => { const pointer = engine._malloc(input.byteLength); engine.HEAPU8.set(input, pointer); try { assert.equal(engine._web_engine_open_blend(handle, pointer, input.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); } finally { engine._free(pointer); } };
|
||||||
const output = (engine, handle, fn, owned = false) => { const data = engine._malloc(4); const length = engine._malloc(4); try { assert.equal(fn(handle, data, length), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); const pointer = engine.HEAPU32[data >>> 2]; const size = engine.HEAPU32[length >>> 2]; const result = engine.HEAPU8.slice(pointer, pointer + size); if (owned) engine._web_engine_free_buffer(pointer); return result; } finally { engine._free(data); engine._free(length); } };
|
const output = (engine, handle, fn, owned = false) => { const data = engine._malloc(4); const length = engine._malloc(4); try { assert.equal(fn(handle, data, length), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); const pointer = engine.HEAPU32[data >>> 2]; const size = engine.HEAPU32[length >>> 2]; const result = engine.HEAPU8.slice(pointer, pointer + size); if (owned) engine._web_engine_free_buffer(pointer); return result; } finally { engine._free(data); engine._free(length); } };
|
||||||
const snapshot = (engine, handle) => JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
|
const snapshot = (engine, handle) => JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
|
||||||
|
|||||||
45
tools/web/generated-gap-preflight.mjs
Normal file
45
tools/web/generated-gap-preflight.mjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const TASK_ID = /^M\d+-GAP-\d{5}$/u;
|
||||||
|
|
||||||
|
function add(issues, code, detail) {
|
||||||
|
issues.push({ code, detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the cheap, deterministic inputs before a generated-gap checker
|
||||||
|
* loads the WASM module or starts Blender. This is intentionally independent
|
||||||
|
* of the task-specific assertions in check-generated-gap.mjs.
|
||||||
|
*/
|
||||||
|
export function collectGeneratedGapPreflight({ root, task, entry }) {
|
||||||
|
const issues = [];
|
||||||
|
if (typeof task !== "string" || !TASK_ID.test(task)) {
|
||||||
|
add(issues, "TASK_ARGUMENT_INVALID", "use --task Mxx-GAP-nnnnn");
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
if (!entry) {
|
||||||
|
add(issues, "TASK_NOT_INDEXED", task);
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
if (!["active", "completed"].includes(entry.state)) {
|
||||||
|
add(issues, "TASK_NOT_RUNNABLE", `${task} state=${entry.state}; expected active or completed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = [];
|
||||||
|
if (entry.fixture?.path) required.push(["fixture", entry.fixture.path]);
|
||||||
|
const generator = typeof entry.desktopCommand === "string"
|
||||||
|
? entry.desktopCommand.match(/\s--python\s+(\S+)/u)?.[1]
|
||||||
|
: undefined;
|
||||||
|
if (generator) required.push(["generator", generator]);
|
||||||
|
for (const [role, relativePath] of required) {
|
||||||
|
const resolved = path.resolve(root, relativePath);
|
||||||
|
if (!fs.existsSync(resolved)) add(issues, "INPUT_MISSING", `${role}=${relativePath}`);
|
||||||
|
else if (!fs.statSync(resolved).isFile()) add(issues, "INPUT_NOT_FILE", `${role}=${relativePath}`);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGeneratedGapPreflight(task, issues) {
|
||||||
|
return `generated-gap-preflight-failed task=${task ?? "MISSING"} issues=${issues.map((issue) => `${issue.code}:${issue.detail}`).join(",")}`;
|
||||||
|
}
|
||||||
28
web/tests/unit/generated-gap-preflight.test.mjs
Normal file
28
web/tests/unit/generated-gap-preflight.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { collectGeneratedGapPreflight } from "../../../tools/web/generated-gap-preflight.mjs";
|
||||||
|
import { readIndexedTask, root, verifyTaskIndex } from "../../../tools/web/task-context-lib.mjs";
|
||||||
|
|
||||||
|
test("generated gap preflight accepts a runnable task with existing inputs", () => {
|
||||||
|
verifyTaskIndex();
|
||||||
|
const entry = readIndexedTask("M16-GAP-00281");
|
||||||
|
assert.deepEqual(collectGeneratedGapPreflight({ root, task: "M16-GAP-00281", entry }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generated gap preflight reports state and input failures before WASM startup", () => {
|
||||||
|
const issues = collectGeneratedGapPreflight({
|
||||||
|
root,
|
||||||
|
task: "M16-GAP-00282",
|
||||||
|
entry: {
|
||||||
|
id: "M16-GAP-00282",
|
||||||
|
state: "pending",
|
||||||
|
fixture: { path: "tests/files/web/generated/does-not-exist.blend" },
|
||||||
|
desktopCommand: "blender --python tools/web/generated/does-not-exist.py -- fixture.blend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(issues.map(({ code }) => code), ["TASK_NOT_RUNNABLE", "INPUT_MISSING", "INPUT_MISSING"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generated gap preflight rejects a missing task argument", () => {
|
||||||
|
assert.deepEqual(collectGeneratedGapPreflight({ root, task: undefined, entry: null }).map(({ code }) => code), ["TASK_ARGUMENT_INVALID"]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user