From 56acc843f1e92755d80f29ac21967c8b23b80ec2 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:39:34 +0800 Subject: [PATCH 01/15] research: prepare owned Node contract deadline diagnostics (#144) --- research/ui/node_contract_bootstrap.js | 24 ++ research/ui/node_contract_deadline.py | 294 +++++++++++++++++++++ research/ui/test_node_contract_deadline.py | 120 +++++++++ research/ui/test_raster_native_contract.js | 14 +- 4 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 research/ui/node_contract_bootstrap.js create mode 100644 research/ui/node_contract_deadline.py create mode 100644 research/ui/test_node_contract_deadline.py diff --git a/research/ui/node_contract_bootstrap.js b/research/ui/node_contract_bootstrap.js new file mode 100644 index 00000000..8bc304c1 --- /dev/null +++ b/research/ui/node_contract_bootstrap.js @@ -0,0 +1,24 @@ +'use strict'; +// Synchronous checkpoints survive an event-loop hang and precede test execution. +const fs=require('fs'); +const mode=process.argv[2]; +if(!['normal','hang-vm-helpers','throw-before-grab'].includes(mode))throw Error('Unknown diagnostic case'); +let sequence=0; +function observe(stage){ + const row={sequence:sequence++,stage,pid:process.pid,monotonic_ns:process.hrtime.bigint().toString()}; + if(stage==='node-first-checkpoint')Object.assign(row,{node_version:process.version,arch:process.arch,platform:process.platform}); + const bytes=Buffer.from('TF_NODE_CONTRACT_V1:'+JSON.stringify(row)+'\n'); + let offset=0; + while(offset= previous_clock, "Checkpoint clock moved backwards") + previous_clock = int(clock) + except (ValueError, KeyError, UnicodeError, TypeError) as error: + raise CheckpointError(str(error), observations) from error + observations.append(row) + return trace_result(observations) + + +def diagnose_node(node, case): + require(os.name == "nt", "This diagnostic requires Windows owned Jobs") + require(case in CASES, "Unexpected diagnostic case") + node = node.resolve(strict=True) + bootstrap = ROOT / "research/ui/node_contract_bootstrap.js" + record = {"schema": "termforge-node-contract-observation-v1", "case": case, "status": "failed", + "execution_budget_seconds": EXECUTION_SECONDS, "cleanup_budget_seconds": CLEANUP_SECONDS, + "source_files_sha256": source_identity(), "node_sha256": sha(node), + "process_created": False, "root_reaped": False, "root_in_owned_job": False, + "cleanup_verified": False, "forced_job_termination": False, "job_after": None, + "primary_exception": None, "stdout": "", "stderr": ""} + job, process, assigned = None, None, False + stdout, stderr = b"", b"" + cleanup_errors = [] + cleanup_deadline = None + + def remaining_cleanup(): + nonlocal cleanup_deadline + if cleanup_deadline is None: + cleanup_deadline = time.monotonic() + CLEANUP_SECONDS + return max(0, cleanup_deadline - time.monotonic()) + + try: + job = OwnedJob() + create_begin = time.monotonic() + process = subprocess.Popen([str(node), str(bootstrap), case], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW) + record.update(process_created=True, root_pid=process.pid, + process_creation_elapsed_seconds=time.monotonic() - create_begin) + # The original test's ten-second wait is not extended by Job/GO setup. + execution_deadline = time.monotonic() + EXECUTION_SECONDS + job.assign(process.pid) + assigned = True + api = job.api + api.IsProcessInJob.argtypes = [wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL)] + api.IsProcessInJob.restype = wintypes.BOOL + in_job = wintypes.BOOL() + job.check(api.IsProcessInJob(wintypes.HANDLE(int(process._handle)), job.handle, ctypes.byref(in_job))) + require(bool(in_job.value), "Retained root handle is not in the owned Job") + record["root_in_owned_job"] = True + record["job_before_go"] = job.accounting() + communicate_begin = time.monotonic() + try: + stdout, stderr = process.communicate(b"GO\n", timeout=max(0, execution_deadline - time.monotonic())) + except subprocess.TimeoutExpired as error: + stdout, stderr = error.output or b"", error.stderr or b"" + record["status"] = "timed_out" + record["primary_exception"] = {"type": "TimeoutExpired", "timeout_seconds": EXECUTION_SECONDS, + "message": str(error)} + try: + record["job_at_timeout"] = job.accounting() + except Exception as query_error: + cleanup_errors.append("query Job at timeout: " + str(query_error)) + finally: + record["communicate_elapsed_seconds"] = time.monotonic() - communicate_begin + except Exception as error: + if record["primary_exception"] is None: + record["primary_exception"] = {"type": type(error).__name__, "message": str(error)} + else: + cleanup_errors.append("secondary observation: " + str(error)) + finally: + if process is not None: + try: + if process.poll() is None: + remaining_cleanup() + if assigned: + job.terminate() + record["forced_job_termination"] = True + else: + process.kill() + stdout, stderr = process.communicate(timeout=remaining_cleanup()) + record["root_reaped"] = process.poll() is not None + record["exit_code"] = process.returncode + except Exception as error: + cleanup_errors.append("reap root: " + str(error)) + if job is not None: + try: + record["job_after"] = job.drain(timeout=remaining_cleanup()) + record["cleanup_verified"] = (record["root_reaped"] and record["root_in_owned_job"] and + record["job_after"]["active"] == 0) + except Exception as error: + cleanup_errors.append("query/drain owned Job: " + str(error)) + finally: + try: + job.close() + except Exception as error: + cleanup_errors.append("close owned Job: " + str(error)) + if process is not None: + for stream in (process.stdin, process.stdout, process.stderr): + try: + if stream is not None: + stream.close() + except Exception as error: + cleanup_errors.append("close root stream: " + str(error)) + record.update(stdout=stdout.decode("utf-8", "replace"), stderr=stderr.decode("utf-8", "replace")) + try: + record["trace"] = read_checkpoints(stderr, record.get("root_pid")) + except CheckpointError as error: + record["trace"] = trace_result(error.observations) + record["trace"]["protocol_error"] = str(error) + if cleanup_errors: + record["cleanup_errors"] = cleanup_errors + record["cleanup_verified"] = False + if record["status"] != "timed_out" and record["primary_exception"] is None: + if (record.get("exit_code") == 0 and record["cleanup_verified"] and record["trace"]["complete"] and + not record["forced_job_termination"] and record["stdout"] == NORMAL_STDOUT): + record["status"] = "passed" + return record + + +def expected_observation(row): + """Recognize this fixed diagnostic plan without changing child verdicts.""" + if not row["cleanup_verified"] or row["trace"].get("protocol_error"): + return False + if row["case"] == "normal": + return row["status"] == "passed" + if row["case"] == "hang-vm-helpers": + return (row["status"] == "timed_out" and row["trace"]["last_observed_stage"] == "vm-helpers-start" and + row["primary_exception"] is not None and row["primary_exception"]["type"] == "TimeoutExpired" and + row["forced_job_termination"]) + return (row["case"] == "throw-before-grab" and row["status"] == "failed" and row["exit_code"] != 0 and + not row["forced_job_termination"] and row["trace"]["last_observed_stage"] == "case-start:grab" and + "TF_NODE_CONTROLLED_EXCEPTION" in row["stderr"]) + + +def prepare_plan(destination, output): + destination, output = destination.resolve(), output.resolve() + require(destination.is_relative_to(ROOT / "local") and output.is_relative_to(ROOT / "local"), + "Plan and output must be owned local paths") + require(not destination.exists() and not output.exists(), "Plan/output already exists") + node = Path(shutil.which("node") or "").resolve(strict=True) + require(node.is_file(), "Node executable is missing") + data = {"schema": "termforge-node-contract-plan-v1", "issue": 144, "execution_authorized": False, + "maximum_attempts": 1, "execution_budget_seconds": EXECUTION_SECONDS, + "cleanup_budget_seconds": CLEANUP_SECONDS, "cases": list(CASES), + "source_base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), + "source_files_sha256": source_identity(), "node": str(node), "node_sha256": sha(node), + "python": str(Path(sys.executable).resolve()), "python_sha256": sha(Path(sys.executable)), + "python_version": sys.version, "windows_version": platform.version(), "out": str(output), + "whole_lifecycle_bound_seconds": None, + "scope": "One diagnostic batch: normal, controlled hang and controlled exception; no original-root-cause claim"} + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8", newline="\n") + return data + + +def run_plan(path): + path = path.resolve(strict=True) + plan = json.loads(path.read_text(encoding="utf-8")) + require(plan.get("execution_authorized") is True, "This is an offline proposal; execution is not authorized") + require(plan.get("schema") == "termforge-node-contract-plan-v1" and plan.get("issue") == 144 and + plan.get("maximum_attempts") == 1 and plan.get("cases") == list(CASES) and + plan.get("execution_budget_seconds") == EXECUTION_SECONDS and + plan.get("cleanup_budget_seconds") == CLEANUP_SECONDS, "Plan contract differs") + require(plan["source_base_commit"] == subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() and + plan["source_files_sha256"] == source_identity(), "Plan source identity changed") + node = Path(plan["node"]).resolve(strict=True) + require(sha(node) == plan["node_sha256"] and Path(sys.executable).resolve() == Path(plan["python"]) and + sha(Path(sys.executable)) == plan["python_sha256"], "Runtime identity changed") + output = Path(plan["out"]).resolve() + require(path.is_relative_to(ROOT / "local") and output.is_relative_to(ROOT / "local") and not output.exists(), + "Output must be a new owned local directory") + with path.with_suffix(".attempted").open("x", encoding="utf-8") as marker: + marker.write("One diagnostic attempt; retain after success or failure.\n") + output.mkdir(parents=True) + observations = [] + for index, case in enumerate(CASES): + row = diagnose_node(node, case) + (output / f"{index}-{case}.json").write_text(json.dumps(row, indent=2) + "\n", encoding="utf-8", newline="\n") + observations.append(row) + # Failed containment is terminal and never releases the next case. + if not row["cleanup_verified"]: + break + complete = len(observations) == len(CASES) and all(expected_observation(row) for row in observations) + return {"schema": "termforge-node-contract-batch-v1", "observations": observations, + "status": "expected_diagnostic_observations" if complete else "failed", + "original_timeout_root_cause": None, "whole_lifecycle_bound_seconds": None} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + prepare = commands.add_parser("prepare-plan") + prepare.add_argument("--plan", type=Path, required=True) + prepare.add_argument("--out", type=Path, required=True) + run = commands.add_parser("run-plan") + run.add_argument("--plan", type=Path, required=True) + args = parser.parse_args() + if args.command == "prepare-plan": + prepare_plan(args.plan, args.out) + print(json.dumps({"prepared": True, "execution_authorized": False})) + return 0 + result = run_plan(args.plan) + print(json.dumps(result)) + return 0 if result["status"] == "expected_diagnostic_observations" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/research/ui/test_node_contract_deadline.py b/research/ui/test_node_contract_deadline.py new file mode 100644 index 00000000..e2c4d872 --- /dev/null +++ b/research/ui/test_node_contract_deadline.py @@ -0,0 +1,120 @@ +"""Public trace/launcher boundaries for the bounded Node diagnostic.""" +import json +import os +from pathlib import Path +import shutil +import tempfile +import unittest + +from research.ui.node_contract_deadline import ( + ROOT, diagnose_node, prepare_plan, read_checkpoints, run_plan, +) + + +def trace(rows): + return b"".join(b"TF_NODE_CONTRACT_V1:" + json.dumps(row).encode() + b"\n" for row in rows) + + +class CheckpointTests(unittest.TestCase): + def test_partial_prefix_preserves_last_observed_stage(self): + rows = [ + {"sequence": 0, "stage": "node-first-checkpoint", "pid": 123, + "monotonic_ns": "100", "node_version": "v0.synthetic", "arch": "x64", "platform": "win32"}, + {"sequence": 1, "stage": "go-received", "pid": 123, "monotonic_ns": "110"}, + ] + result = read_checkpoints(trace(rows), 123) + self.assertEqual(result["last_observed_stage"], "go-received") + self.assertFalse(result["complete"]) + self.assertEqual(result["observations"], rows) + self.assertEqual(read_checkpoints(b"", 123)["last_observed_stage"], None) + + def test_sequence_pid_clock_and_unknown_stage_are_rejected(self): + first = {"sequence": 0, "stage": "node-first-checkpoint", "pid": 123, + "monotonic_ns": "100", "node_version": "v0.synthetic", "arch": "x64", "platform": "win32"} + for change in ({"sequence": 1}, {"pid": 124}, {"stage": "private-path"}, + {"monotonic_ns": "-1"}, {"node_version": 1}): + with self.subTest(change=change), self.assertRaises(ValueError): + read_checkpoints(trace([{**first, **change}]), 123) + second = {"sequence": 1, "stage": "go-received", "pid": 123, "monotonic_ns": "99"} + with self.assertRaises(ValueError): + read_checkpoints(trace([first, second]), 123) + + def test_truncated_json_and_fake_final_cannot_claim_complete(self): + with self.assertRaises(ValueError): + read_checkpoints(b'TF_NODE_CONTRACT_V1:{"sequence":0\n', 123) + with self.assertRaises(ValueError): + read_checkpoints(trace([{"sequence": 0, "stage": "final", "pid": 123, + "monotonic_ns": "1"}]), 123) + + +class PlanTests(unittest.TestCase): + def temporary(self): + (ROOT / "local").mkdir(exist_ok=True) + return tempfile.TemporaryDirectory(prefix="node-preflight-", dir=ROOT / "local") + + def test_false_plan_refuses_launch_and_retains_no_attempt_marker(self): + with self.temporary() as temporary: + plan = Path(temporary) / "plan.json" + prepare_plan(plan, Path(temporary) / "result") + with self.assertRaisesRegex(ValueError, "offline proposal"): + run_plan(plan) + self.assertFalse(plan.with_suffix(".attempted").exists()) + self.assertFalse((Path(temporary) / "result").exists()) + + def test_source_identity_change_is_rejected_before_launch(self): + with self.temporary() as temporary: + plan = Path(temporary) / "plan.json" + prepare_plan(plan, Path(temporary) / "result") + data = json.loads(plan.read_text(encoding="utf-8")) + data["execution_authorized"] = True + data["source_files_sha256"]["research/ui/node_contract_bootstrap.js"] = "0" * 64 + plan.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "source identity"): + run_plan(plan) + self.assertFalse(plan.with_suffix(".attempted").exists()) + + +@unittest.skipUnless(os.name == "nt" and shutil.which("node"), "Windows and Node are required") +class OwnedNodeTests(unittest.TestCase): + def check_case(self, case): + result = diagnose_node(Path(shutil.which("node")), case) + # These synthetic test observations remain separate from any frozen batch. + print("TF_NODE_PREFLIGHT_TEST_RESULT:" + json.dumps(result, sort_keys=True)) + self.assertTrue(result["cleanup_verified"], result) + self.assertTrue(result["root_reaped"], result) + self.assertTrue(result["root_in_owned_job"], result) + self.assertEqual(result["job_after"]["active"], 0) + return result + + def test_normal_retains_all_cases_and_natural_success(self): + result = self.check_case("normal") + self.assertEqual(result["status"], "passed", result) + self.assertEqual(result["exit_code"], 0) + self.assertFalse(result["forced_job_termination"]) + self.assertEqual(result["trace"]["last_observed_stage"], "final") + starts = [row["stage"] for row in result["trace"]["observations"] if row["stage"].startswith("case-start:")] + self.assertEqual(starts, ["case-start:" + case for case in ("normal", "grab", "toImage", "empty", "dpr", "pixel", "destroy")]) + + def test_controlled_hang_keeps_ten_second_failure_and_prefix(self): + result = self.check_case("hang-vm-helpers") + self.assertEqual(result["status"], "timed_out", result) + self.assertEqual(result["execution_budget_seconds"], 10) + self.assertEqual(result["primary_exception"]["type"], "TimeoutExpired") + self.assertEqual(result["trace"]["last_observed_stage"], "vm-helpers-start") + self.assertFalse(result["trace"]["complete"]) + self.assertTrue(result["forced_job_termination"]) + self.assertGreaterEqual(result["job_at_timeout"]["active"], 1) + self.assertGreaterEqual(result["communicate_elapsed_seconds"], 9) + + def test_controlled_exception_is_failed_despite_clean_job(self): + result = self.check_case("throw-before-grab") + self.assertEqual(result["status"], "failed", result) + self.assertNotEqual(result["exit_code"], 0) + self.assertFalse(result["forced_job_termination"]) + self.assertEqual(result["trace"]["last_observed_stage"], "case-start:grab") + self.assertIn("TF_NODE_CONTROLLED_EXCEPTION", result["stderr"]) + self.assertFalse(result["trace"]["complete"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/research/ui/test_raster_native_contract.js b/research/ui/test_raster_native_contract.js index 87ef6885..faef72c5 100644 --- a/research/ui/test_raster_native_contract.js +++ b/research/ui/test_raster_native_contract.js @@ -1,7 +1,11 @@ 'use strict'; // Run the production helper bodies with synthetic Qt calls, without Frida/Qt. const fs=require('fs'),vm=require('vm'),assert=require('assert'); +// The opt-in diagnostic observes this test; ordinary execution has no observer. +const observe=typeof global.__termforgeNodeContractObserve==='function'?global.__termforgeNodeContractObserve:()=>{}; +observe('before-file-read'); const source=fs.readFileSync(require('path').join(__dirname,'raster_style_reference.js'),'utf8'); +observe('file-read'); function helpers(extra={}){ const context=vm.createContext({Number,Map,JSON,Error,...extra}); vm.runInContext(source.slice(source.indexOf('function modulePathMatches'),source.indexOf('const moduleGuards')),context); @@ -10,7 +14,9 @@ function helpers(extra={}){ return context; } function equal(a,b){assert.strictEqual(JSON.stringify(a),JSON.stringify(b));} +observe('vm-helpers-start'); const pure=helpers(); +observe('vm-helpers-ready'); assert(pure.modulePathMatches('D:\\OWNED\\app\\Qt5Gui.dll','d:/owned','Qt5Gui.dll')); assert(!pure.modulePathMatches('D:/other/app/Qt5Gui.dll','d:/owned','Qt5Gui.dll')); equal(pure.rgba(0xff123456),[0x12,0x34,0x56,0xff]); @@ -19,6 +25,7 @@ assert.throws(()=>pure.physicalRect({x:7,y:0,width:2,height:1},1,8,2),/out-of-bo assert.throws(()=>pure.colors([1,1],[1,1]),/no-marker-ink/); assert.throws(()=>pure.colors([2,3],[1,1]),/dominant-ink-ambiguous/); assert.throws(()=>pure.colors([2,2],[1,3]),/space-background-ambiguous/); +observe('pure-checks-done'); function lifecycle(failure){ const calls=[],allocations=[]; function pointer(){const result={data:[],equals(other){return result===other;},writeS32(value){result.data[0]=value;},add(offset){return {writeS32(value){result.data[offset/4]=value;}};}};return result;} @@ -41,5 +48,10 @@ function lifecycle(failure){ else assert.strictEqual(capture.calibration,false); equal(context.grabs,[0]); } -for(const failure of [null,'grab','toImage','empty','dpr','pixel','destroy'])lifecycle(failure); +for(const failure of [null,'grab','toImage','empty','dpr','pixel','destroy']){ + observe('case-start:'+(failure===null?'normal':failure)); + lifecycle(failure); + observe('case-end:'+(failure===null?'normal':failure)); +} process.stdout.write('module paths, QRgb, ROI/ink gates,32B sret and normal-return destructor order passed\n'); +observe('final'); From 5ddecd4e0bd978d38a38974bb4d27b193b605fa7 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:47:19 +0800 Subject: [PATCH 02/15] research: preserve Node startup errors and freeze diagnostic boundaries (#144) --- docs/research/node-contract-deadline.md | 35 ++++++++++++++++++++++ research/ui/node_contract_deadline.py | 20 ++++++++----- research/ui/test_node_contract_deadline.py | 17 +++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 docs/research/node-contract-deadline.md diff --git a/docs/research/node-contract-deadline.md b/docs/research/node-contract-deadline.md new file mode 100644 index 00000000..3bf113c8 --- /dev/null +++ b/docs/research/node-contract-deadline.md @@ -0,0 +1,35 @@ +# Node 栅格纯夹具的有界阶段诊断 + +对应 [#144](https://github.com/JavaZeroo/termforge/issues/144)。旧 Windows CI 在 `test_raster_native_contract.js` 的原 10 秒等待中超时,没有 Node 阶段记录。这里提供后续有限调查的工具,**没有解释或修复旧超时**;新源码、新宿主或受控挂起的结果不能补写旧样本。 + +## 执行与证据边界 + +这是 Windows 专用、纯合成的诊断入口,不启动 WindTerm、Qt、ConPTY 或输入法,也不访问个人桌面。现有 CTest 仍按原入口执行该 JS;本工具没有自动接入或重试失败的 CI。 + +`test_raster_native_contract.js` 的原合成输入、七种生命周期情形及断言保留,只添加可选 observer;普通执行时 observer 为 no-op。`raster_style_reference.js` 等原版生产探针、旧计划与旧证据不改动。专用 bootstrap 在加载被测 JS 前等待 `GO`,宿主先将根进程加入新的 kill-on-close Job,再通过保留的根句柄核对归属,最后释放 `GO`。 + +固定 checkpoint 区分 Node 首次记录、GO、夹具加载、原文件读取、VM helpers、纯检查及七个 lifecycle case 的开始/结束。stderr 使用同步短记录;首条记录之前仍包含 Node 启动和内建 `fs` 加载的未知区间,不能将“未见首条”直接归因于进程创建。Node 单调时钟与宿主时间分别保存,不混作已校准的性能端点。观测开销未校准。 + +执行预算为原 **10 秒**;进程创建返回后的 Job/GO 设置占用同一预算,不另加等待。同步进程创建本身和准备/哈希不具备整程硬时限,所以 `whole_lifecycle_bound_seconds=null`。超时保留 `TimeoutExpired`、已收到的 stdout/stderr 和最后有效 checkpoint;截断或非法的下一条记录不删除此前有效前缀。检查点的数量、顺序、PID、字段和单调时钟不合约时,不能报告完整成功。 + +回收使用独立、共享的 **5 秒**预算,验证根已回收及自有 Job active=0;不能验证时不派发下一 case。Job 仅约束该可信、无外部代理调用的自包含测试进程及正常继承成员,不是通用沙箱或外部服务回收证明。原错误、退出码、是否强杀与回收结果分别保留;清理成功不会把超时或异常变成通过。 + +## 固定计划 + +先提交工具源码,再在新的 `local/` 路径准备不可复用的离线提案: + +```powershell +python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan local/node-144-proposal.json --out local/node-144-proposed-output +``` + +提案默认 `execution_authorized=false`,`run-plan` 在创建 attempt marker 或子进程前拒绝。计划固定 Git HEAD、五个实际源码文件、Node/Python EXE 哈希、运行时信息、预算和三个顺序 case;旧 CI 的实际 Node 身份和缺失阶段仍未知。只在独立审查后,依据既有授权用新的路径准备 `--execution-authorized` 计划;保留原 false 提案,不编辑它。 + +三个 case 为正常执行、在 VM helpers 开始处受控挂起、在 grab case 开始处受控异常。每份计划最多尝试一次;独占 `.attempted` marker 在成功或失败后均保留,输出目录须不存在。控制异常和挂起仅检验诊断及清理能力,不能证明旧原因相同。 + +`run-plan --plan <新计划>` 将原始合成记录写入该计划的私有输出目录。正常 case 只有完整检查点、原 stdout、自然 exit0 和回收均成立才通过;另两 case 分别仍为 `timed_out`、`failed`。CLI 只有观察到三种预先固定的诊断结果才返回0,汇总名为 `expected_diagnostic_observations`,不是产品成功或旧缺陷修复。 + +## 针对性验证 + +公开边界为计划/launcher 和 checkpoint 记录。`research.ui.test_node_contract_deadline` 检查前缀保留、顺序/PID/字段/时钟拒绝、假 final、false/漂移计划拒绝,以及 Windows OwnedJob 中正常、受控挂起和异常三条独立路径。后两条不能因 Job 清空而转为通过。 + +原件、环境路径、stderr 栈和观察数据留 `local/` 待审查;本说明不发布新样本或为旧超时填写根因。#144、#38 和 R2 继续开放。 diff --git a/research/ui/node_contract_deadline.py b/research/ui/node_contract_deadline.py index 4a1ac4a6..edb8dfb0 100644 --- a/research/ui/node_contract_deadline.py +++ b/research/ui/node_contract_deadline.py @@ -105,6 +105,7 @@ def diagnose_node(node, case): "primary_exception": None, "stdout": "", "stderr": ""} job, process, assigned = None, None, False stdout, stderr = b"", b"" + drained = False cleanup_errors = [] cleanup_deadline = None @@ -137,6 +138,7 @@ def remaining_cleanup(): communicate_begin = time.monotonic() try: stdout, stderr = process.communicate(b"GO\n", timeout=max(0, execution_deadline - time.monotonic())) + drained = True except subprocess.TimeoutExpired as error: stdout, stderr = error.output or b"", error.stderr or b"" record["status"] = "timed_out" @@ -156,14 +158,15 @@ def remaining_cleanup(): finally: if process is not None: try: - if process.poll() is None: + if not drained: remaining_cleanup() - if assigned: + if assigned and job.accounting()["active"]: job.terminate() record["forced_job_termination"] = True - else: + elif not assigned and process.poll() is None: process.kill() stdout, stderr = process.communicate(timeout=remaining_cleanup()) + drained = True record["root_reaped"] = process.poll() is not None record["exit_code"] = process.returncode except Exception as error: @@ -180,7 +183,7 @@ def remaining_cleanup(): job.close() except Exception as error: cleanup_errors.append("close owned Job: " + str(error)) - if process is not None: + if process is not None and drained: for stream in (process.stdin, process.stdout, process.stderr): try: if stream is not None: @@ -218,14 +221,14 @@ def expected_observation(row): "TF_NODE_CONTROLLED_EXCEPTION" in row["stderr"]) -def prepare_plan(destination, output): +def prepare_plan(destination, output, execution_authorized=False): destination, output = destination.resolve(), output.resolve() require(destination.is_relative_to(ROOT / "local") and output.is_relative_to(ROOT / "local"), "Plan and output must be owned local paths") require(not destination.exists() and not output.exists(), "Plan/output already exists") node = Path(shutil.which("node") or "").resolve(strict=True) require(node.is_file(), "Node executable is missing") - data = {"schema": "termforge-node-contract-plan-v1", "issue": 144, "execution_authorized": False, + data = {"schema": "termforge-node-contract-plan-v1", "issue": 144, "execution_authorized": execution_authorized, "maximum_attempts": 1, "execution_budget_seconds": EXECUTION_SECONDS, "cleanup_budget_seconds": CLEANUP_SECONDS, "cases": list(CASES), "source_base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), @@ -278,12 +281,13 @@ def main(): prepare = commands.add_parser("prepare-plan") prepare.add_argument("--plan", type=Path, required=True) prepare.add_argument("--out", type=Path, required=True) + prepare.add_argument("--execution-authorized", action="store_true") run = commands.add_parser("run-plan") run.add_argument("--plan", type=Path, required=True) args = parser.parse_args() if args.command == "prepare-plan": - prepare_plan(args.plan, args.out) - print(json.dumps({"prepared": True, "execution_authorized": False})) + prepare_plan(args.plan, args.out, args.execution_authorized) + print(json.dumps({"prepared": True, "execution_authorized": args.execution_authorized})) return 0 result = run_plan(args.plan) print(json.dumps(result)) diff --git a/research/ui/test_node_contract_deadline.py b/research/ui/test_node_contract_deadline.py index e2c4d872..402b0564 100644 --- a/research/ui/test_node_contract_deadline.py +++ b/research/ui/test_node_contract_deadline.py @@ -5,6 +5,7 @@ import shutil import tempfile import unittest +from unittest.mock import patch from research.ui.node_contract_deadline import ( ROOT, diagnose_node, prepare_plan, read_checkpoints, run_plan, @@ -46,6 +47,13 @@ def test_truncated_json_and_fake_final_cannot_claim_complete(self): read_checkpoints(trace([{"sequence": 0, "stage": "final", "pid": 123, "monotonic_ns": "1"}]), 123) + def test_truncated_tail_retains_verified_prefix(self): + first = {"sequence": 0, "stage": "node-first-checkpoint", "pid": 123, + "monotonic_ns": "100", "node_version": "v0.synthetic", "arch": "x64", "platform": "win32"} + with self.assertRaises(ValueError) as caught: + read_checkpoints(trace([first]) + b'TF_NODE_CONTRACT_V1:{"sequence":', 123) + self.assertEqual(caught.exception.observations, [first]) + class PlanTests(unittest.TestCase): def temporary(self): @@ -115,6 +123,15 @@ def test_controlled_exception_is_failed_despite_clean_job(self): self.assertIn("TF_NODE_CONTROLLED_EXCEPTION", result["stderr"]) self.assertFalse(result["trace"]["complete"]) + def test_node_startup_error_retains_stderr_without_checkpoint(self): + with patch.dict(os.environ, {"NODE_OPTIONS": "--termforge-invalid-preflight-option"}): + result = diagnose_node(Path(shutil.which("node")), "normal") + print("TF_NODE_PREFLIGHT_STARTUP_ERROR_RESULT:" + json.dumps(result, sort_keys=True)) + self.assertEqual(result["status"], "failed", result) + self.assertTrue(result["root_reaped"], result) + self.assertIn("termforge-invalid-preflight-option", result["stderr"]) + self.assertEqual(result["trace"]["observations"], []) + if __name__ == "__main__": unittest.main() From 66a03916f96891c64a88e4528438afdfafb575e0 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:54:58 +0800 Subject: [PATCH 03/15] test: reproduce passing Node verdict after invalid trace tail (#144) --- research/ui/node_contract_deadline.py | 30 ++++++++++++------ research/ui/test_node_contract_deadline.py | 36 +++++++++++++++++++++- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/research/ui/node_contract_deadline.py b/research/ui/node_contract_deadline.py index edb8dfb0..75ed7262 100644 --- a/research/ui/node_contract_deadline.py +++ b/research/ui/node_contract_deadline.py @@ -92,6 +92,25 @@ def read_checkpoints(stderr, root_pid): return trace_result(observations) +def read_diagnostic_trace(stderr, root_pid): + """Return verified prefix data as well as the next protocol failure.""" + try: + return read_checkpoints(stderr, root_pid) + except CheckpointError as error: + result = trace_result(error.observations) + result["protocol_error"] = str(error) + return result + + +def observation_status(record): + """Judge one child observation independently of batch expectations.""" + if record["status"] != "timed_out" and record["primary_exception"] is None: + if (record.get("exit_code") == 0 and record["cleanup_verified"] and record["trace"]["complete"] and + not record["forced_job_termination"] and record["stdout"] == NORMAL_STDOUT): + return "passed" + return record["status"] + + def diagnose_node(node, case): require(os.name == "nt", "This diagnostic requires Windows owned Jobs") require(case in CASES, "Unexpected diagnostic case") @@ -191,18 +210,11 @@ def remaining_cleanup(): except Exception as error: cleanup_errors.append("close root stream: " + str(error)) record.update(stdout=stdout.decode("utf-8", "replace"), stderr=stderr.decode("utf-8", "replace")) - try: - record["trace"] = read_checkpoints(stderr, record.get("root_pid")) - except CheckpointError as error: - record["trace"] = trace_result(error.observations) - record["trace"]["protocol_error"] = str(error) + record["trace"] = read_diagnostic_trace(stderr, record.get("root_pid")) if cleanup_errors: record["cleanup_errors"] = cleanup_errors record["cleanup_verified"] = False - if record["status"] != "timed_out" and record["primary_exception"] is None: - if (record.get("exit_code") == 0 and record["cleanup_verified"] and record["trace"]["complete"] and - not record["forced_job_termination"] and record["stdout"] == NORMAL_STDOUT): - record["status"] = "passed" + record["status"] = observation_status(record) return record diff --git a/research/ui/test_node_contract_deadline.py b/research/ui/test_node_contract_deadline.py index 402b0564..a30fddec 100644 --- a/research/ui/test_node_contract_deadline.py +++ b/research/ui/test_node_contract_deadline.py @@ -8,7 +8,7 @@ from unittest.mock import patch from research.ui.node_contract_deadline import ( - ROOT, diagnose_node, prepare_plan, read_checkpoints, run_plan, + ROOT, diagnose_node, observation_status, prepare_plan, read_checkpoints, read_diagnostic_trace, run_plan, ) @@ -55,6 +55,40 @@ def test_truncated_tail_retains_verified_prefix(self): self.assertEqual(caught.exception.observations, [first]) +class LauncherVerdictTests(unittest.TestCase): + def normal_record(self, suffix=b""): + # Literal protocol witness; no Node/Job process is simulated here. + stages = ["node-first-checkpoint", "go-received", "fixture-before-load", "before-file-read", + "file-read", "vm-helpers-start", "vm-helpers-ready", "pure-checks-done"] + for case in ("normal", "grab", "toImage", "empty", "dpr", "pixel", "destroy"): + stages.extend(("case-start:" + case, "case-end:" + case)) + stages.append("final") + rows = [{"sequence": index, "stage": stage, "pid": 123, "monotonic_ns": str(100 + index)} + for index, stage in enumerate(stages)] + rows[0].update(node_version="v0.synthetic", arch="x64", platform="win32") + return {"status": "failed", "primary_exception": None, "exit_code": 0, + "cleanup_verified": True, "forced_job_termination": False, + "stdout": "module paths, QRgb, ROI/ink gates,32B sret and normal-return destructor order passed\n", + "trace": read_diagnostic_trace(trace(rows) + suffix, 123)} + + def test_valid_complete_control_can_pass(self): + row = self.normal_record() + self.assertEqual(observation_status(row), "passed") + self.assertEqual(len(row["trace"]["observations"]), 23) + + def test_invalid_tail_after_full_prefix_fails_child_despite_clean_exit(self): + tails = (b'TF_NODE_CONTRACT_V1:{"sequence":23\n', + trace([{"sequence": 23, "stage": "extra", "pid": 123, "monotonic_ns": "123"}])) + for suffix in tails: + with self.subTest(suffix=suffix): + row = self.normal_record(suffix) + self.assertEqual(observation_status(row), "failed") + self.assertFalse(row["trace"]["complete"]) + self.assertIn("protocol_error", row["trace"]) + self.assertEqual(len(row["trace"]["observations"]), 23) + self.assertEqual(row["trace"]["last_observed_stage"], "final") + + class PlanTests(unittest.TestCase): def temporary(self): (ROOT / "local").mkdir(exist_ok=True) From 3b74a947f09880d67d12eececa6907b7278e7640 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:55:40 +0800 Subject: [PATCH 04/15] fix(research): reject Node protocol errors after complete prefix (#144) --- docs/research/node-contract-deadline.md | 8 ++++++++ research/ui/node_contract_deadline.py | 16 +++++++++------- research/ui/test_node_contract_deadline.py | 2 ++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/research/node-contract-deadline.md b/docs/research/node-contract-deadline.md index 3bf113c8..c645beda 100644 --- a/docs/research/node-contract-deadline.md +++ b/docs/research/node-contract-deadline.md @@ -12,6 +12,8 @@ 执行预算为原 **10 秒**;进程创建返回后的 Job/GO 设置占用同一预算,不另加等待。同步进程创建本身和准备/哈希不具备整程硬时限,所以 `whole_lifecycle_bound_seconds=null`。超时保留 `TimeoutExpired`、已收到的 stdout/stderr 和最后有效 checkpoint;截断或非法的下一条记录不删除此前有效前缀。检查点的数量、顺序、PID、字段和单调时钟不合约时,不能报告完整成功。 +即使全部23个必需 checkpoint 已出现,后续多余或非法记录仍使 `trace.complete=false`,子记录判为 `failed`;正常 stdout、exit0 和 Job 回收不能覆盖协议错误。状态判定也独立拒绝带有 `protocol_error` 的旧完整标记,前缀仍保留供诊断。 + 回收使用独立、共享的 **5 秒**预算,验证根已回收及自有 Job active=0;不能验证时不派发下一 case。Job 仅约束该可信、无外部代理调用的自包含测试进程及正常继承成员,不是通用沙箱或外部服务回收证明。原错误、退出码、是否强杀与回收结果分别保留;清理成功不会把超时或异常变成通过。 ## 固定计划 @@ -33,3 +35,9 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan loca 公开边界为计划/launcher 和 checkpoint 记录。`research.ui.test_node_contract_deadline` 检查前缀保留、顺序/PID/字段/时钟拒绝、假 final、false/漂移计划拒绝,以及 Windows OwnedJob 中正常、受控挂起和异常三条独立路径。后两条不能因 Job 清空而转为通过。 原件、环境路径、stderr 栈和观察数据留 `local/` 待审查;本说明不发布新样本或为旧超时填写根因。#144、#38 和 R2 继续开放。 + +## 预审发现与限定修正 + +`5ddecd4` 预审发现:完整23条前缀后追加非法/多余 checkpoint 时,解析错误保留完整前缀,原子状态判定却只检查前缀的 `complete`,可能误报 `passed`。批次层已有拒绝仍不足以保证子记录正确。该缺陷属于新诊断工具,与旧 CI 的10秒超时没有因果关系。 + +公开 trace-data/launcher 判定边界的两个负例在 `66a0391` 均实际失败:正常 stdout、exit0、清理成功时,非法 JSON 尾和额外事件仍被旧子判定返回 `passed`。修正同时清除错误轨迹的完整标记、在子判定直接拒绝协议错误,并保留23条有效前缀;测试另保留完整合法对照及带错误的旧 `complete=true` 输入。相关纯测试的普通/`-O` 运行身份与原始记录留新的 `local/node-deadline-p2/`,不重跑此前的 Node/受控10秒挂起观察,也不改写旧计划或原件。 diff --git a/research/ui/node_contract_deadline.py b/research/ui/node_contract_deadline.py index 75ed7262..aefb6480 100644 --- a/research/ui/node_contract_deadline.py +++ b/research/ui/node_contract_deadline.py @@ -98,17 +98,19 @@ def read_diagnostic_trace(stderr, root_pid): return read_checkpoints(stderr, root_pid) except CheckpointError as error: result = trace_result(error.observations) - result["protocol_error"] = str(error) + result.update(complete=False, protocol_error=str(error)) return result def observation_status(record): """Judge one child observation independently of batch expectations.""" - if record["status"] != "timed_out" and record["primary_exception"] is None: - if (record.get("exit_code") == 0 and record["cleanup_verified"] and record["trace"]["complete"] and - not record["forced_job_termination"] and record["stdout"] == NORMAL_STDOUT): - return "passed" - return record["status"] + if record["status"] == "timed_out": + return "timed_out" + if (record["primary_exception"] is None and record.get("exit_code") == 0 and record["cleanup_verified"] and + record["trace"]["complete"] and "protocol_error" not in record["trace"] and + not record["forced_job_termination"] and record["stdout"] == NORMAL_STDOUT): + return "passed" + return "failed" def diagnose_node(node, case): @@ -220,7 +222,7 @@ def remaining_cleanup(): def expected_observation(row): """Recognize this fixed diagnostic plan without changing child verdicts.""" - if not row["cleanup_verified"] or row["trace"].get("protocol_error"): + if not row["cleanup_verified"] or "protocol_error" in row["trace"]: return False if row["case"] == "normal": return row["status"] == "passed" diff --git a/research/ui/test_node_contract_deadline.py b/research/ui/test_node_contract_deadline.py index a30fddec..8fc5d078 100644 --- a/research/ui/test_node_contract_deadline.py +++ b/research/ui/test_node_contract_deadline.py @@ -87,6 +87,8 @@ def test_invalid_tail_after_full_prefix_fails_child_despite_clean_exit(self): self.assertIn("protocol_error", row["trace"]) self.assertEqual(len(row["trace"]["observations"]), 23) self.assertEqual(row["trace"]["last_observed_stage"], "final") + stale_complete = {**row, "trace": {**row["trace"], "complete": True}} + self.assertEqual(observation_status(stale_complete), "failed") class PlanTests(unittest.TestCase): From 0949ac6fc371e24ee20c25c36b6e90f372c99fe8 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:56:52 +0800 Subject: [PATCH 05/15] docs: record Node protocol verdict red and green source identities (#144) --- docs/research/node-contract-deadline.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/research/node-contract-deadline.md b/docs/research/node-contract-deadline.md index c645beda..71652ea3 100644 --- a/docs/research/node-contract-deadline.md +++ b/docs/research/node-contract-deadline.md @@ -41,3 +41,5 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan loca `5ddecd4` 预审发现:完整23条前缀后追加非法/多余 checkpoint 时,解析错误保留完整前缀,原子状态判定却只检查前缀的 `complete`,可能误报 `passed`。批次层已有拒绝仍不足以保证子记录正确。该缺陷属于新诊断工具,与旧 CI 的10秒超时没有因果关系。 公开 trace-data/launcher 判定边界的两个负例在 `66a0391` 均实际失败:正常 stdout、exit0、清理成功时,非法 JSON 尾和额外事件仍被旧子判定返回 `passed`。修正同时清除错误轨迹的完整标记、在子判定直接拒绝协议错误,并保留23条有效前缀;测试另保留完整合法对照及带错误的旧 `complete=true` 输入。相关纯测试的普通/`-O` 运行身份与原始记录留新的 `local/node-deadline-p2/`,不重跑此前的 Node/受控10秒挂起观察,也不改写旧计划或原件。 + +实际修正验证源为 `3b74a947f09880d67d12eececa6907b7278e7640`:普通与 `-O` 各8项纯边界测试通过、无跳过。此前的 Node/受控进程观察仍仅属于 `5ddecd4`,不转记为新源码的执行结果;本次末尾只补说明和准备新 false 提案,正式有限批次仍待审查。 From cebc530d4251eae5d503878acf005caa8b7b7377 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:06:27 +0800 Subject: [PATCH 06/15] test: reject Node diagnostic runtime version drift before attempt (#144) --- research/ui/test_node_contract_deadline.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/research/ui/test_node_contract_deadline.py b/research/ui/test_node_contract_deadline.py index 8fc5d078..11cdd362 100644 --- a/research/ui/test_node_contract_deadline.py +++ b/research/ui/test_node_contract_deadline.py @@ -117,6 +117,30 @@ def test_source_identity_change_is_rejected_before_launch(self): run_plan(plan) self.assertFalse(plan.with_suffix(".attempted").exists()) + def test_runtime_version_drift_is_rejected_before_attempt(self): + drifts = (("research.ui.node_contract_deadline.platform.version", {"return_value": "changed-windows-version"}), + ("research.ui.node_contract_deadline.sys.version", {"new": "changed-python-version"})) + original_open = Path.open + for target, change in drifts: + with self.subTest(target=target), self.temporary() as temporary: + plan = Path(temporary) / "plan.json" + output = Path(temporary) / "result" + prepare_plan(plan, output, execution_authorized=True) + marker = plan.with_suffix(".attempted") + + def stop_attempt(candidate, *args, **kwargs): + if candidate == marker: + self.fail("runtime version drift reached the attempt gate") + return original_open(candidate, *args, **kwargs) + + # Intercept the documented filesystem gate even in the red + # version; no marker, output or Node process may be created. + with patch(target, **change), patch.object(Path, "open", stop_attempt): + with self.assertRaisesRegex(ValueError, "runtime version identity"): + run_plan(plan) + self.assertFalse(marker.exists()) + self.assertFalse(output.exists()) + @unittest.skipUnless(os.name == "nt" and shutil.which("node"), "Windows and Node are required") class OwnedNodeTests(unittest.TestCase): From b8097064df229e5f6363be5c08a4acb31cd26554 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:07:22 +0800 Subject: [PATCH 07/15] fix(research): verify Node diagnostic host versions before attempt (#144) --- docs/research/node-contract-deadline.md | 8 +++++++- research/ui/node_contract_deadline.py | 12 ++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/research/node-contract-deadline.md b/docs/research/node-contract-deadline.md index 71652ea3..b3c26a02 100644 --- a/docs/research/node-contract-deadline.md +++ b/docs/research/node-contract-deadline.md @@ -24,7 +24,7 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan local/node-144-proposal.json --out local/node-144-proposed-output ``` -提案默认 `execution_authorized=false`,`run-plan` 在创建 attempt marker 或子进程前拒绝。计划固定 Git HEAD、五个实际源码文件、Node/Python EXE 哈希、运行时信息、预算和三个顺序 case;旧 CI 的实际 Node 身份和缺失阶段仍未知。只在独立审查后,依据既有授权用新的路径准备 `--execution-authorized` 计划;保留原 false 提案,不编辑它。 +提案默认 `execution_authorized=false`,`run-plan` 在创建 attempt marker 或子进程前拒绝。计划固定 Git HEAD、五个实际源码文件、Node/Python EXE 哈希、实际 `windows_version` / `python_version`、预算和三个顺序 case。`run-plan` 在 attempt marker 前重新读取并严格比较两个版本字符串,漂移时不创建 marker 或输出目录。批次记录使用该次实际读取值,各子观察再记录当时实际读取值,不把计划请求值当实际身份。这仅是两项版本核对,不是完整机器指纹;旧 CI 的实际 Node 身份和缺失阶段仍未知。只在独立审查后,依据既有授权用新的路径准备 `--execution-authorized` 计划;保留原 false 提案,不编辑它。 三个 case 为正常执行、在 VM helpers 开始处受控挂起、在 grab case 开始处受控异常。每份计划最多尝试一次;独占 `.attempted` marker 在成功或失败后均保留,输出目录须不存在。控制异常和挂起仅检验诊断及清理能力,不能证明旧原因相同。 @@ -43,3 +43,9 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan loca 公开 trace-data/launcher 判定边界的两个负例在 `66a0391` 均实际失败:正常 stdout、exit0、清理成功时,非法 JSON 尾和额外事件仍被旧子判定返回 `passed`。修正同时清除错误轨迹的完整标记、在子判定直接拒绝协议错误,并保留23条有效前缀;测试另保留完整合法对照及带错误的旧 `complete=true` 输入。相关纯测试的普通/`-O` 运行身份与原始记录留新的 `local/node-deadline-p2/`,不重跑此前的 Node/受控10秒挂起观察,也不改写旧计划或原件。 实际修正验证源为 `3b74a947f09880d67d12eececa6907b7278e7640`:普通与 `-O` 各8项纯边界测试通过、无跳过。此前的 Node/受控进程观察仍仅属于 `5ddecd4`,不转记为新源码的执行结果;本次末尾只补说明和准备新 false 提案,正式有限批次仍待审查。 + +## 版本漂移的预审修正 + +`0949ac6` 预审发现,计划虽记录 Windows/Python 版本字符串,执行前却只复验了 EXE 哈希,没有核对这两个已声明值。公开计划边界的受控反例在 `cebc530` 将 `platform.version()`、`sys.version` 分别替换为不同字符串,两案均实际到达 attempt gate;测试在该文件边界拦截,未创建 marker、输出或 Node 进程。这不是实际操作系统版本改变,也不能解释旧 CI 超时。 + +修正将两个当前版本的严格比较放在 marker 前,并在新批次和新子观察中记录实际值。相关纯计划测试的源码身份和日志保留在新的 `local/node-deadline-p3/`;此前 false 提案、原始观察和首次 P2 的纯日志保持原字节,不重新运行 Node 或10秒受控场景。 diff --git a/research/ui/node_contract_deadline.py b/research/ui/node_contract_deadline.py index aefb6480..ac514cfc 100644 --- a/research/ui/node_contract_deadline.py +++ b/research/ui/node_contract_deadline.py @@ -47,6 +47,10 @@ def source_identity(): return {name: sha(ROOT / name) for name in SOURCE_FILES} +def runtime_versions(): + return {"windows_version": platform.version(), "python_version": sys.version} + + class CheckpointError(ValueError): """Keep verified prefix rows when the next row is truncated or invalid.""" def __init__(self, message, observations): @@ -119,6 +123,7 @@ def diagnose_node(node, case): node = node.resolve(strict=True) bootstrap = ROOT / "research/ui/node_contract_bootstrap.js" record = {"schema": "termforge-node-contract-observation-v1", "case": case, "status": "failed", + **runtime_versions(), "execution_budget_seconds": EXECUTION_SECONDS, "cleanup_budget_seconds": CLEANUP_SECONDS, "source_files_sha256": source_identity(), "node_sha256": sha(node), "process_created": False, "root_reaped": False, "root_in_owned_job": False, @@ -248,7 +253,7 @@ def prepare_plan(destination, output, execution_authorized=False): "source_base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), "source_files_sha256": source_identity(), "node": str(node), "node_sha256": sha(node), "python": str(Path(sys.executable).resolve()), "python_sha256": sha(Path(sys.executable)), - "python_version": sys.version, "windows_version": platform.version(), "out": str(output), + **runtime_versions(), "out": str(output), "whole_lifecycle_bound_seconds": None, "scope": "One diagnostic batch: normal, controlled hang and controlled exception; no original-root-cause claim"} destination.parent.mkdir(parents=True, exist_ok=True) @@ -269,6 +274,9 @@ def run_plan(path): node = Path(plan["node"]).resolve(strict=True) require(sha(node) == plan["node_sha256"] and Path(sys.executable).resolve() == Path(plan["python"]) and sha(Path(sys.executable)) == plan["python_sha256"], "Runtime identity changed") + actual_versions = runtime_versions() + require(all(type(value) is str and value and plan.get(name) == value for name, value in actual_versions.items()), + "Host runtime version identity changed") output = Path(plan["out"]).resolve() require(path.is_relative_to(ROOT / "local") and output.is_relative_to(ROOT / "local") and not output.exists(), "Output must be a new owned local directory") @@ -284,7 +292,7 @@ def run_plan(path): if not row["cleanup_verified"]: break complete = len(observations) == len(CASES) and all(expected_observation(row) for row in observations) - return {"schema": "termforge-node-contract-batch-v1", "observations": observations, + return {"schema": "termforge-node-contract-batch-v1", **actual_versions, "observations": observations, "status": "expected_diagnostic_observations" if complete else "failed", "original_timeout_root_cause": None, "whole_lifecycle_bound_seconds": None} From 02aac45ca6e8eaffcbca1d3ef1a80d243159ec46 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:08:34 +0800 Subject: [PATCH 08/15] docs: record Node diagnostic version gate validation source (#144) --- docs/research/node-contract-deadline.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/research/node-contract-deadline.md b/docs/research/node-contract-deadline.md index b3c26a02..eea3d420 100644 --- a/docs/research/node-contract-deadline.md +++ b/docs/research/node-contract-deadline.md @@ -49,3 +49,5 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan loca `0949ac6` 预审发现,计划虽记录 Windows/Python 版本字符串,执行前却只复验了 EXE 哈希,没有核对这两个已声明值。公开计划边界的受控反例在 `cebc530` 将 `platform.version()`、`sys.version` 分别替换为不同字符串,两案均实际到达 attempt gate;测试在该文件边界拦截,未创建 marker、输出或 Node 进程。这不是实际操作系统版本改变,也不能解释旧 CI 超时。 修正将两个当前版本的严格比较放在 marker 前,并在新批次和新子观察中记录实际值。相关纯计划测试的源码身份和日志保留在新的 `local/node-deadline-p3/`;此前 false 提案、原始观察和首次 P2 的纯日志保持原字节,不重新运行 Node 或10秒受控场景。 + +实际修正验证源为 `b8097064df229e5f6363be5c08a4acb31cd26554`:相关3项纯计划测试在普通与 `-O` 均通过、无跳过;两种漂移均在 marker 前拒绝。此次没有执行新的 Node 或正式诊断批次,也不把先前8/10项验证转记为这一源码的进程观察结果。 From d697518edaa38e14cd589909ab23fe74a1eeb6a8 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:40:20 +0800 Subject: [PATCH 09/15] research: add offline whitelist export for fixed Node diagnostic (#144) --- research/ui/export_node_contract_deadline.py | 173 +++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 research/ui/export_node_contract_deadline.py diff --git a/research/ui/export_node_contract_deadline.py b/research/ui/export_node_contract_deadline.py new file mode 100644 index 00000000..9a79a344 --- /dev/null +++ b/research/ui/export_node_contract_deadline.py @@ -0,0 +1,173 @@ +"""Offline whitelist export of the fixed #144 three-case diagnostic batch.""" +import argparse +import hashlib +import json +import math +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] +EXECUTED_SOURCE = "02aac45ca6e8eaffcbca1d3ef1a80d243159ec46" +PLAN_SHA256 = "87a0518c23b65f050d3991557b6b58aca334c8d80dc939fab601d3fae01f0fc8" +CASES = ("normal", "hang-vm-helpers", "throw-before-grab") +SOURCE_FILES = ("research/ui/node_contract_deadline.py", "research/ui/node_contract_bootstrap.js", + "research/ui/test_raster_native_contract.js", "research/ui/raster_style_reference.js", + "experiments/conpty/run.py") +STAGES = ["node-first-checkpoint", "go-received", "fixture-before-load", "before-file-read", + "file-read", "vm-helpers-start", "vm-helpers-ready", "pure-checks-done"] +for _case in ("normal", "grab", "toImage", "empty", "dpr", "pixel", "destroy"): + STAGES.extend(("case-start:" + _case, "case-end:" + _case)) +STAGES.append("final") + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def load(path): + return json.loads(path.read_bytes().decode("utf-8-sig")) + + +def file_identity(label, path): + data = path.read_bytes() + return {"id": label, "bytes": len(data), "sha256": digest(data)} + + +def account(value): + require(type(value) is dict and set(value) == {"active", "total", "terminated"}, "Job fields differ") + require(all(type(number) is int and number >= 0 for number in value.values()), "Invalid Job count") + return {name: value[name] for name in ("active", "total", "terminated")} + + +def export(plan_path, log_path, exit_path, case_directory): + require(digest(plan_path.read_bytes()) == PLAN_SHA256, "Authorized plan identity differs") + plan, batch, exit_record = load(plan_path), load(log_path), load(exit_path) + require(plan["source_base_commit"] == EXECUTED_SOURCE and plan["execution_authorized"] is True and + plan["maximum_attempts"] == 1 and plan["cases"] == list(CASES), "Plan contract differs") + require(plan["execution_budget_seconds"] == 10 and plan["cleanup_budget_seconds"] == 5 and + plan["whole_lifecycle_bound_seconds"] is None, "Plan budget differs") + require(set(plan["source_files_sha256"]) == set(SOURCE_FILES), "Source set differs") + require(all(digest((ROOT / name).read_bytes()) == plan["source_files_sha256"][name] for name in SOURCE_FILES), + "Current source bytes differ from executed source") + require(digest(Path(plan["node"]).read_bytes()) == plan["node_sha256"] and + digest(Path(plan["python"]).read_bytes()) == plan["python_sha256"], "Runtime EXE identity differs") + require(plan["out"] == str(case_directory.resolve()), "Case directory differs") + require(batch["schema"] == "termforge-node-contract-batch-v1" and + batch["status"] == "expected_diagnostic_observations" and exit_record["exit_code"] == 0, + "CLI/batch verdict differs") + require(batch["original_timeout_root_cause"] is None and batch["whole_lifecycle_bound_seconds"] is None, + "Unknown endpoint was filled") + require(re.fullmatch(r"\d+\.\d+\.\d+", plan["windows_version"]), "Windows version is not a safe scalar") + python_version = re.match(r"\A(\d+\.\d+\.\d+)", plan["python_version"]) + require(python_version is not None, "Python version is not a safe scalar") + require(all(batch[name] == plan[name] for name in ("windows_version", "python_version")), "Batch host differs") + require(len(batch["observations"]) == 3, "Observation count differs") + marker = plan_path.with_suffix(".attempted") + require(marker.read_bytes() == b"One diagnostic attempt; retain after success or failure.\n", "Attempt marker differs") + raw_files = [file_identity("authorized-plan", plan_path), file_identity("cli-log", log_path), + file_identity("cli-exit", exit_path), file_identity("attempt-marker", marker)] + public_cases = [] + for index, (case, row) in enumerate(zip(CASES, batch["observations"])): + case_path = case_directory / f"{index}-{case}.json" + case_bytes = case_path.read_bytes() + require(case_bytes == (json.dumps(row, indent=2) + "\n").encode(), "Case bytes differ from CLI row") + raw_files.append(file_identity("case-" + case, case_path)) + require(row["schema"] == "termforge-node-contract-observation-v1" and row["case"] == case, + "Case identity differs") + require(row["source_files_sha256"] == plan["source_files_sha256"] and row["node_sha256"] == plan["node_sha256"] and + all(row[name] == plan[name] for name in ("windows_version", "python_version")), "Case input/host differs") + require(row["execution_budget_seconds"] == 10 and row["cleanup_budget_seconds"] == 5, "Case budget differs") + require(all(row[name] is True for name in ("process_created", "root_reaped", "root_in_owned_job", "cleanup_verified")), + "Owned root/cleanup evidence differs") + require("cleanup_errors" not in row and "protocol_error" not in row["trace"], "Case contains an observation error") + observations = [json.loads(line.split(":", 1)[1]) for line in row["stderr"].splitlines() + if line.startswith("TF_NODE_CONTRACT_V1:")] + require(observations == row["trace"]["observations"], "Captured checkpoints differ from decoded trace") + count = (23, 6, 11)[index] + require(len(observations) == count and [event["stage"] for event in observations] == STAGES[:count], + "Checkpoint schedule differs") + require([event["sequence"] for event in observations] == list(range(count)) and + all(type(event["pid"]) is int and event["pid"] == row["root_pid"] for event in observations), + "Checkpoint sequence/root identity differs") + clocks = [int(event["monotonic_ns"]) for event in observations] + require(clocks == sorted(clocks) and clocks[0] >= 0, "Checkpoint clocks differ") + require(row["trace"]["last_observed_stage"] == STAGES[count - 1] and row["trace"]["complete"] is (index == 0), + "Trace terminal state differs") + first = observations[0] + require(re.fullmatch(r"v\d+\.\d+\.\d+", first["node_version"]) and first["arch"] == "x64" and + first["platform"] == "win32", "Node runtime differs") + expected_status, expected_exit = (("passed", 0), ("timed_out", 99), ("failed", 1))[index] + require(row["status"] == expected_status and row["exit_code"] == expected_exit and + row["forced_job_termination"] is (index == 1), "Child verdict differs") + require(account(row["job_before_go"]) == {"active": 1, "total": 1, "terminated": 0} and + account(row["job_after"]) == {"active": 0, "total": 2, "terminated": 0}, "Job accounting differs") + exception = None + if index == 1: + require(row["primary_exception"]["type"] == "TimeoutExpired" and row["primary_exception"]["timeout_seconds"] == 10 and + account(row["job_at_timeout"]) == {"active": 2, "total": 2, "terminated": 0}, "Timeout evidence differs") + exception = {"type": "TimeoutExpired", "timeout_seconds": 10} + else: + require(row["primary_exception"] is None, "Unexpected host exception") + expected_stdout = "module paths, QRgb, ROI/ink gates,32B sret and normal-return destructor order passed\n" if index == 0 else "" + require(row["stdout"] == expected_stdout and + (index != 2 or "TF_NODE_CONTROLLED_EXCEPTION" in row["stderr"]), "Captured output differs") + times = {name: row[name] for name in ("process_creation_elapsed_seconds", "communicate_elapsed_seconds")} + require(all(type(value) in (float, int) and math.isfinite(value) and value >= 0 for value in times.values()), + "Invalid diagnostic timing") + public_cases.append({"case": case, "status": expected_status, "exit_code": expected_exit, + "execution_budget_seconds": 10, "cleanup_budget_seconds": 5, **times, + "primary_host_exception": exception, "forced_job_termination": row["forced_job_termination"], + "root_reaped": True, "root_in_owned_job": True, "cleanup_verified": True, + "job_before_go": account(row["job_before_go"]), "job_at_timeout": row.get("job_at_timeout"), + "job_after": account(row["job_after"]), "checkpoint_count": count, + "stages": [event["stage"] for event in observations], "complete": index == 0, + "node_version": first["node_version"], "node_arch": first["arch"], "node_platform": first["platform"]}) + prior = load(ROOT / "local/node-deadline-p3/prior-artifacts-before.json") + require(len(prior) == 24, "Prior artifact set differs") + preserved = [] + for index, (name, expected) in enumerate(sorted(prior.items())): + path = ROOT / name + require(digest(path.read_bytes()) == expected, "Prior artifact changed") + preserved.append(file_identity(f"prior-{index:02d}", path)) + false_plans = [] + for suffix, label in (("", "initial"), ("-p2", "protocol-fix"), ("-p3", "version-fix")): + path = ROOT / f"local/node-144-offline-proposal{suffix}.json" + value = load(path) + require(value["execution_authorized"] is False and not path.with_suffix(".attempted").exists() and + not Path(value["out"]).exists(), "False proposal execution state differs") + false_plans.append({**file_identity(label, path), "source_base_commit": value["source_base_commit"], + "execution_authorized": False, "attempt_marker_present": False, "output_present": False}) + result = {"schema": "termforge-node-contract-public-v1", "issue": 144, "execution_source_commit": EXECUTED_SOURCE, + "authorized_plan_sha256": PLAN_SHA256, "maximum_attempts": 1, "actual_case_count": 3, + "cli_exit_code": 0, "batch_status": batch["status"], "original_timeout_root_cause": None, + "whole_lifecycle_bound_seconds": None, "windows_version": plan["windows_version"], + "python_version_number": python_version[1], "python_version_text_sha256": digest(plan["python_version"].encode()), + "python_exe_sha256": plan["python_sha256"], "node_exe_sha256": plan["node_sha256"], + "source_files_sha256": plan["source_files_sha256"], "observations": public_cases, + "raw_files": raw_files, "preserved_prior_artifacts": preserved, "unexecuted_false_plans": false_plans, + "exporter_sha256": digest(Path(__file__).read_bytes())} + text = json.dumps(result, indent=2) + "\n" + require(not re.search(r"[A-Za-z]:[\\/]|root_pid|TF_NODE_CONTRACT_V1|TF_NODE_CONTROLLED_EXCEPTION", text), + "Public projection contains a private identity or raw stream") + return text + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("plan", "cli-log", "exit-record", "case-dir", "out"): + parser.add_argument("--" + name, type=Path, required=True) + args = parser.parse_args() + require(not args.out.exists(), "Output must not already exist") + text = export(args.plan, args.cli_log, args.exit_record, args.case_dir) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(text, encoding="utf-8", newline="\n") + print(json.dumps({"exported": True, "cases": 3, "sha256": digest(text.encode())})) + + +if __name__ == "__main__": + main() From 099f5c171c05e177c18863f28f3e54d4b962f863 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:41:03 +0800 Subject: [PATCH 10/15] fix(research): retain Windows marker bytes in offline audit (#144) --- research/ui/export_node_contract_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/research/ui/export_node_contract_deadline.py b/research/ui/export_node_contract_deadline.py index 9a79a344..104ba12d 100644 --- a/research/ui/export_node_contract_deadline.py +++ b/research/ui/export_node_contract_deadline.py @@ -68,7 +68,7 @@ def export(plan_path, log_path, exit_path, case_directory): require(all(batch[name] == plan[name] for name in ("windows_version", "python_version")), "Batch host differs") require(len(batch["observations"]) == 3, "Observation count differs") marker = plan_path.with_suffix(".attempted") - require(marker.read_bytes() == b"One diagnostic attempt; retain after success or failure.\n", "Attempt marker differs") + require(marker.read_bytes() == b"One diagnostic attempt; retain after success or failure.\r\n", "Attempt marker differs") raw_files = [file_identity("authorized-plan", plan_path), file_identity("cli-log", log_path), file_identity("cli-exit", exit_path), file_identity("attempt-marker", marker)] public_cases = [] From c52125bfb07eda9685adb3407d5d84a458c0fee2 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:44:32 +0800 Subject: [PATCH 11/15] research: bind unchanged Node reference sources in whitelist (#144) --- research/ui/export_node_contract_deadline.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/research/ui/export_node_contract_deadline.py b/research/ui/export_node_contract_deadline.py index 104ba12d..87d03e2f 100644 --- a/research/ui/export_node_contract_deadline.py +++ b/research/ui/export_node_contract_deadline.py @@ -142,6 +142,16 @@ def export(plan_path, log_path, exit_path, case_directory): not Path(value["out"]).exists(), "False proposal execution state differs") false_plans.append({**file_identity(label, path), "source_base_commit": value["source_base_commit"], "execution_authorized": False, "attempt_marker_present": False, "output_present": False}) + reference_commit = "43883022883d382e3dd57bd627e9c735bafe58ca" + references = ("research/ui/raster_style_reference.js", "research/ui/test_raster_style_reference.py", "experiments/conpty/run.py") + archive = load(ROOT / "local/node-deadline-preflight/old-source-identity.json") + require(archive["original_head"] == reference_commit, "Reference source commit differs") + reference_hashes = {} + for name in references: + entry = next(row for row in archive["git_blob_archive"] if row["path"] == name) + require(digest((ROOT / name).read_bytes()) == entry["git_blob_sha256"] == + digest((ROOT / "local/node-deadline-preflight/old-source" / name).read_bytes()), "Reference source changed") + reference_hashes[name] = entry["git_blob_sha256"] result = {"schema": "termforge-node-contract-public-v1", "issue": 144, "execution_source_commit": EXECUTED_SOURCE, "authorized_plan_sha256": PLAN_SHA256, "maximum_attempts": 1, "actual_case_count": 3, "cli_exit_code": 0, "batch_status": batch["status"], "original_timeout_root_cause": None, @@ -149,6 +159,7 @@ def export(plan_path, log_path, exit_path, case_directory): "python_version_number": python_version[1], "python_version_text_sha256": digest(plan["python_version"].encode()), "python_exe_sha256": plan["python_sha256"], "node_exe_sha256": plan["node_sha256"], "source_files_sha256": plan["source_files_sha256"], "observations": public_cases, + "reference_sources_unchanged_from_commit": reference_commit, "reference_sources_sha256": reference_hashes, "raw_files": raw_files, "preserved_prior_artifacts": preserved, "unexecuted_false_plans": false_plans, "exporter_sha256": digest(Path(__file__).read_bytes())} text = json.dumps(result, indent=2) + "\n" From 30da569e538accd63913843a41d466d051f45aad Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:45:55 +0800 Subject: [PATCH 12/15] docs(research): publish bounded Node diagnostic observations (#144) --- docs/EVIDENCE.md | 1 + docs/research/node-contract-deadline.md | 6 +- .../README.md | 37 ++ .../validation.json | 364 ++++++++++++++++++ 4 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 research/ui/results/2026-10-29-node-contract-deadline/README.md create mode 100644 research/ui/results/2026-10-29-node-contract-deadline/validation.json diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index a044dfe5..52f3be5e 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -63,6 +63,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | 持续输出中断的原失败记录 | [#36](https://github.com/JavaZeroo/termforge/issues/36)、[ConPTY 输入机制研究](research/conpty-ctrl-c-input.md)、[原 R1 历史证据](../experiments/shell/results/2026-10-23-r1-foundations/validation.json) | 原 Windows 11 回归中的 normal/resize 超时与 ETW/插桩观察;调试扰动及管道读完成缺口保留。不是当前全套结果,后续未复现不能抹去此失败。 | | controller B 正常结束后 cmd 退出未验收(#44) | [原失败脱敏记录](../experiments/shell/results/2026-10-26-controller-exit/validation.json)、[新退出诊断及有界重放](../experiments/shell/results/2026-10-26-controller-exit-observation/validation.json)、[关闭合同](https://github.com/JavaZeroo/termforge/issues/44) | 原 child 收完 32B、退出 0/join/Job0;cmd 只留下 close 后 0xC000013A,close 前未知。新观察分列输入、wait、pre-close 与清理;多轮未复现不构成历史修复,继续阻塞 #38。 | | 原 10 秒纯夹具超时的阶段保留(#40 未关闭) | [限定合同](research/inventory-timeout-checkpoints.md)、[正常与受控超时对照](../research/ime/results/2026-10-26-inventory-timeout-checkpoints/validation.json) | 保持原 10 秒期限;超时后保留有界阶段、原异常和回收。受控挂起对照已通过,但历史 CI 的阶段/根因仍未知,#40 不因此关闭。 | +| Node 纯夹具阶段与失败判定(#144 未关闭) | [工具合同](research/node-contract-deadline.md)、[固定三 case 与身份](../research/ui/results/2026-10-29-node-contract-deadline/README.md) | 固定02aac源码的一次正式批次:正常passed0/23cp、受控挂起timed_out99/6cp、受控异常failed1/11cp,均根回收/Job0;CLI0只表示预定义结果齐全。协议尾和版本漂移P2的纯反例分列,旧CI根因/整程硬界仍null,不接原CI入口,不关闭#144/#38。 | | 纯 AST 夹具的生命周期记录 | [#40](https://github.com/JavaZeroo/termforge/issues/40)、[计时、进度与回收验证](../research/ime/results/2026-10-26-inventory-lifecycle/validation.json)、[专用 CI 40253727745](https://github.com/JavaZeroo/termforge/actions/runs/40253727745) | 旧 10 秒 AST 夹具及专用 CI 记录启动、AST 和 Job0;原超时根因未确定。诊断增强见 #40 阶段保留,不由一次 CI 成功结项。 | | 原生 margin 逐字段合同与修复候选(#56 仍开放) | [合同、源码与路径](research/conpty-native-margin-path.md)、[独立 oracle](../experiments/conpty/margin_contract.py)、[固定运行](../experiments/conpty/results/2026-10-26-native-margin-path/validation.json) | 四阶段文字/格属性/光标独立判据;系统与官方原生 final 仍红,官方80列两次原5秒生命周期失败保留。共享单 margin 源码支持小 MIT fork 候选;patch 仅可应用,未编译、未运行、不计修复。 | | ConPTY 系统/固定官方后端的同源 margin A/B(#41、#47 未关闭) | [合同与第一方来源](research/conpty-margin-backends.md)、[固定 175B 与 16 case 结果](../experiments/conpty/results/2026-10-26-margin-backends/validation.json) | 相同 175B、两网格/两 mode/两 fresh:系统末端 margin 错,固定官方后端的末端正确;CHAR_INFO 仍错。禁用 VT 由窄屏反例拒绝;仅诊断可行性,不证明 PR #50 或 main 联合整合通过。 | diff --git a/docs/research/node-contract-deadline.md b/docs/research/node-contract-deadline.md index eea3d420..e3b1cefb 100644 --- a/docs/research/node-contract-deadline.md +++ b/docs/research/node-contract-deadline.md @@ -2,6 +2,8 @@ 对应 [#144](https://github.com/JavaZeroo/termforge/issues/144)。旧 Windows CI 在 `test_raster_native_contract.js` 的原 10 秒等待中超时,没有 Node 阶段记录。这里提供后续有限调查的工具,**没有解释或修复旧超时**;新源码、新宿主或受控挂起的结果不能补写旧样本。 +新工具在冻结源码 `02aac45` 上完成了[一次正式三 case 诊断](../../research/ui/results/2026-10-29-node-contract-deadline/README.md):正常通过、受控挂起超时和受控异常失败分别保留。CLI0仅表示预定义诊断结果齐全,原超时根因仍未知;原始记录留私有目录,公开只投影白名单标量、阶段和哈希。 + ## 执行与证据边界 这是 Windows 专用、纯合成的诊断入口,不启动 WindTerm、Qt、ConPTY 或输入法,也不访问个人桌面。现有 CTest 仍按原入口执行该 JS;本工具没有自动接入或重试失败的 CI。 @@ -34,7 +36,7 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan loca 公开边界为计划/launcher 和 checkpoint 记录。`research.ui.test_node_contract_deadline` 检查前缀保留、顺序/PID/字段/时钟拒绝、假 final、false/漂移计划拒绝,以及 Windows OwnedJob 中正常、受控挂起和异常三条独立路径。后两条不能因 Job 清空而转为通过。 -原件、环境路径、stderr 栈和观察数据留 `local/` 待审查;本说明不发布新样本或为旧超时填写根因。#144、#38 和 R2 继续开放。 +原件、环境路径和 stderr 栈留 `local/`;[正式批次的白名单结果](../../research/ui/results/2026-10-29-node-contract-deadline/validation.json)经离线核对后另行发布,不为旧超时填写根因。#144、#38 和 R2 继续开放。 ## 预审发现与限定修正 @@ -42,7 +44,7 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan loca 公开 trace-data/launcher 判定边界的两个负例在 `66a0391` 均实际失败:正常 stdout、exit0、清理成功时,非法 JSON 尾和额外事件仍被旧子判定返回 `passed`。修正同时清除错误轨迹的完整标记、在子判定直接拒绝协议错误,并保留23条有效前缀;测试另保留完整合法对照及带错误的旧 `complete=true` 输入。相关纯测试的普通/`-O` 运行身份与原始记录留新的 `local/node-deadline-p2/`,不重跑此前的 Node/受控10秒挂起观察,也不改写旧计划或原件。 -实际修正验证源为 `3b74a947f09880d67d12eececa6907b7278e7640`:普通与 `-O` 各8项纯边界测试通过、无跳过。此前的 Node/受控进程观察仍仅属于 `5ddecd4`,不转记为新源码的执行结果;本次末尾只补说明和准备新 false 提案,正式有限批次仍待审查。 +实际修正验证源为 `3b74a947f09880d67d12eececa6907b7278e7640`:普通与 `-O` 各8项纯边界测试通过、无跳过。此前的 Node/受控进程观察仍仅属于 `5ddecd4`,不转记为新源码的执行结果;该阶段末尾只补说明和准备新 false 提案,正式有限批次当时尚待审查。 ## 版本漂移的预审修正 diff --git a/research/ui/results/2026-10-29-node-contract-deadline/README.md b/research/ui/results/2026-10-29-node-contract-deadline/README.md new file mode 100644 index 00000000..6005421b --- /dev/null +++ b/research/ui/results/2026-10-29-node-contract-deadline/README.md @@ -0,0 +1,37 @@ +# Node 纯夹具:一次有界三 case 诊断 + +[#144](https://github.com/JavaZeroo/termforge/issues/144) 的新诊断工具已完成一次正式、预先冻结的本地批次。**正常项通过,受控挂起仍为超时,受控异常仍为失败。** CLI exit0 仅表示三个预定义诊断结果都观察到,不表示三项均通过,更不表示旧 CI 超时已修复。#144/#38/R2 保持开放。 + +## 执行身份 + +执行源码为 `02aac45ca6e8eaffcbca1d3ef1a80d243159ec46`;授权计划 SHA256 为 `87a0518c23b65f050d3991557b6b58aca334c8d80dc939fab601d3fae01f0fc8`。计划与输出目录只使用一次,exclusive attempt marker 保留。源码、三份 case、CLI 日志/退出记录及 marker 的实际字节身份见 [validation.json](validation.json)。原件留私有 `local/`,公开不含 stderr 栈、本地绝对路径、PID 或原始 checkpoint 对象。 + +实际 Windows 版本字符串为 `10.0.26200`,Python `3.12.10`,Node `v22.21.1` / x64 / win32;Python/Node EXE 哈希、Python 完整版本文本哈希和五项实际源码哈希单列。它们与计划、各子观察及离线当前文件一致。这仅是已记录的版本/文件身份,不是完整机器或环境指纹,也不补写旧 CI 的未知 Node 身份。 + +发布分支已整合 main `4d7bd8a7c822135b1fdee5935262af3205ee565f`;五项执行源码在合并前后字节不变。整合和发布提交不是新的执行身份。 + +## 实际结果 + +| case | 子状态 / 退出码 | checkpoint | 最后阶段 | 强制结束 Job | 结束 Job active/total/terminated | +|---|---|---|---|---|---| +| normal | passed / 0 | 23,完整 | final | 否 | 0/2/0 | +| hang-vm-helpers | timed_out / 99 | 6,前缀 | vm-helpers-start | 是 | 0/2/0 | +| throw-before-grab | failed / 1 | 11,前缀 | case-start:grab | 否 | 0/2/0 | + +三项在 GO 前均为 Job 1/1/0,根进程归属由保留句柄实际核对;根已回收、Job active=0。超时处为 Job 2/2/0,原10秒预算产生 `TimeoutExpired`;清理使用独立共享5秒预算。子异常由 stderr 中的受控标记和 exit1 分别保留,不能因清理成功改判为通过。离线审计只重算已保存记录及源代码守卫,没有重新读取已经销毁的 Job。 + +Node 首次 checkpoint 位于内建 fs 加载之后;此前的启动区间仍不能细分。宿主计时与 Node 单调时钟不作跨时钟性能推断。同步进程创建、准备和哈希没有整程硬界,`whole_lifecycle_bound_seconds=null`;计时仅为诊断记录,观测开销未校准。旧 CI 原因仍为 `null`。 + +## 来源与审计分列 + +| 记录 | 实际源码 | 边界 | +|---|---|---| +| 原 CI 失败 | `4388302` | Node 原10秒超时,无阶段;不由本次本地结果解释 | +| 早期受控进程测试 | `5ddecd4` | 私有历史原件保留,不转记为新源码批次;不补实际宿主版本字段 | +| 协议尾部 P2 | red `66a0391` → green `3b74a94` | 完整23条后非法/多余记录不得通过;普通/-O各8项纯测试 | +| 版本漂移 P2 | red `cebc530` → green `b809706` | marker前拒绝 Windows/Python 版本漂移;普通/-O各3项纯计划测试 | +| 本次正式三 case | `02aac45` | 唯一新授权批次,结果如上;没有再次运行 Node 或重试 CI | + +另一次独立 stdlib 审计将三个 case 与 CLI 行逐字段比较,并按原写出格式重建字节,全部一致;重算有序阶段、PID绑定、时钟、源码/运行库、计划及 Job 字段,发现0项。24份旧原件和三个未执行 false 提案逐一保持,公开只列不透明原件 ID、长度和 SHA256。普通与 `python -O` 的[白名单导出器](../../export_node_contract_deadline.py)输出逐字节一致,公开 JSON SHA256 为 `db97e95aa6d56a9bb44553178f788c6f7c33fff9e52441ef343ea63821f98a3d`。 + +这是[有界诊断工具](../../../../docs/research/node-contract-deadline.md)的实现与验证,不接入原 Python/CTest 入口,不关闭 #144/#38,也不支持产品、真实 Qt/原版行为、性能或长期稳定验收。普通 observer 为 no-op,原合成输入/断言保留;`raster_style_reference.js`、原 `test_raster_style_reference.py` 入口和 `experiments/conpty/run.py` 与 `4388302` 字节一致,三项哈希另列。 diff --git a/research/ui/results/2026-10-29-node-contract-deadline/validation.json b/research/ui/results/2026-10-29-node-contract-deadline/validation.json new file mode 100644 index 00000000..06eaabb9 --- /dev/null +++ b/research/ui/results/2026-10-29-node-contract-deadline/validation.json @@ -0,0 +1,364 @@ +{ + "schema": "termforge-node-contract-public-v1", + "issue": 144, + "execution_source_commit": "02aac45ca6e8eaffcbca1d3ef1a80d243159ec46", + "authorized_plan_sha256": "87a0518c23b65f050d3991557b6b58aca334c8d80dc939fab601d3fae01f0fc8", + "maximum_attempts": 1, + "actual_case_count": 3, + "cli_exit_code": 0, + "batch_status": "expected_diagnostic_observations", + "original_timeout_root_cause": null, + "whole_lifecycle_bound_seconds": null, + "windows_version": "10.0.26200", + "python_version_number": "3.12.10", + "python_version_text_sha256": "f0823692b73f6cc9b2412e30deb4aecc1f01e6368a93b3ca3e19ea3b586b08df", + "python_exe_sha256": "0b471133e110cfb53a061cad528ce8e517d7b9ac41a0a396c39ad795a487fc14", + "node_exe_sha256": "471961cb355311c9a9dd8ba417eca8269ead32a2231653084112554cda52e8b3", + "source_files_sha256": { + "research/ui/node_contract_deadline.py": "358fc4b886b7954b1c3cba9bacf64130241681afe5860318fb895e0fbcd202e7", + "research/ui/node_contract_bootstrap.js": "f2bd0c7f7e574528bb10bf19f614cc6a77c217cfa3796258b6822429b117c8b4", + "research/ui/test_raster_native_contract.js": "b3f5e298b2611bee629c687d2c9f99210b2f14fc3386d876fbf232695df5fd63", + "research/ui/raster_style_reference.js": "499670efdb970984e8ac72d326033cca456720c83e9585152739ff6a82dd36db", + "experiments/conpty/run.py": "c8eef1e36b6d200b2d508cef91eaa5ca79a91fa87c5daf6265b2475c31dce329" + }, + "observations": [ + { + "case": "normal", + "status": "passed", + "exit_code": 0, + "execution_budget_seconds": 10, + "cleanup_budget_seconds": 5, + "process_creation_elapsed_seconds": 0.046000000002095476, + "communicate_elapsed_seconds": 0.11000000000058208, + "primary_host_exception": null, + "forced_job_termination": false, + "root_reaped": true, + "root_in_owned_job": true, + "cleanup_verified": true, + "job_before_go": { + "active": 1, + "total": 1, + "terminated": 0 + }, + "job_at_timeout": null, + "job_after": { + "active": 0, + "total": 2, + "terminated": 0 + }, + "checkpoint_count": 23, + "stages": [ + "node-first-checkpoint", + "go-received", + "fixture-before-load", + "before-file-read", + "file-read", + "vm-helpers-start", + "vm-helpers-ready", + "pure-checks-done", + "case-start:normal", + "case-end:normal", + "case-start:grab", + "case-end:grab", + "case-start:toImage", + "case-end:toImage", + "case-start:empty", + "case-end:empty", + "case-start:dpr", + "case-end:dpr", + "case-start:pixel", + "case-end:pixel", + "case-start:destroy", + "case-end:destroy", + "final" + ], + "complete": true, + "node_version": "v22.21.1", + "node_arch": "x64", + "node_platform": "win32" + }, + { + "case": "hang-vm-helpers", + "status": "timed_out", + "exit_code": 99, + "execution_budget_seconds": 10, + "cleanup_budget_seconds": 5, + "process_creation_elapsed_seconds": 0.04700000000593718, + "communicate_elapsed_seconds": 10.015999999988708, + "primary_host_exception": { + "type": "TimeoutExpired", + "timeout_seconds": 10 + }, + "forced_job_termination": true, + "root_reaped": true, + "root_in_owned_job": true, + "cleanup_verified": true, + "job_before_go": { + "active": 1, + "total": 1, + "terminated": 0 + }, + "job_at_timeout": { + "active": 2, + "total": 2, + "terminated": 0 + }, + "job_after": { + "active": 0, + "total": 2, + "terminated": 0 + }, + "checkpoint_count": 6, + "stages": [ + "node-first-checkpoint", + "go-received", + "fixture-before-load", + "before-file-read", + "file-read", + "vm-helpers-start" + ], + "complete": false, + "node_version": "v22.21.1", + "node_arch": "x64", + "node_platform": "win32" + }, + { + "case": "throw-before-grab", + "status": "failed", + "exit_code": 1, + "execution_budget_seconds": 10, + "cleanup_budget_seconds": 5, + "process_creation_elapsed_seconds": 0.031000000002677552, + "communicate_elapsed_seconds": 0.0629999999946449, + "primary_host_exception": null, + "forced_job_termination": false, + "root_reaped": true, + "root_in_owned_job": true, + "cleanup_verified": true, + "job_before_go": { + "active": 1, + "total": 1, + "terminated": 0 + }, + "job_at_timeout": null, + "job_after": { + "active": 0, + "total": 2, + "terminated": 0 + }, + "checkpoint_count": 11, + "stages": [ + "node-first-checkpoint", + "go-received", + "fixture-before-load", + "before-file-read", + "file-read", + "vm-helpers-start", + "vm-helpers-ready", + "pure-checks-done", + "case-start:normal", + "case-end:normal", + "case-start:grab" + ], + "complete": false, + "node_version": "v22.21.1", + "node_arch": "x64", + "node_platform": "win32" + } + ], + "reference_sources_unchanged_from_commit": "43883022883d382e3dd57bd627e9c735bafe58ca", + "reference_sources_sha256": { + "research/ui/raster_style_reference.js": "499670efdb970984e8ac72d326033cca456720c83e9585152739ff6a82dd36db", + "research/ui/test_raster_style_reference.py": "11c282b292d7b64c627f999920bec618bf41bcf345f076b73ceba71958fda6a5", + "experiments/conpty/run.py": "c8eef1e36b6d200b2d508cef91eaa5ca79a91fa87c5daf6265b2475c31dce329" + }, + "raw_files": [ + { + "id": "authorized-plan", + "bytes": 1578, + "sha256": "87a0518c23b65f050d3991557b6b58aca334c8d80dc939fab601d3fae01f0fc8" + }, + { + "id": "cli-log", + "bytes": 15209, + "sha256": "5935018b9cfbefb48accc60feab911fff96c8b68f10d39acc449f979db0eaa4b" + }, + { + "id": "cli-exit", + "bytes": 75, + "sha256": "c8394eb28fdbb10ba454c2db591dbf25e93d2a7c281bcbbe0451641d75790074" + }, + { + "id": "attempt-marker", + "bytes": 58, + "sha256": "21ceb85899a55c3c2532390bf948ceb66e8aa55727564935adf0a739a81afa33" + }, + { + "id": "case-normal", + "bytes": 7816, + "sha256": "f1a141c53acad15dd37d2f24b5895ba97e2b43780d8ddbf7e17f1e3394daf8eb" + }, + { + "id": "case-hang-vm-helpers", + "bytes": 3682, + "sha256": "f6b8e4de34b64466d6e1f3d04e3ee8caba832f4bd86d8fdbbe05d549a2d4c600" + }, + { + "id": "case-throw-before-grab", + "bytes": 5713, + "sha256": "7590fbaa0943292ecfdd772d85255bf80a52713b0a7af2de0ba3c80ea7e4f213" + } + ], + "preserved_prior_artifacts": [ + { + "id": "prior-00", + "bytes": 1592, + "sha256": "77429f072e16ebaf3d1b7c729aae65dd67f1ae36d682b1b3c2812eb22703ef5d" + }, + { + "id": "prior-01", + "bytes": 1589, + "sha256": "4e2a7280c1a64d6203f2b7c9f8c00bfbb4d1aa4f691f154e75bb249e074631d6" + }, + { + "id": "prior-02", + "bytes": 2534, + "sha256": "e0e80600370188fb3aa455ea25f5837849dcb6131a41b59f8bb2c6c1ec810bb7" + }, + { + "id": "prior-03", + "bytes": 1479, + "sha256": "bdfaae0f86e1fc77360d8f40f83aa23b8f31c00a6953d559f9d2fcc0e5588aba" + }, + { + "id": "prior-04", + "bytes": 1479, + "sha256": "9b09b34d02e8054b576d3a5d8c467feb6fdb3f9ce5ec259b1f26252b1ac53f89" + }, + { + "id": "prior-05", + "bytes": 2691, + "sha256": "84cb44a1af86157d12d2fbe9fb67a72b4e37e6f4d112518635d1e470ff28f4ac" + }, + { + "id": "prior-06", + "bytes": 41, + "sha256": "fb498c4df0819034d599441acd6cc73aff4811609b44cf088039eca83dd6d303" + }, + { + "id": "prior-07", + "bytes": 41, + "sha256": "d437beb05dd5b67ded31f5bf2c6ec0aa29221f8778577d714339badce9393f0e" + }, + { + "id": "prior-08", + "bytes": 1752, + "sha256": "4a939db136469f70668bc9a80029198347c47755713577eecc089c6eecb00414" + }, + { + "id": "prior-09", + "bytes": 41, + "sha256": "aac3d607fe077f1867847438020105f7745f19172013d52ed0c1f19a1e371ad2" + }, + { + "id": "prior-10", + "bytes": 3383, + "sha256": "e22a4e999666008dfe3be0eb6697c42629edcdf781815d3b6791ac0e959b1998" + }, + { + "id": "prior-11", + "bytes": 1001, + "sha256": "f128c2e076c926f60bc1a48ed585b0a68efacc49977c04eb310d65698aee6be9" + }, + { + "id": "prior-12", + "bytes": 975, + "sha256": "2294c40f835030fdb31396a1d64bc66388da653a52981e9a09f4f75de04fb1cd" + }, + { + "id": "prior-13", + "bytes": 15171, + "sha256": "0cfff1982fdfd2f8396c2764332f64105bce1e1972d2fdcd46afe3d02f592915" + }, + { + "id": "prior-14", + "bytes": 1129, + "sha256": "69db83f032acf8e46965d083dd9301c958afd6b83c52a4187f02f369fc972d54" + }, + { + "id": "prior-15", + "bytes": 1129, + "sha256": "d0feea4569e13ffb65ea91cfbf8883d1ca7fb1032166dc9d6185aa0de1bb8a6a" + }, + { + "id": "prior-16", + "bytes": 1896, + "sha256": "a1945f48371ad8b5b6f5713471fe51662841d7fdfbaa72665c02f7d6d6d74c1f" + }, + { + "id": "prior-17", + "bytes": 1758, + "sha256": "d326bb375f0ce7310bf075a9802f5f4392e21dbe0924d966ebd7995a3ced775a" + }, + { + "id": "prior-18", + "bytes": 17857, + "sha256": "2973a0c13212256c3231ed6cacf9b8548d03edba610ed5a24cacf6e5f513333e" + }, + { + "id": "prior-19", + "bytes": 1129, + "sha256": "95beb8464553f93bbb1b3b05b07d831a7c32b4867655f0677a8503aa00040161" + }, + { + "id": "prior-20", + "bytes": 904, + "sha256": "8468e0ed5535d8c67917fb1ef41722f97a74b006f4f813a29c1fb41ef05ec696" + }, + { + "id": "prior-21", + "bytes": 41, + "sha256": "0649922600b96ef7c9d4fc06e00df325d530cf04ff51fe6bf3024732f4b8dd2e" + }, + { + "id": "prior-22", + "bytes": 1294, + "sha256": "4775ef06cb4209f77a0cb1c110672f9baa2ec0bb71313394f15af38fa38dcac5" + }, + { + "id": "prior-23", + "bytes": 4147, + "sha256": "c6f0d58ab64e06f5a75c98aaa9000c379540d2fc987bca717adfbeaa04d107fb" + } + ], + "unexecuted_false_plans": [ + { + "id": "initial", + "bytes": 1589, + "sha256": "4e2a7280c1a64d6203f2b7c9f8c00bfbb4d1aa4f691f154e75bb249e074631d6", + "source_base_commit": "5ddecd4e0bd978d38a38974bb4d27b193b605fa7", + "execution_authorized": false, + "attempt_marker_present": false, + "output_present": false + }, + { + "id": "protocol-fix", + "bytes": 1592, + "sha256": "77429f072e16ebaf3d1b7c729aae65dd67f1ae36d682b1b3c2812eb22703ef5d", + "source_base_commit": "0949ac6fc371e24ee20c25c36b6e90f372c99fe8", + "execution_authorized": false, + "attempt_marker_present": false, + "output_present": false + }, + { + "id": "version-fix", + "bytes": 1592, + "sha256": "115c972ce327e9456edf9e96de4f885ec2fdeb16fd819dd34dc24bac871072b4", + "source_base_commit": "02aac45ca6e8eaffcbca1d3ef1a80d243159ec46", + "execution_authorized": false, + "attempt_marker_present": false, + "output_present": false + } + ], + "exporter_sha256": "bdbbee3898b4a7f77ef0ae368be00c5452f07dfb78d00365629f1025c4962be8" +} From 5c8bc6368872c609efe0d40070c1b1ae10e3cc6a Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:16:48 +0800 Subject: [PATCH 13/15] test: reproduce offline Node export whitelist gaps --- .../ui/test_export_node_contract_deadline.py | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 research/ui/test_export_node_contract_deadline.py diff --git a/research/ui/test_export_node_contract_deadline.py b/research/ui/test_export_node_contract_deadline.py new file mode 100644 index 00000000..7fd955d6 --- /dev/null +++ b/research/ui/test_export_node_contract_deadline.py @@ -0,0 +1,134 @@ +"""Synthetic, in-memory tests of the public offline export boundary; no Node.""" +from contextlib import ExitStack +import json +from pathlib import Path +import unittest +from unittest.mock import patch + +from research.ui import export_node_contract_deadline as exporter + + +class ExportTests(unittest.TestCase): + def setUp(self): + self.root = Path.cwd() / "synthetic-node-export" + self.files = {} + self.plan_path = self.root / "authorized.json" + self.log_path = self.root / "cli.json" + self.exit_path = self.root / "exit.json" + self.case_dir = self.root / "cases" + source_hashes = {} + for name in exporter.SOURCE_FILES: + self.files[self.root / name] = ("synthetic source " + name).encode() + source_hashes[name] = exporter.digest(self.files[self.root / name]) + self.files[self.root / "node"] = b"synthetic node EXE" + self.files[self.root / "python"] = b"synthetic Python EXE" + self.plan = {"source_base_commit": exporter.EXECUTED_SOURCE, "execution_authorized": True, + "maximum_attempts": 1, "cases": list(exporter.CASES), "execution_budget_seconds": 10, + "cleanup_budget_seconds": 5, "whole_lifecycle_bound_seconds": None, + "source_files_sha256": source_hashes, "node": str(self.root / "node"), + "python": str(self.root / "python"), "node_sha256": exporter.digest(b"synthetic node EXE"), + "python_sha256": exporter.digest(b"synthetic Python EXE"), "out": str(self.case_dir.resolve()), + "windows_version": "10.0.26200", "python_version": "3.12.10 synthetic"} + self.put(self.plan_path, self.plan) + self.plan_sha = exporter.digest(self.files[self.plan_path]) + self.files[self.plan_path.with_suffix(".attempted")] = b"One diagnostic attempt; retain after success or failure.\r\n" + # Literal protocol witness, independent of the exporter's STAGES list. + stages = ["node-first-checkpoint", "go-received", "fixture-before-load", "before-file-read", + "file-read", "vm-helpers-start", "vm-helpers-ready", "pure-checks-done"] + for case in ("normal", "grab", "toImage", "empty", "dpr", "pixel", "destroy"): + stages.extend(("case-start:" + case, "case-end:" + case)) + stages.append("final") + self.batch = {"schema": "termforge-node-contract-batch-v1", "status": "expected_diagnostic_observations", + "original_timeout_root_cause": None, "whole_lifecycle_bound_seconds": None, + "windows_version": "10.0.26200", "python_version": "3.12.10 synthetic", "observations": []} + for index, case in enumerate(exporter.CASES): + events = [{"sequence": i, "stage": stage, "pid": 123, "monotonic_ns": str(100 + i)} + for i, stage in enumerate(stages[:(23, 6, 11)[index]])] + events[0].update(node_version="v22.21.1", arch="x64", platform="win32") + row = {"schema": "termforge-node-contract-observation-v1", "case": case, + "source_files_sha256": source_hashes, "node_sha256": self.plan["node_sha256"], + "windows_version": "10.0.26200", "python_version": "3.12.10 synthetic", + "execution_budget_seconds": 10, "cleanup_budget_seconds": 5, "process_created": True, + "root_reaped": True, "root_in_owned_job": True, "cleanup_verified": True, "root_pid": 123, + "trace": {"observations": events, "last_observed_stage": events[-1]["stage"], "complete": index == 0}, + "stderr": "".join("TF_NODE_CONTRACT_V1:" + json.dumps(event) + "\n" for event in events), + "status": ("passed", "timed_out", "failed")[index], "exit_code": (0, 99, 1)[index], + "forced_job_termination": index == 1, "job_before_go": {"active": 1, "total": 1, "terminated": 0}, + "job_after": {"active": 0, "total": 2, "terminated": 0}, + "primary_exception": {"type": "TimeoutExpired", "timeout_seconds": 10} if index == 1 else None, + "stdout": "module paths, QRgb, ROI/ink gates,32B sret and normal-return destructor order passed\n" if index == 0 else "", + "process_creation_elapsed_seconds": 0.05, "communicate_elapsed_seconds": (0.1, 10.01, 0.06)[index]} + if index == 1: + row["job_at_timeout"] = {"active": 2, "total": 2, "terminated": 0} + if index == 2: + row["stderr"] += "TF_NODE_CONTROLLED_EXCEPTION\n" + self.batch["observations"].append(row) + self.put(self.exit_path, {"exit_code": 0}) + prior = {} + for index in range(24): + name = f"local/prior-{index}.json" + self.files[self.root / name] = b"synthetic historical record" + prior[name] = exporter.digest(self.files[self.root / name]) + self.put(self.root / "local/node-deadline-p3/prior-artifacts-before.json", prior) + self.false_contracts = [] + for suffix, label, source in (("", "initial", "5ddecd4e0bd978d38a38974bb4d27b193b605fa7"), + ("-p2", "protocol-fix", "0949ac6fc371e24ee20c25c36b6e90f372c99fe8"), + ("-p3", "version-fix", exporter.EXECUTED_SOURCE)): + path = self.root / f"local/node-144-offline-proposal{suffix}.json" + self.put(path, {"execution_authorized": False, "source_base_commit": source, + "out": str(self.root / (label + "-absent-output"))}) + self.false_contracts.append((suffix, label, exporter.digest(self.files[path]), source)) + references = [] + for name in ("research/ui/raster_style_reference.js", "research/ui/test_raster_style_reference.py", "experiments/conpty/run.py"): + data = self.files.setdefault(self.root / name, b"synthetic unchanged reference") + self.files[self.root / "local/node-deadline-preflight/old-source" / name] = data + references.append({"path": name, "git_blob_sha256": exporter.digest(data)}) + self.put(self.root / "local/node-deadline-preflight/old-source-identity.json", + {"original_head": "43883022883d382e3dd57bd627e9c735bafe58ca", "git_blob_archive": references}) + self.files[Path(exporter.__file__)] = b"synthetic exporter source" + + def put(self, path, value): + self.files[path] = (json.dumps(value, indent=2) + "\n").encode() + + def export(self): + self.put(self.log_path, self.batch) + for index, row in enumerate(self.batch["observations"]): + self.put(self.case_dir / f"{index}-{row['case']}.json", row) + with ExitStack() as stack: + stack.enter_context(patch.object(exporter, "ROOT", self.root)) + stack.enter_context(patch.object(exporter, "PLAN_SHA256", self.plan_sha)) + stack.enter_context(patch.object(exporter, "FALSE_PLANS", tuple(self.false_contracts), create=True)) + stack.enter_context(patch.object(Path, "read_bytes", lambda path: self.files[path])) + stack.enter_context(patch.object(Path, "exists", lambda path: path in self.files)) + return json.loads(exporter.export(self.plan_path, self.log_path, self.exit_path, self.case_dir)) + + def test_synthetic_valid_batch_exports_only_expected_diagnostic_results(self): + result = self.export() + self.assertEqual([row["status"] for row in result["observations"]], ["passed", "timed_out", "failed"]) + self.assertEqual([row["job_at_timeout"] for row in result["observations"]], + [None, {"active": 2, "total": 2, "terminated": 0}, None]) + self.assertNotIn("synthetic-node-export", json.dumps(result)) + + def test_non_timeout_job_payload_is_rejected_even_with_matching_cli_and_case(self): + for index in (0, 2): + with self.subTest(case=exporter.CASES[index]): + self.batch["observations"][index]["job_at_timeout"] = { + "pid": 987654, "private_path": "/private/TF_SYNTHETIC_NODE_SECRET"} + with self.assertRaisesRegex(ValueError, "Unexpected timeout Job"): + self.export() + del self.batch["observations"][index]["job_at_timeout"] + + def test_false_plan_source_or_other_byte_drift_is_rejected(self): + path = self.root / "local/node-144-offline-proposal-p3.json" + original = self.files[path] + for change in ({"source_base_commit": "/private/TF_SYNTHETIC_FALSE_PLAN_ID"}, + {"source_base_commit": "f" * 40}, {"extra": "synthetic-byte-drift"}): + with self.subTest(change=change): + self.put(path, {**json.loads(original), **change}) + with self.assertRaisesRegex(ValueError, "False proposal identity"): + self.export() + self.files[path] = original + + +if __name__ == "__main__": + unittest.main() From 504c0fdfdd32f7ddbde340a4831b1dec70dd3b3f Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:17:58 +0800 Subject: [PATCH 14/15] fix: enforce fixed Node export whitelist identities --- CMakeLists.txt | 5 ++ research/ui/export_node_contract_deadline.py | 57 +++++++++++++------ .../ui/test_export_node_contract_deadline.py | 38 ++++++++++--- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c1ceabb4..dc2ebe62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -310,6 +310,11 @@ if(BUILD_TESTING) termforge_add_python_test(windterm_alternate_export -m unittest research.ui.test_export_alternate_reference -v) termforge_add_python_test(windterm_alternate_style_reference -m unittest research.ui.test_export_alternate_style_reference research.ui.test_alternate_style_peer -v) termforge_add_python_test(windterm_raster_style_reference -m unittest research.ui.test_raster_style_reference -v) + # Pure offline regressions only; OwnedNodeTests and frozen batches are never launched here. + termforge_add_python_test(node_contract_export -m unittest research.ui.test_export_node_contract_deadline research.ui.test_node_contract_deadline.CheckpointTests research.ui.test_node_contract_deadline.LauncherVerdictTests -v) + if(WIN32) + termforge_add_python_test(node_contract_plan -m unittest research.ui.test_node_contract_deadline.PlanTests -v) + endif() termforge_add_python_test(windterm_raster_selection_proposal -m unittest research.ui.test_raster_selection_reference -v) termforge_add_python_test(windterm_raster_raw_proposal -m unittest research.ui.test_raster_raw_reference -v) termforge_add_python_test(differential -m unittest discover -s experiments/differential -p test_*.py -v) diff --git a/research/ui/export_node_contract_deadline.py b/research/ui/export_node_contract_deadline.py index 87d03e2f..d26423c7 100644 --- a/research/ui/export_node_contract_deadline.py +++ b/research/ui/export_node_contract_deadline.py @@ -9,6 +9,15 @@ ROOT = Path(__file__).resolve().parents[2] EXECUTED_SOURCE = "02aac45ca6e8eaffcbca1d3ef1a80d243159ec46" PLAN_SHA256 = "87a0518c23b65f050d3991557b6b58aca334c8d80dc939fab601d3fae01f0fc8" +# These are preserved proposals from the fixed batch, not mutable input labels. +FALSE_PLANS = ( + ("", "initial", "4e2a7280c1a64d6203f2b7c9f8c00bfbb4d1aa4f691f154e75bb249e074631d6", + "5ddecd4e0bd978d38a38974bb4d27b193b605fa7"), + ("-p2", "protocol-fix", "77429f072e16ebaf3d1b7c729aae65dd67f1ae36d682b1b3c2812eb22703ef5d", + "0949ac6fc371e24ee20c25c36b6e90f372c99fe8"), + ("-p3", "version-fix", "115c972ce327e9456edf9e96de4f885ec2fdeb16fd819dd34dc24bac871072b4", + EXECUTED_SOURCE), +) CASES = ("normal", "hang-vm-helpers", "throw-before-grab") SOURCE_FILES = ("research/ui/node_contract_deadline.py", "research/ui/node_contract_bootstrap.js", "research/ui/test_raster_native_contract.js", "research/ui/raster_style_reference.js", @@ -29,6 +38,12 @@ def digest(data): return hashlib.sha256(data).hexdigest() +def hex_identity(value, length): + require(type(value) is str and re.fullmatch(r"[0-9a-f]{" + str(length) + "}", value) is not None, + "Invalid hexadecimal identity") + return value + + def load(path): return json.loads(path.read_bytes().decode("utf-8-sig")) @@ -51,7 +66,10 @@ def export(plan_path, log_path, exit_path, case_directory): plan["maximum_attempts"] == 1 and plan["cases"] == list(CASES), "Plan contract differs") require(plan["execution_budget_seconds"] == 10 and plan["cleanup_budget_seconds"] == 5 and plan["whole_lifecycle_bound_seconds"] is None, "Plan budget differs") - require(set(plan["source_files_sha256"]) == set(SOURCE_FILES), "Source set differs") + require(type(plan["source_files_sha256"]) is dict and set(plan["source_files_sha256"]) == set(SOURCE_FILES), + "Source set differs") + source_hashes = {name: hex_identity(plan["source_files_sha256"][name], 64) for name in SOURCE_FILES} + python_sha, node_sha = (hex_identity(plan[name], 64) for name in ("python_sha256", "node_sha256")) require(all(digest((ROOT / name).read_bytes()) == plan["source_files_sha256"][name] for name in SOURCE_FILES), "Current source bytes differ from executed source") require(digest(Path(plan["node"]).read_bytes()) == plan["node_sha256"] and @@ -62,8 +80,10 @@ def export(plan_path, log_path, exit_path, case_directory): "CLI/batch verdict differs") require(batch["original_timeout_root_cause"] is None and batch["whole_lifecycle_bound_seconds"] is None, "Unknown endpoint was filled") - require(re.fullmatch(r"\d+\.\d+\.\d+", plan["windows_version"]), "Windows version is not a safe scalar") - python_version = re.match(r"\A(\d+\.\d+\.\d+)", plan["python_version"]) + require(type(plan["windows_version"]) is str and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", plan["windows_version"]), + "Windows version is not a safe scalar") + require(type(plan["python_version"]) is str, "Python version is not a safe scalar") + python_version = re.match(r"\A([0-9]+\.[0-9]+\.[0-9]+)", plan["python_version"]) require(python_version is not None, "Python version is not a safe scalar") require(all(batch[name] == plan[name] for name in ("windows_version", "python_version")), "Batch host differs") require(len(batch["observations"]) == 3, "Observation count differs") @@ -99,7 +119,7 @@ def export(plan_path, log_path, exit_path, case_directory): require(row["trace"]["last_observed_stage"] == STAGES[count - 1] and row["trace"]["complete"] is (index == 0), "Trace terminal state differs") first = observations[0] - require(re.fullmatch(r"v\d+\.\d+\.\d+", first["node_version"]) and first["arch"] == "x64" and + require(type(first["node_version"]) is str and re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", first["node_version"]) and first["arch"] == "x64" and first["platform"] == "win32", "Node runtime differs") expected_status, expected_exit = (("passed", 0), ("timed_out", 99), ("failed", 1))[index] require(row["status"] == expected_status and row["exit_code"] == expected_exit and @@ -107,12 +127,15 @@ def export(plan_path, log_path, exit_path, case_directory): require(account(row["job_before_go"]) == {"active": 1, "total": 1, "terminated": 0} and account(row["job_after"]) == {"active": 0, "total": 2, "terminated": 0}, "Job accounting differs") exception = None + timeout_job = None if index == 1: + timeout_job = account(row["job_at_timeout"]) require(row["primary_exception"]["type"] == "TimeoutExpired" and row["primary_exception"]["timeout_seconds"] == 10 and - account(row["job_at_timeout"]) == {"active": 2, "total": 2, "terminated": 0}, "Timeout evidence differs") + timeout_job == {"active": 2, "total": 2, "terminated": 0}, "Timeout evidence differs") exception = {"type": "TimeoutExpired", "timeout_seconds": 10} else: require(row["primary_exception"] is None, "Unexpected host exception") + require(row.get("job_at_timeout") is None, "Unexpected timeout Job") expected_stdout = "module paths, QRgb, ROI/ink gates,32B sret and normal-return destructor order passed\n" if index == 0 else "" require(row["stdout"] == expected_stdout and (index != 2 or "TF_NODE_CONTROLLED_EXCEPTION" in row["stderr"]), "Captured output differs") @@ -121,26 +144,28 @@ def export(plan_path, log_path, exit_path, case_directory): "Invalid diagnostic timing") public_cases.append({"case": case, "status": expected_status, "exit_code": expected_exit, "execution_budget_seconds": 10, "cleanup_budget_seconds": 5, **times, - "primary_host_exception": exception, "forced_job_termination": row["forced_job_termination"], + "primary_host_exception": exception, "forced_job_termination": index == 1, "root_reaped": True, "root_in_owned_job": True, "cleanup_verified": True, - "job_before_go": account(row["job_before_go"]), "job_at_timeout": row.get("job_at_timeout"), + "job_before_go": account(row["job_before_go"]), "job_at_timeout": timeout_job, "job_after": account(row["job_after"]), "checkpoint_count": count, - "stages": [event["stage"] for event in observations], "complete": index == 0, - "node_version": first["node_version"], "node_arch": first["arch"], "node_platform": first["platform"]}) + "stages": list(STAGES[:count]), "complete": index == 0, + "node_version": first["node_version"], "node_arch": "x64", "node_platform": "win32"}) prior = load(ROOT / "local/node-deadline-p3/prior-artifacts-before.json") require(len(prior) == 24, "Prior artifact set differs") preserved = [] for index, (name, expected) in enumerate(sorted(prior.items())): path = ROOT / name - require(digest(path.read_bytes()) == expected, "Prior artifact changed") + require(digest(path.read_bytes()) == hex_identity(expected, 64), "Prior artifact changed") preserved.append(file_identity(f"prior-{index:02d}", path)) false_plans = [] - for suffix, label in (("", "initial"), ("-p2", "protocol-fix"), ("-p3", "version-fix")): + for suffix, label, expected_sha, expected_source in FALSE_PLANS: path = ROOT / f"local/node-144-offline-proposal{suffix}.json" + require(digest(path.read_bytes()) == expected_sha, "False proposal identity differs") value = load(path) + require(hex_identity(value["source_base_commit"], 40) == expected_source, "False proposal identity differs") require(value["execution_authorized"] is False and not path.with_suffix(".attempted").exists() and not Path(value["out"]).exists(), "False proposal execution state differs") - false_plans.append({**file_identity(label, path), "source_base_commit": value["source_base_commit"], + false_plans.append({**file_identity(label, path), "source_base_commit": expected_source, "execution_authorized": False, "attempt_marker_present": False, "output_present": False}) reference_commit = "43883022883d382e3dd57bd627e9c735bafe58ca" references = ("research/ui/raster_style_reference.js", "research/ui/test_raster_style_reference.py", "experiments/conpty/run.py") @@ -151,14 +176,14 @@ def export(plan_path, log_path, exit_path, case_directory): entry = next(row for row in archive["git_blob_archive"] if row["path"] == name) require(digest((ROOT / name).read_bytes()) == entry["git_blob_sha256"] == digest((ROOT / "local/node-deadline-preflight/old-source" / name).read_bytes()), "Reference source changed") - reference_hashes[name] = entry["git_blob_sha256"] + reference_hashes[name] = hex_identity(entry["git_blob_sha256"], 64) result = {"schema": "termforge-node-contract-public-v1", "issue": 144, "execution_source_commit": EXECUTED_SOURCE, "authorized_plan_sha256": PLAN_SHA256, "maximum_attempts": 1, "actual_case_count": 3, - "cli_exit_code": 0, "batch_status": batch["status"], "original_timeout_root_cause": None, + "cli_exit_code": 0, "batch_status": "expected_diagnostic_observations", "original_timeout_root_cause": None, "whole_lifecycle_bound_seconds": None, "windows_version": plan["windows_version"], "python_version_number": python_version[1], "python_version_text_sha256": digest(plan["python_version"].encode()), - "python_exe_sha256": plan["python_sha256"], "node_exe_sha256": plan["node_sha256"], - "source_files_sha256": plan["source_files_sha256"], "observations": public_cases, + "python_exe_sha256": python_sha, "node_exe_sha256": node_sha, + "source_files_sha256": source_hashes, "observations": public_cases, "reference_sources_unchanged_from_commit": reference_commit, "reference_sources_sha256": reference_hashes, "raw_files": raw_files, "preserved_prior_artifacts": preserved, "unexecuted_false_plans": false_plans, "exporter_sha256": digest(Path(__file__).read_bytes())} diff --git a/research/ui/test_export_node_contract_deadline.py b/research/ui/test_export_node_contract_deadline.py index 7fd955d6..fe09d0d2 100644 --- a/research/ui/test_export_node_contract_deadline.py +++ b/research/ui/test_export_node_contract_deadline.py @@ -119,15 +119,39 @@ def test_non_timeout_job_payload_is_rejected_even_with_matching_cli_and_case(sel del self.batch["observations"][index]["job_at_timeout"] def test_false_plan_source_or_other_byte_drift_is_rejected(self): - path = self.root / "local/node-144-offline-proposal-p3.json" - original = self.files[path] - for change in ({"source_base_commit": "/private/TF_SYNTHETIC_FALSE_PLAN_ID"}, - {"source_base_commit": "f" * 40}, {"extra": "synthetic-byte-drift"}): + for suffix, _, _, _ in self.false_contracts: + path = self.root / f"local/node-144-offline-proposal{suffix}.json" + original = self.files[path] + for change in ({"source_base_commit": "/private/TF_SYNTHETIC_FALSE_PLAN_ID"}, + {"source_base_commit": "f" * 40}, {"extra": "synthetic-byte-drift"}): + with self.subTest(proposal=suffix, change=change): + self.put(path, {**json.loads(original), **change}) + with self.assertRaisesRegex(ValueError, "False proposal identity"): + self.export() + self.files[path] = original + + def test_timeout_account_rejects_unknown_fields_and_non_integer_counts(self): + row = self.batch["observations"][1] + for change in ({"private_path": "/private/TF_SYNTHETIC_JOB_SECRET"}, {"active": True}): with self.subTest(change=change): - self.put(path, {**json.loads(original), **change}) - with self.assertRaisesRegex(ValueError, "False proposal identity"): + row["job_at_timeout"] = {"active": 2, "total": 2, "terminated": 0, **change} + with self.assertRaisesRegex(ValueError, "Job fields differ|Invalid Job count"): self.export() - self.files[path] = original + + def test_non_timeout_null_is_allowed_and_timeout_projection_has_only_counts(self): + for index in (0, 2): + self.batch["observations"][index]["job_at_timeout"] = None + result = self.export() + self.assertEqual(result["observations"][1]["job_at_timeout"], {"active": 2, "total": 2, "terminated": 0}) + self.assertEqual(result["unexecuted_false_plans"][2]["source_base_commit"], exporter.EXECUTED_SOURCE) + + def test_source_commit_requires_exact_lowercase_hex_even_with_matching_content_digest(self): + path = self.root / "local/node-144-offline-proposal-p3.json" + self.put(path, {**json.loads(self.files[path]), "source_base_commit": "A" * 40}) + suffix, label, _, source = self.false_contracts[2] + self.false_contracts[2] = (suffix, label, exporter.digest(self.files[path]), source) + with self.assertRaisesRegex(ValueError, "Invalid hexadecimal identity"): + self.export() if __name__ == "__main__": From 81d50eebbe149cf20cca292e3f076ae682cd99de Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:21:56 +0800 Subject: [PATCH 15/15] docs: preserve Node publication and record export guard validation --- docs/EVIDENCE.md | 2 +- docs/research/node-contract-deadline.md | 6 + .../README.md | 12 +- .../export-guards-validation.json | 64 +++ .../guarded-validation.json | 364 ++++++++++++++++++ 5 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 research/ui/results/2026-10-29-node-contract-deadline/export-guards-validation.json create mode 100644 research/ui/results/2026-10-29-node-contract-deadline/guarded-validation.json diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 52f3be5e..b12df44c 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -63,7 +63,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | 持续输出中断的原失败记录 | [#36](https://github.com/JavaZeroo/termforge/issues/36)、[ConPTY 输入机制研究](research/conpty-ctrl-c-input.md)、[原 R1 历史证据](../experiments/shell/results/2026-10-23-r1-foundations/validation.json) | 原 Windows 11 回归中的 normal/resize 超时与 ETW/插桩观察;调试扰动及管道读完成缺口保留。不是当前全套结果,后续未复现不能抹去此失败。 | | controller B 正常结束后 cmd 退出未验收(#44) | [原失败脱敏记录](../experiments/shell/results/2026-10-26-controller-exit/validation.json)、[新退出诊断及有界重放](../experiments/shell/results/2026-10-26-controller-exit-observation/validation.json)、[关闭合同](https://github.com/JavaZeroo/termforge/issues/44) | 原 child 收完 32B、退出 0/join/Job0;cmd 只留下 close 后 0xC000013A,close 前未知。新观察分列输入、wait、pre-close 与清理;多轮未复现不构成历史修复,继续阻塞 #38。 | | 原 10 秒纯夹具超时的阶段保留(#40 未关闭) | [限定合同](research/inventory-timeout-checkpoints.md)、[正常与受控超时对照](../research/ime/results/2026-10-26-inventory-timeout-checkpoints/validation.json) | 保持原 10 秒期限;超时后保留有界阶段、原异常和回收。受控挂起对照已通过,但历史 CI 的阶段/根因仍未知,#40 不因此关闭。 | -| Node 纯夹具阶段与失败判定(#144 未关闭) | [工具合同](research/node-contract-deadline.md)、[固定三 case 与身份](../research/ui/results/2026-10-29-node-contract-deadline/README.md) | 固定02aac源码的一次正式批次:正常passed0/23cp、受控挂起timed_out99/6cp、受控异常failed1/11cp,均根回收/Job0;CLI0只表示预定义结果齐全。协议尾和版本漂移P2的纯反例分列,旧CI根因/整程硬界仍null,不接原CI入口,不关闭#144/#38。 | +| Node 纯夹具阶段与失败判定(#144 未关闭) | [工具合同](research/node-contract-deadline.md)、[固定三 case 与身份](../research/ui/results/2026-10-29-node-contract-deadline/README.md) | 固定02aac源码的一次正式批次:正常passed0/23cp、受控挂起timed_out99/6cp、受控异常failed1/11cp,均根回收/Job0;CLI0只表示预定义结果齐全。协议尾、版本漂移及离线出口白名单P2的纯反例分列;出口普通/-O各15项纯测试,原件和旧公开字节保留。旧CI根因/整程硬界仍null,不接原CI执行入口,新增CTest仅注册纯数据回归,不关闭#144/#38。 | | 纯 AST 夹具的生命周期记录 | [#40](https://github.com/JavaZeroo/termforge/issues/40)、[计时、进度与回收验证](../research/ime/results/2026-10-26-inventory-lifecycle/validation.json)、[专用 CI 40253727745](https://github.com/JavaZeroo/termforge/actions/runs/40253727745) | 旧 10 秒 AST 夹具及专用 CI 记录启动、AST 和 Job0;原超时根因未确定。诊断增强见 #40 阶段保留,不由一次 CI 成功结项。 | | 原生 margin 逐字段合同与修复候选(#56 仍开放) | [合同、源码与路径](research/conpty-native-margin-path.md)、[独立 oracle](../experiments/conpty/margin_contract.py)、[固定运行](../experiments/conpty/results/2026-10-26-native-margin-path/validation.json) | 四阶段文字/格属性/光标独立判据;系统与官方原生 final 仍红,官方80列两次原5秒生命周期失败保留。共享单 margin 源码支持小 MIT fork 候选;patch 仅可应用,未编译、未运行、不计修复。 | | ConPTY 系统/固定官方后端的同源 margin A/B(#41、#47 未关闭) | [合同与第一方来源](research/conpty-margin-backends.md)、[固定 175B 与 16 case 结果](../experiments/conpty/results/2026-10-26-margin-backends/validation.json) | 相同 175B、两网格/两 mode/两 fresh:系统末端 margin 错,固定官方后端的末端正确;CHAR_INFO 仍错。禁用 VT 由窄屏反例拒绝;仅诊断可行性,不证明 PR #50 或 main 联合整合通过。 | diff --git a/docs/research/node-contract-deadline.md b/docs/research/node-contract-deadline.md index e3b1cefb..33af0578 100644 --- a/docs/research/node-contract-deadline.md +++ b/docs/research/node-contract-deadline.md @@ -38,6 +38,8 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan loca 原件、环境路径和 stderr 栈留 `local/`;[正式批次的白名单结果](../../research/ui/results/2026-10-29-node-contract-deadline/validation.json)经离线核对后另行发布,不为旧超时填写根因。#144、#38 和 R2 继续开放。 +离线出口的公开 seam 是 `export_node_contract_deadline.export`。纯内存回归替换文件字节读取,不启动 Node,也不修改真实原件;normal/throw 的 timeout Job 非空、hang Job 任意额外字段/非整数,以及任一 false 提案的源码或字节漂移必须拒绝。固定批次出口钉住三份 false 原 SHA256/commit,所有 Job 都只投影三项计数;版本、commit、hash、阶段与状态分别受类型、格式或枚举约束。CTest 的 `node_contract_export` 只含纯出口和 trace/launcher 判定类,`node_contract_plan` 仅在 WIN32 注册纯计划类;不注册 `OwnedNodeTests`,不自动运行授权批次。 + ## 预审发现与限定修正 `5ddecd4` 预审发现:完整23条前缀后追加非法/多余 checkpoint 时,解析错误保留完整前缀,原子状态判定却只检查前缀的 `complete`,可能误报 `passed`。批次层已有拒绝仍不足以保证子记录正确。该缺陷属于新诊断工具,与旧 CI 的10秒超时没有因果关系。 @@ -53,3 +55,7 @@ python -B -X utf8 -m research.ui.node_contract_deadline prepare-plan --plan loca 修正将两个当前版本的严格比较放在 marker 前,并在新批次和新子观察中记录实际值。相关纯计划测试的源码身份和日志保留在新的 `local/node-deadline-p3/`;此前 false 提案、原始观察和首次 P2 的纯日志保持原字节,不重新运行 Node 或10秒受控场景。 实际修正验证源为 `b8097064df229e5f6363be5c08a4acb31cd26554`:相关3项纯计划测试在普通与 `-O` 均通过、无跳过;两种漂移均在 marker 前拒绝。此次没有执行新的 Node 或正式诊断批次,也不把先前8/10项验证转记为这一源码的进程观察结果。 + +## 白名单出口的发布预审修正 + +`30da569` 的真实公开文件没有泄漏;纯内存恶意输入表明,未校验的非超时 `job_at_timeout` 和未固定身份的 p3 false 源码字段可被公开。red `5c8bc63` 的3项测试有5个失败子例;green `504c0fd` 在公开离线 export seam 拒绝注入,普通与 `-O` 各15项相关纯测试通过。三份 false 的原 SHA256、commit 和未执行状态均固定,非超时项只能缺失/null,超时 Job 只重建三个计数。旧原件、执行五源、旧公开 `db97e95…` 不改;新[派生结果与出口验证](../../research/ui/results/2026-10-29-node-contract-deadline/README.md#离线出口的发布预审修正)只有导出器源码哈希变化。此次不重跑 Node/10秒控制场景、原版、guest 或 CI;这些是新出口缺陷的修复,与旧 CI 超时原因分列。 diff --git a/research/ui/results/2026-10-29-node-contract-deadline/README.md b/research/ui/results/2026-10-29-node-contract-deadline/README.md index 6005421b..e249c630 100644 --- a/research/ui/results/2026-10-29-node-contract-deadline/README.md +++ b/research/ui/results/2026-10-29-node-contract-deadline/README.md @@ -32,6 +32,14 @@ Node 首次 checkpoint 位于内建 fs 加载之后;此前的启动区间仍 | 版本漂移 P2 | red `cebc530` → green `b809706` | marker前拒绝 Windows/Python 版本漂移;普通/-O各3项纯计划测试 | | 本次正式三 case | `02aac45` | 唯一新授权批次,结果如上;没有再次运行 Node 或重试 CI | -另一次独立 stdlib 审计将三个 case 与 CLI 行逐字段比较,并按原写出格式重建字节,全部一致;重算有序阶段、PID绑定、时钟、源码/运行库、计划及 Job 字段,发现0项。24份旧原件和三个未执行 false 提案逐一保持,公开只列不透明原件 ID、长度和 SHA256。普通与 `python -O` 的[白名单导出器](../../export_node_contract_deadline.py)输出逐字节一致,公开 JSON SHA256 为 `db97e95aa6d56a9bb44553178f788c6f7c33fff9e52441ef343ea63821f98a3d`。 +另一次独立 stdlib 审计将三个 case 与 CLI 行逐字段比较,并按原写出格式重建字节,全部一致;重算有序阶段、PID绑定、时钟、源码/运行库、计划及 Job 字段,发现0项。24份旧原件和三个未执行 false 提案逐一保持,公开只列不透明原件 ID、长度和 SHA256。首次发布头 `30da569` 的普通与 `python -O` 导出逐字节一致,[原公开 JSON](validation.json) SHA256 为 `db97e95aa6d56a9bb44553178f788c6f7c33fff9e52441ef343ea63821f98a3d`,该文件保留原字节。 -这是[有界诊断工具](../../../../docs/research/node-contract-deadline.md)的实现与验证,不接入原 Python/CTest 入口,不关闭 #144/#38,也不支持产品、真实 Qt/原版行为、性能或长期稳定验收。普通 observer 为 no-op,原合成输入/断言保留;`raster_style_reference.js`、原 `test_raster_style_reference.py` 入口和 `experiments/conpty/run.py` 与 `4388302` 字节一致,三项哈希另列。 +## 离线出口的发布预审修正 + +`30da569` 的真实公开结果没有泄漏,但两轴预审用纯内存反例发现两处出口缺口:normal/throw 的 `job_at_timeout` 可夹带任意对象,第三份 false 提案的源码身份也未固定。合成反例在 red `5c8bc63` 运行3项测试,得到5个预期失败子例,证明这些非法输入未被拒绝;原件未用于注入。 + +修正源 `504c0fdfdd32f7ddbde340a4831b1dec70dd3b3f` 要求非超时项该字段缺失或 null,超时项只重建 active/total/terminated 三个整数;三个 false 提案分别固定既有 SHA256 和源码 commit,并严格检查小写40位 hex。其余公开字段按固定枚举、严格标量类型和明确 key/list 重建。普通和 `-O` 各15项相关纯测试通过、无跳过;含六项纯出口测试及既有 trace/launcher 判定与计划守卫。新增 CTest 仅注册这些纯数据类,PlanTests 限定 WIN32;本次通过 configure/show-only 核对注册命令,没有执行 Node、OwnedNodeTests 或完整矩阵。 + +相同真实 raw 在新[导出器](../../export_node_contract_deadline.py)下重新派生的普通/`-O` 字节一致,见 [guarded-validation.json](guarded-validation.json),SHA256 `de57db6e0f7c3fa57bfe1e588db2fe9587792bd4181f7836797daa8eb144e27b`。它与旧 JSON 仅 `exporter_sha256` 不同;观测、源码和原件身份完全相同。自指的导出器源码哈希解释了整文件 SHA 变化,不是新运行或原始结果改写。[出口验证记录](export-guards-validation.json)分列 red/green 源码、测试和58项旧文件重算;其中包含旧24项、三份 false、正式 raw、旧发布与执行源码。原 CI ZIP 和 LastTest.log 原哈希另行复验一致。执行源仍为 `02aac45`,旧超时原因仍未知。 + +这是[有界诊断工具](../../../../docs/research/node-contract-deadline.md)的实现与验证;新 runner 不接入原 Python/CTest 执行入口,新增纯回归单独注册,不关闭 #144/#38,也不支持产品、真实 Qt/原版行为、性能或长期稳定验收。普通 observer 为 no-op,原合成输入/断言保留;`raster_style_reference.js`、原 `test_raster_style_reference.py` 入口和 `experiments/conpty/run.py` 与 `4388302` 字节一致,三项哈希另列。 diff --git a/research/ui/results/2026-10-29-node-contract-deadline/export-guards-validation.json b/research/ui/results/2026-10-29-node-contract-deadline/export-guards-validation.json new file mode 100644 index 00000000..2ea97b76 --- /dev/null +++ b/research/ui/results/2026-10-29-node-contract-deadline/export-guards-validation.json @@ -0,0 +1,64 @@ +{ + "schema": "termforge-node-export-guards-validation-v1", + "issue": 144, + "red_test_source_commit": "5c8bc6368872c609efe0d40070c1b1ae10e3cc6a", + "green_source_commit": "504c0fdfdd32f7ddbde340a4831b1dec70dd3b3f", + "executed_diagnostic_source_commit": "02aac45ca6e8eaffcbca1d3ef1a80d243159ec46", + "previous_publication_head": "30da569e538accd63913843a41d466d051f45aad", + "synthetic_red_tests": 3, + "synthetic_red_failing_subcases": 5, + "ordinary_pure_tests_passed": 15, + "optimized_pure_tests_passed": 15, + "skipped_tests": 0, + "new_node_executions": 0, + "new_formal_batches": 0, + "new_ci_retries": 0, + "ctest_registration_verified": [ + "node_contract_export", + "node_contract_plan" + ], + "ctest_runtime_executed_in_this_delta": false, + "full_matrix_executed": false, + "preserved_snapshot_count": 58, + "preserved_snapshot_sha256": "dab5544aacf273a01bd32c741b8fbeefb49fb09ad246f2471584ef6fe6751717", + "previous_public_export_sha256": "db97e95aa6d56a9bb44553178f788c6f7c33fff9e52441ef343ea63821f98a3d", + "guarded_public_export_sha256": "de57db6e0f7c3fa57bfe1e588db2fe9587792bd4181f7836797daa8eb144e27b", + "normal_and_optimized_export_byte_equal": true, + "changed_projection_fields": [ + "exporter_sha256" + ], + "exporter_sha256": "f06bcbb78e30ae1a1d75ec87e3132dd6be9f87c5f0b37e8e8145534b55bd2fc1", + "original_timeout_root_cause": null, + "private_log_identities": [ + { + "id": "red", + "bytes": 5812, + "sha256": "a64e2323c481de31b4cc5bafd3fa0fbefec663e2a2fc6b948c23e419f171c9ee" + }, + { + "id": "initial-green", + "bytes": 702, + "sha256": "d294ffd9fb20d9ba91a4283cb2a2858cbf55e1c96702fa71a340a7dd5afe0e86" + }, + { + "id": "green", + "bytes": 2896, + "sha256": "eee4a1dd2c436288b8e531439305a26149a6881f796c51ee6520b9fc8724ae3a" + }, + { + "id": "optimized-green", + "bytes": 2896, + "sha256": "3860ba1f345216407216acefc35a141fae52fab5cc1631ac549b7387fc68ba69" + }, + { + "id": "cmake-configure", + "bytes": 760, + "sha256": "4febaa2bd2bf5cd543df855a08889336361b59fd7047c7c870917ea2a0494c76" + }, + { + "id": "ctest-inventory", + "bytes": 2750, + "sha256": "7b074cfd5493a7aac60ba091e7aa6e4490e3eb70bd6dd1f9cbfc161992acf749" + } + ] +} diff --git a/research/ui/results/2026-10-29-node-contract-deadline/guarded-validation.json b/research/ui/results/2026-10-29-node-contract-deadline/guarded-validation.json new file mode 100644 index 00000000..28c80551 --- /dev/null +++ b/research/ui/results/2026-10-29-node-contract-deadline/guarded-validation.json @@ -0,0 +1,364 @@ +{ + "schema": "termforge-node-contract-public-v1", + "issue": 144, + "execution_source_commit": "02aac45ca6e8eaffcbca1d3ef1a80d243159ec46", + "authorized_plan_sha256": "87a0518c23b65f050d3991557b6b58aca334c8d80dc939fab601d3fae01f0fc8", + "maximum_attempts": 1, + "actual_case_count": 3, + "cli_exit_code": 0, + "batch_status": "expected_diagnostic_observations", + "original_timeout_root_cause": null, + "whole_lifecycle_bound_seconds": null, + "windows_version": "10.0.26200", + "python_version_number": "3.12.10", + "python_version_text_sha256": "f0823692b73f6cc9b2412e30deb4aecc1f01e6368a93b3ca3e19ea3b586b08df", + "python_exe_sha256": "0b471133e110cfb53a061cad528ce8e517d7b9ac41a0a396c39ad795a487fc14", + "node_exe_sha256": "471961cb355311c9a9dd8ba417eca8269ead32a2231653084112554cda52e8b3", + "source_files_sha256": { + "research/ui/node_contract_deadline.py": "358fc4b886b7954b1c3cba9bacf64130241681afe5860318fb895e0fbcd202e7", + "research/ui/node_contract_bootstrap.js": "f2bd0c7f7e574528bb10bf19f614cc6a77c217cfa3796258b6822429b117c8b4", + "research/ui/test_raster_native_contract.js": "b3f5e298b2611bee629c687d2c9f99210b2f14fc3386d876fbf232695df5fd63", + "research/ui/raster_style_reference.js": "499670efdb970984e8ac72d326033cca456720c83e9585152739ff6a82dd36db", + "experiments/conpty/run.py": "c8eef1e36b6d200b2d508cef91eaa5ca79a91fa87c5daf6265b2475c31dce329" + }, + "observations": [ + { + "case": "normal", + "status": "passed", + "exit_code": 0, + "execution_budget_seconds": 10, + "cleanup_budget_seconds": 5, + "process_creation_elapsed_seconds": 0.046000000002095476, + "communicate_elapsed_seconds": 0.11000000000058208, + "primary_host_exception": null, + "forced_job_termination": false, + "root_reaped": true, + "root_in_owned_job": true, + "cleanup_verified": true, + "job_before_go": { + "active": 1, + "total": 1, + "terminated": 0 + }, + "job_at_timeout": null, + "job_after": { + "active": 0, + "total": 2, + "terminated": 0 + }, + "checkpoint_count": 23, + "stages": [ + "node-first-checkpoint", + "go-received", + "fixture-before-load", + "before-file-read", + "file-read", + "vm-helpers-start", + "vm-helpers-ready", + "pure-checks-done", + "case-start:normal", + "case-end:normal", + "case-start:grab", + "case-end:grab", + "case-start:toImage", + "case-end:toImage", + "case-start:empty", + "case-end:empty", + "case-start:dpr", + "case-end:dpr", + "case-start:pixel", + "case-end:pixel", + "case-start:destroy", + "case-end:destroy", + "final" + ], + "complete": true, + "node_version": "v22.21.1", + "node_arch": "x64", + "node_platform": "win32" + }, + { + "case": "hang-vm-helpers", + "status": "timed_out", + "exit_code": 99, + "execution_budget_seconds": 10, + "cleanup_budget_seconds": 5, + "process_creation_elapsed_seconds": 0.04700000000593718, + "communicate_elapsed_seconds": 10.015999999988708, + "primary_host_exception": { + "type": "TimeoutExpired", + "timeout_seconds": 10 + }, + "forced_job_termination": true, + "root_reaped": true, + "root_in_owned_job": true, + "cleanup_verified": true, + "job_before_go": { + "active": 1, + "total": 1, + "terminated": 0 + }, + "job_at_timeout": { + "active": 2, + "total": 2, + "terminated": 0 + }, + "job_after": { + "active": 0, + "total": 2, + "terminated": 0 + }, + "checkpoint_count": 6, + "stages": [ + "node-first-checkpoint", + "go-received", + "fixture-before-load", + "before-file-read", + "file-read", + "vm-helpers-start" + ], + "complete": false, + "node_version": "v22.21.1", + "node_arch": "x64", + "node_platform": "win32" + }, + { + "case": "throw-before-grab", + "status": "failed", + "exit_code": 1, + "execution_budget_seconds": 10, + "cleanup_budget_seconds": 5, + "process_creation_elapsed_seconds": 0.031000000002677552, + "communicate_elapsed_seconds": 0.0629999999946449, + "primary_host_exception": null, + "forced_job_termination": false, + "root_reaped": true, + "root_in_owned_job": true, + "cleanup_verified": true, + "job_before_go": { + "active": 1, + "total": 1, + "terminated": 0 + }, + "job_at_timeout": null, + "job_after": { + "active": 0, + "total": 2, + "terminated": 0 + }, + "checkpoint_count": 11, + "stages": [ + "node-first-checkpoint", + "go-received", + "fixture-before-load", + "before-file-read", + "file-read", + "vm-helpers-start", + "vm-helpers-ready", + "pure-checks-done", + "case-start:normal", + "case-end:normal", + "case-start:grab" + ], + "complete": false, + "node_version": "v22.21.1", + "node_arch": "x64", + "node_platform": "win32" + } + ], + "reference_sources_unchanged_from_commit": "43883022883d382e3dd57bd627e9c735bafe58ca", + "reference_sources_sha256": { + "research/ui/raster_style_reference.js": "499670efdb970984e8ac72d326033cca456720c83e9585152739ff6a82dd36db", + "research/ui/test_raster_style_reference.py": "11c282b292d7b64c627f999920bec618bf41bcf345f076b73ceba71958fda6a5", + "experiments/conpty/run.py": "c8eef1e36b6d200b2d508cef91eaa5ca79a91fa87c5daf6265b2475c31dce329" + }, + "raw_files": [ + { + "id": "authorized-plan", + "bytes": 1578, + "sha256": "87a0518c23b65f050d3991557b6b58aca334c8d80dc939fab601d3fae01f0fc8" + }, + { + "id": "cli-log", + "bytes": 15209, + "sha256": "5935018b9cfbefb48accc60feab911fff96c8b68f10d39acc449f979db0eaa4b" + }, + { + "id": "cli-exit", + "bytes": 75, + "sha256": "c8394eb28fdbb10ba454c2db591dbf25e93d2a7c281bcbbe0451641d75790074" + }, + { + "id": "attempt-marker", + "bytes": 58, + "sha256": "21ceb85899a55c3c2532390bf948ceb66e8aa55727564935adf0a739a81afa33" + }, + { + "id": "case-normal", + "bytes": 7816, + "sha256": "f1a141c53acad15dd37d2f24b5895ba97e2b43780d8ddbf7e17f1e3394daf8eb" + }, + { + "id": "case-hang-vm-helpers", + "bytes": 3682, + "sha256": "f6b8e4de34b64466d6e1f3d04e3ee8caba832f4bd86d8fdbbe05d549a2d4c600" + }, + { + "id": "case-throw-before-grab", + "bytes": 5713, + "sha256": "7590fbaa0943292ecfdd772d85255bf80a52713b0a7af2de0ba3c80ea7e4f213" + } + ], + "preserved_prior_artifacts": [ + { + "id": "prior-00", + "bytes": 1592, + "sha256": "77429f072e16ebaf3d1b7c729aae65dd67f1ae36d682b1b3c2812eb22703ef5d" + }, + { + "id": "prior-01", + "bytes": 1589, + "sha256": "4e2a7280c1a64d6203f2b7c9f8c00bfbb4d1aa4f691f154e75bb249e074631d6" + }, + { + "id": "prior-02", + "bytes": 2534, + "sha256": "e0e80600370188fb3aa455ea25f5837849dcb6131a41b59f8bb2c6c1ec810bb7" + }, + { + "id": "prior-03", + "bytes": 1479, + "sha256": "bdfaae0f86e1fc77360d8f40f83aa23b8f31c00a6953d559f9d2fcc0e5588aba" + }, + { + "id": "prior-04", + "bytes": 1479, + "sha256": "9b09b34d02e8054b576d3a5d8c467feb6fdb3f9ce5ec259b1f26252b1ac53f89" + }, + { + "id": "prior-05", + "bytes": 2691, + "sha256": "84cb44a1af86157d12d2fbe9fb67a72b4e37e6f4d112518635d1e470ff28f4ac" + }, + { + "id": "prior-06", + "bytes": 41, + "sha256": "fb498c4df0819034d599441acd6cc73aff4811609b44cf088039eca83dd6d303" + }, + { + "id": "prior-07", + "bytes": 41, + "sha256": "d437beb05dd5b67ded31f5bf2c6ec0aa29221f8778577d714339badce9393f0e" + }, + { + "id": "prior-08", + "bytes": 1752, + "sha256": "4a939db136469f70668bc9a80029198347c47755713577eecc089c6eecb00414" + }, + { + "id": "prior-09", + "bytes": 41, + "sha256": "aac3d607fe077f1867847438020105f7745f19172013d52ed0c1f19a1e371ad2" + }, + { + "id": "prior-10", + "bytes": 3383, + "sha256": "e22a4e999666008dfe3be0eb6697c42629edcdf781815d3b6791ac0e959b1998" + }, + { + "id": "prior-11", + "bytes": 1001, + "sha256": "f128c2e076c926f60bc1a48ed585b0a68efacc49977c04eb310d65698aee6be9" + }, + { + "id": "prior-12", + "bytes": 975, + "sha256": "2294c40f835030fdb31396a1d64bc66388da653a52981e9a09f4f75de04fb1cd" + }, + { + "id": "prior-13", + "bytes": 15171, + "sha256": "0cfff1982fdfd2f8396c2764332f64105bce1e1972d2fdcd46afe3d02f592915" + }, + { + "id": "prior-14", + "bytes": 1129, + "sha256": "69db83f032acf8e46965d083dd9301c958afd6b83c52a4187f02f369fc972d54" + }, + { + "id": "prior-15", + "bytes": 1129, + "sha256": "d0feea4569e13ffb65ea91cfbf8883d1ca7fb1032166dc9d6185aa0de1bb8a6a" + }, + { + "id": "prior-16", + "bytes": 1896, + "sha256": "a1945f48371ad8b5b6f5713471fe51662841d7fdfbaa72665c02f7d6d6d74c1f" + }, + { + "id": "prior-17", + "bytes": 1758, + "sha256": "d326bb375f0ce7310bf075a9802f5f4392e21dbe0924d966ebd7995a3ced775a" + }, + { + "id": "prior-18", + "bytes": 17857, + "sha256": "2973a0c13212256c3231ed6cacf9b8548d03edba610ed5a24cacf6e5f513333e" + }, + { + "id": "prior-19", + "bytes": 1129, + "sha256": "95beb8464553f93bbb1b3b05b07d831a7c32b4867655f0677a8503aa00040161" + }, + { + "id": "prior-20", + "bytes": 904, + "sha256": "8468e0ed5535d8c67917fb1ef41722f97a74b006f4f813a29c1fb41ef05ec696" + }, + { + "id": "prior-21", + "bytes": 41, + "sha256": "0649922600b96ef7c9d4fc06e00df325d530cf04ff51fe6bf3024732f4b8dd2e" + }, + { + "id": "prior-22", + "bytes": 1294, + "sha256": "4775ef06cb4209f77a0cb1c110672f9baa2ec0bb71313394f15af38fa38dcac5" + }, + { + "id": "prior-23", + "bytes": 4147, + "sha256": "c6f0d58ab64e06f5a75c98aaa9000c379540d2fc987bca717adfbeaa04d107fb" + } + ], + "unexecuted_false_plans": [ + { + "id": "initial", + "bytes": 1589, + "sha256": "4e2a7280c1a64d6203f2b7c9f8c00bfbb4d1aa4f691f154e75bb249e074631d6", + "source_base_commit": "5ddecd4e0bd978d38a38974bb4d27b193b605fa7", + "execution_authorized": false, + "attempt_marker_present": false, + "output_present": false + }, + { + "id": "protocol-fix", + "bytes": 1592, + "sha256": "77429f072e16ebaf3d1b7c729aae65dd67f1ae36d682b1b3c2812eb22703ef5d", + "source_base_commit": "0949ac6fc371e24ee20c25c36b6e90f372c99fe8", + "execution_authorized": false, + "attempt_marker_present": false, + "output_present": false + }, + { + "id": "version-fix", + "bytes": 1592, + "sha256": "115c972ce327e9456edf9e96de4f885ec2fdeb16fd819dd34dc24bac871072b4", + "source_base_commit": "02aac45ca6e8eaffcbca1d3ef1a80d243159ec46", + "execution_authorized": false, + "attempt_marker_present": false, + "output_present": false + } + ], + "exporter_sha256": "f06bcbb78e30ae1a1d75ec87e3132dd6be9f87c5f0b37e8e8145534b55bd2fc1" +}