aps-agent/poc/pi-fallback/smoke/smoke_s6_reconcile.py

768 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# P3 S6 真实冒烟(Agent-N):MES 断连 → Pi 待补录清单 → 逐笔确认卡 → 批准补录
# → 人工触发「恢复」→ readiness probe → 对账报告(三档)→ 审计链完整。
# 对应 P3-DESIGN §9.1 验收点 A–H。真实 server + 真实 Pi + 真实 LLM(kimi-k2.6)
# + 真实 MES HTTP 适配器(断连=未监听端口;恢复=冒烟脚本内嵌假 MES HTTP 服务)。
#
# 分阶段执行(单命令 300s 预算约束;阶段间状态落 site4/ + s6_results.json):
# python smoke_s6_reconcile.py setup —— 搭临时现场(幂等重建)
# python smoke_s6_reconcile.py outage —— 断连期:readiness failed + 补录全链
# python smoke_s6_reconcile.py recovery —— 恢复期:假 MES 上线 + 对账协议
# python smoke_s6_reconcile.py control —— 对照组:删白名单重发补录请求
# python smoke_s6_reconcile.py finalize —— 收尾校验 + 汇总退出码
#
# 已知设施取舍(如实记录):
# - 断连前现场(已下发工单 + 断连期间完工事实)由脚本直接种子到租户世界与
# CSV 导出文件(watcher 投 inbox)——这是冒烟设施,不是产品能力声明;
# - 假 MES 是冒烟脚本内嵌的最小 HTTP 服务(/health + GET /work-orders/{id}),
# 用于让真实 HttpMesClient 走真实 HTTP 协议完成恢复后对账;
# - LLM key 只经 server 子进程环境从仓库根 .env 流入,本脚本不读不打印不落盘。
import csv
import hashlib
import json
import os
import shutil
import subprocess
import sys
import threading
import time
import urllib.request
from http.cookiejar import CookieJar
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
HERE = Path(__file__).resolve().parent # poc/pi-fallback/smoke
REPO = HERE.parents[2]
SITE = HERE / "site4"
APS_HOME = SITE / "aps-home"
DATA = APS_HOME / "data"
PORT = 8394
MES_PORT = 8641 # 断连期无人监听;恢复期假 MES 上线
BASE = f"http://127.0.0.1:{PORT}"
DEVICE_ID = "smoke-n-device-s6-0001"
RESULTS = HERE / "s6_results.json"
MOCK_TENANT = hashlib.sha256(f"{SITE.resolve()}|tenant".encode()).hexdigest()[:32]
MOCK_SECRET = hashlib.sha256(f"{SITE.resolve()}|license".encode()).hexdigest()
# 口令逐条避让 intent.py 快路(无 完工报工/工序报工/报工回流/下发MES/执行进度/
# 分析..文件/导入 等触发)与 _RECONCILE_SIGNALS(对账/恢复)——落 LLM 分类 →
# assistant.reply → 兜底分支 → S6 归类(命中 连不上/补录/mes 信号)。
QUERY_BACKFILL = ("车间 MES 连不上了,今天实际干完的活系统里都还没登记"
"(车间导出的 mes-导出-车间完工.csv 已放进 inbox)。"
"帮我核对这份车间导出表,把断连期间落下的进度逐笔补登进系统,别漏行。")
QUERY_RECONCILE = "MES 恢复了,对一下账"
PRODUCT_CODE = "28200003654300" # demo 世界唯一成品物料
WO_SEED = [(9001, "EXT-1001"), (9002, "EXT-1002")]
CSV_ROWS = [("EXT-1001", "100", "10", "COMPLETED"),
("EXT-1002", "60", "6", "RUNNING")]
def load_results() -> dict:
if RESULTS.is_file():
return json.loads(RESULTS.read_text(encoding="utf-8"))
return {"startedAt": time.strftime("%Y-%m-%d %H:%M:%S"), "checks": {}}
R = load_results()
def check(name: str, ok: bool, detail: str = "") -> bool:
R["checks"][name] = {"ok": bool(ok), "detail": detail}
print(f"[{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
return ok
def dump_results() -> None:
RESULTS.write_text(json.dumps(R, ensure_ascii=False, indent=2, default=str),
encoding="utf-8")
def http(opener, method, path, body=None, headers=None, timeout=30):
req = urllib.request.Request(BASE + path, method=method)
for k, v in (headers or {}).items():
req.add_header(k, v)
data = None
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
req.add_header("Content-Type", "application/json")
return opener.open(req, data=data, timeout=timeout)
def desktop_headers():
return {"x-aps-client": "desktop", "x-aps-device-id": DEVICE_ID}
def tenant_world_path() -> Path:
device_hash = hashlib.sha256(DEVICE_ID.encode("utf-8")).hexdigest()
user_id = int(device_hash[:15], 16)
return DATA / "tenants" / MOCK_TENANT / "projects" / f"personal-{user_id}" / "world.json"
def world_fingerprint_of(path: Path) -> str:
sys.path.insert(0, str(REPO))
from server.agent_core.harness import world_fingerprint
return world_fingerprint(json.loads(path.read_text(encoding="utf-8")))
def start_server(env_extra: dict, log_name: str):
env = dict(os.environ)
env.update({
"APS_HOME": str(APS_HOME),
"APS_FEATURES_PATH": str(SITE / "features.json"),
"APS_FALLBACK_HIGHRISK_PATH": str(SITE / "fallback-highrisk.json"),
"APS_WORLD_PATH": str(DATA / "world.json"),
"APS_DB_PATH": str(DATA / "master.db"),
"APS_LICENSE_PROVIDER": "mock",
"APS_MOCK_LICENSE_SECRET": MOCK_SECRET,
"APS_MOCK_LICENSE_TENANT_UUID": MOCK_TENANT,
"APS_PORT": str(PORT),
"APS_FALLBACK_MODEL": "aps-fallback/kimi-k2.6",
"APS_FALLBACK_TIMEOUT_SEC": "170",
"MES_HTTP_BASE_URL": f"http://127.0.0.1:{MES_PORT}",
"MES_HTTP_TIMEOUT_SECONDS": "2",
"MES_HTTP_MAX_RETRIES": "1",
# 本机系统代理(注册表 127.0.0.1:7897)会劫持 httpx 对本地端口的请求,
# 把「拒连」包装成代理错误页(UPSTREAM_ERROR)——显式绕过,冒烟环境层修正
"NO_PROXY": "127.0.0.1,localhost",
"no_proxy": "127.0.0.1,localhost",
"PYTHONIOENCODING": "utf-8",
})
env.pop("APS_FALLBACK_DIR", None)
env.update(env_extra)
server_log = open(HERE / log_name, "w", encoding="utf-8")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.main:app",
"--host", "127.0.0.1", "--port", str(PORT)],
cwd=str(REPO), env=env, stdout=server_log, stderr=subprocess.STDOUT)
return proc, server_log
def wait_health(proc, seconds=90) -> dict | None:
for _ in range(seconds):
if proc.poll() is not None:
return None
try:
with urllib.request.urlopen(BASE + "/api/health", timeout=3) as r:
return json.loads(r.read().decode("utf-8"))
except Exception:
time.sleep(1)
return None
def kill_server(proc, server_log) -> None:
if proc.poll() is None:
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output=True, timeout=30, check=False)
try:
proc.wait(timeout=20)
except Exception:
pass
server_log.close()
def make_opener():
cj = CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
def activate_license(opener) -> None:
resp = http(opener, "POST", "/api/auth/license/activate",
{"code": "APS-DAY-DEMO", "deviceId": DEVICE_ID},
headers=desktop_headers())
resp.read()
def parse_sse(raw: str) -> list[dict]:
events = []
for line in raw.splitlines():
line = line.strip()
if line.startswith("data:"):
try:
events.append(json.loads(line[5:].strip()))
except json.JSONDecodeError:
pass
return events
class InboxWatcher:
"""把车间导出 CSV 投入新建 run 目录的 inbox/(模拟「给文件」环节)。"""
def __init__(self, fb_root: Path, payload: Path):
self.fb_root = fb_root
self.payload = payload
self.delivered_to = ""
self._stop = threading.Event()
self._thread = threading.Thread(target=self._loop, daemon=True)
def _loop(self):
while not self._stop.is_set() and not self.delivered_to:
try:
if self.fb_root.is_dir():
for d in sorted(self.fb_root.glob("fb-*")):
if d.is_dir():
dst = d / "inbox" / self.payload.name
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(self.payload, dst)
self.delivered_to = str(dst)
return
except Exception:
pass
time.sleep(0.25)
def __enter__(self):
self._thread.start()
return self
def __exit__(self, *exc):
self._stop.set()
self._thread.join(timeout=5)
class FakeMes:
"""内嵌最小 MES HTTP 服务:GET /health + GET /work-orders/{id}(真实 HTTP 协议)。"""
def __init__(self, port: int, dataset: dict[str, dict]):
self.port = port
self.dataset = dataset
self.server = None
self.thread = None
def _handler(self):
dataset = self.dataset
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # 静音
pass
def do_GET(self):
if self.path == "/health":
body = {"system": "MES-HTTP-FAKE", "plant": "CNWH",
"woCount": len(dataset), "openCount": 0,
"reportCount": 0, "updatedAt": "2026-09-05T00:00:00"}
elif self.path.startswith("/work-orders/"):
ext_id = self.path.rsplit("/", 1)[-1]
body = dataset.get(ext_id)
if body is None:
self.send_response(404)
self.end_headers()
return
else:
self.send_response(404)
self.end_headers()
return
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return Handler
def __enter__(self):
self.server = ThreadingHTTPServer(("127.0.0.1", self.port), self._handler())
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
return self
def __exit__(self, *exc):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
# ---------------------------------------------------------------------------
# phase: setup —— 搭临时现场(幂等重建 site4)
# ---------------------------------------------------------------------------
def _robocopy_purge(target: Path) -> None:
"""robocopy 空目录镜像 purge:绕开 Windows MAX_PATH(pi 会话目录名编码完整
工作路径 + 中文,rmtree 递归在超长路径上必败——冒烟环境层对策)。"""
empty = target.parent / "_empty_purge"
empty.mkdir(exist_ok=True)
subprocess.run(["robocopy", str(empty), str(target), "/MIR",
"/NFL", "/NDL", "/NJH", "/NJS", "/NP"],
capture_output=True, timeout=120, check=False)
try:
empty.rmdir()
target.rmdir()
except OSError:
pass
def phase_setup() -> int:
if SITE.exists():
for attempt in range(30):
try:
shutil.rmtree(SITE)
break
except OSError:
if attempt == 29:
_robocopy_purge(SITE)
if SITE.exists():
raise
time.sleep(1.0)
DATA.mkdir(parents=True)
shutil.copy(REPO / "server" / "data" / "world.json", DATA / "world.json")
tworld = tenant_world_path()
tworld.parent.mkdir(parents=True)
seed = json.loads((REPO / "server" / "data" / "world.json").read_text(encoding="utf-8"))
# 断连前现场:2 张已下发工单(世界侧投影 + mesLinks 链路),进度 0。
# 种进 flexWorkOrders:mes.apply_report 默认 track=flex 查该表
# (真实 Pi 计划未申报 track → 默认 flex;golden 的 track=fixed 是测试构造)
seed.setdefault("flexWorkOrders", [])
seed.setdefault("mesLinks", [])
for wo_id, ext_id in WO_SEED:
seed["flexWorkOrders"].append({
"id": wo_id, "mesExternalId": ext_id, "status": "RUNNING",
"progressPct": 0, "qtyDone": 0, "productCode": PRODUCT_CODE})
seed["mesLinks"].append({
"kind": "dispatch", "woId": wo_id, "externalWoId": ext_id,
"idemKey": f"idem-s6-{wo_id}"})
tworld.write_text(json.dumps(seed, ensure_ascii=False), encoding="utf-8")
(SITE / "features.json").write_text(
json.dumps({"version": 1, "features": {"fallback": True}}, ensure_ascii=False),
encoding="utf-8")
whitelist = {
"whitelistVersion": 1, "updatedAt": "2026-09-05T09:00:00",
"updatedBy": "smoke-s6",
"scenarios": {
"S4": {"enabled": True,
"intents": ["flex.simulate_due", "flex.compare",
"scenario.compare", "scenario.sensitivity"],
"roles": ["planner", "admin"]},
"S6": {"enabled": True, "intents": ["mes.report"],
"roles": ["planner", "admin"], "maxItemsPerRun": 20},
"S7": {"enabled": True,
"intents": ["agent.fallback.ops.config.apply",
"agent.fallback.policy.update"],
"roles": ["ops", "admin"]}}}
(SITE / "fallback-highrisk.json").write_text(
json.dumps(whitelist, ensure_ascii=False, indent=2), encoding="utf-8")
# 断连期间车间实际完工事实(MES 导出表)
with open(SITE / "mes-导出-车间完工.csv", "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["外部单号", "完工进度%", "完工数量", "状态"])
w.writerows(CSV_ROWS)
R.clear()
R.update({"startedAt": time.strftime("%Y-%m-%d %H:%M:%S"), "checks": {},
"site": str(SITE), "tenantWorld": str(tworld),
"queryBackfill": QUERY_BACKFILL, "queryReconcile": QUERY_RECONCILE})
check("setup.site_ready", tworld.is_file()
and (SITE / "fallback-highrisk.json").is_file())
dump_results()
return 0
# ---------------------------------------------------------------------------
# phase: outage —— 断连注入 + 段 A/B/C 全链(验收点 A/B/C/D)
# ---------------------------------------------------------------------------
def _chat(opener, query: str, timeout: int = 190) -> tuple[str, list[dict]]:
resp = http(opener, "POST", "/api/chat", {"text": query},
headers=desktop_headers(), timeout=timeout)
raw = resp.read().decode("utf-8", errors="replace")
return raw, parse_sse(raw)
def _confirm(opener, confirm_id: str, note: str) -> dict:
resp = http(opener, "POST", "/api/actions/confirm",
{"confirmId": confirm_id, "approve": True, "note": note},
headers=desktop_headers(), timeout=120)
return json.loads(resp.read().decode("utf-8"))
def phase_outage() -> int:
tworld = tenant_world_path()
# 前置自检:断连注入端口必须无人监听(否则「断连」前提不成立,冒烟无效)
try:
urllib.request.urlopen(f"http://127.0.0.1:{MES_PORT}/health", timeout=2)
check("A.mes_outage_injected", False,
f"端口 {MES_PORT} 已有服务监听,断连前提不成立")
dump_results()
return 1
except Exception:
pass
proc, server_log = start_server({}, "server_s6_outage.log")
R["serverPidOutage"] = proc.pid
try:
health = wait_health(proc)
if not health:
check("A0.outage_server_health", False, "server 90s 内未就绪(见 server_s6_outage.log)")
dump_results()
return 1
check("A0.outage_server_health", True)
opener = make_opener()
activate_license(opener)
# 验收点 A:断连注入(readiness 探测 → connectivity=failed + CONNECT_FAILED)
resp = http(opener, "POST", "/api/integrations/mes/readiness/probe",
{}, headers=desktop_headers())
probe = json.loads(resp.read().decode("utf-8"))
R["readinessOutage"] = probe
check("A.mes_outage_injected",
probe.get("connectivity") == "failed"
and (probe.get("lastError") or {}).get("code") == "MES_HTTP_CONNECT_FAILED",
f"connectivity={probe.get('connectivity')} "
f"code={(probe.get('lastError') or {}).get('code')}")
# 验收点 B:断连前现场已种子(世界内 mesLinks=2 + 导出文件待投 inbox)
world0 = json.loads(tworld.read_text(encoding="utf-8"))
links = [l for l in world0.get("mesLinks") or [] if l.get("kind") == "dispatch"]
check("B.prescene_seeded", len(links) == 2
and (SITE / "mes-导出-车间完工.csv").is_file(),
f"mesLinks={len(links)}")
# 验收点 C:断连对话 → S6 兜底(集成状态注入 + Pi 待补录清单/计划)
fb_root = DATA / "fallback"
runs_before = {p.name for p in fb_root.glob("fb-*")} if fb_root.is_dir() else set()
t0 = time.monotonic()
with InboxWatcher(fb_root, SITE / "mes-导出-车间完工.csv") as watcher:
raw, events = _chat(opener, QUERY_BACKFILL)
R["chatElapsedSec"] = round(time.monotonic() - t0, 1)
R["csvDeliveredTo"] = watcher.delivered_to
(HERE / "chat_s6_outage.sse").write_text(raw, encoding="utf-8")
reply_text = "".join(e.get("text", "") for e in events if e.get("type") == "token")
(HERE / "reply_s6_outage.txt").write_text(reply_text, encoding="utf-8")
intent_ev = next((e for e in events if e.get("type") == "intent"), None)
intent_name = ((intent_ev or {}).get("intent") or {}).get("intent") \
if isinstance((intent_ev or {}).get("intent"), dict) \
else (intent_ev or {}).get("intent")
check("C1.intent_lands_assistant_reply", intent_name == "assistant.reply",
f"intent={intent_name}")
runs_after = {p.name for p in fb_root.glob("fb-*")} if fb_root.is_dir() else set()
new_runs = sorted(runs_after - runs_before)
run_dir = fb_root / new_runs[-1] if new_runs else None
R["outageRunDir"] = str(run_dir) if run_dir else None
status_md = ""
if run_dir and (run_dir / "inbox" / "integration-status.md").is_file():
status_md = (run_dir / "inbox" / "integration-status.md") \
.read_text(encoding="utf-8")
check("C2.integration_status_injected",
"connectivity=failed" in status_md and "未连接" in status_md,
status_md.replace("\n", " ")[:200])
plan_doc = None
if run_dir and (run_dir / "outbox" / "plan.json").is_file():
plan_doc = json.loads((run_dir / "outbox" / "plan.json")
.read_text(encoding="utf-8"))
R["outagePlan"] = plan_doc
check("C3.s6_plan_produced",
bool(plan_doc) and plan_doc.get("scenario") == "S6"
and all(s.get("intent") == "mes.report" and s.get("mode") == "frozen"
for s in (plan_doc or {}).get("steps", [])),
f"scenario={(plan_doc or {}).get('scenario')} "
f"steps={len((plan_doc or {}).get('steps', []))}")
# 验收点 D:逐笔确认卡(张数 == 清单项数)+ 逐张批准逐笔落账 + 独立 checkpoint
blocks = [e["block"] for e in events
if e.get("type") == "block" and isinstance(e.get("block"), dict)]
cards = [b for b in blocks
if (b.get("props") or {}).get("action") == "agent.fallback.execute"]
R["outageCards"] = [{"confirmId": (b.get("props") or {}).get("confirmId"),
"title": (b.get("props") or {}).get("title"),
"summary": (b.get("props") or {}).get("summary")}
for b in cards]
n_steps = len((plan_doc or {}).get("steps", []))
check("D1.per_item_cards", bool(cards) and len(cards) == n_steps and n_steps > 0,
f"cards={len(cards)} steps={n_steps}")
if not cards:
dump_results()
return 1
# 卡摘要全部由结构化字段再生成(逐笔列明 woId / 补录内容)
summary_blob = json.dumps(R["outageCards"], ensure_ascii=False)
check("D2.card_summary_machine_rendered",
"工单 woId=" in summary_blob and "计划指纹 sha256:" in summary_blob)
confirm_results = []
for i, card in enumerate(cards, 1):
cid = card["props"]["confirmId"]
confirm_results.append(_confirm(opener, cid, f"P3 S6 冒烟批准第 {i} 笔"))
R["confirmResults"] = [
{"message": str(r.get("message") or "")[:300]} for r in confirm_results]
all_ok = all("兜底计划已执行完成" in str(r.get("message") or "")
for r in confirm_results)
check("D3.each_card_executed", all_ok,
f"{sum('兜底计划已执行完成' in str(r.get('message') or '') for r in confirm_results)}"
f"/{len(confirm_results)} 笔执行完成")
# 落账对账:按各卡冻结参数逐项核对(run 目录冻结计划 steps.params 是
# 唯一事实源——approvals.json 历史条目只存 paramsHash 不存 params;
# 修复登记:原实现读 history[].record.params 恒为空,该路径此前从未在
# 通过态被验证,Agent-O 修复轮改为读冻结计划)
world_after = json.loads(tworld.read_text(encoding="utf-8"))
wos = {w["id"]: w
for table in ("flexWorkOrders", "workOrders")
for w in world_after.get(table) or []}
applied = [dict(s.get("params") or {})
for s in (plan_doc or {}).get("steps", [])]
R["appliedParams"] = applied
mismatches = []
for p in applied:
wo = wos.get(p.get("woId"))
if wo is None:
mismatches.append(f"woId={p.get('woId')} 不存在")
continue
if p.get("finish") and wo.get("status") != "COMPLETED":
mismatches.append(f"woId={p['woId']} 未完工({wo.get('status')})")
if p.get("progressPct") is not None \
and wo.get("progressPct") != p.get("progressPct"):
mismatches.append(f"woId={p['woId']} 进度 {wo.get('progressPct')}"
f"!={p.get('progressPct')}")
check("D4.backfill_applied_per_card",
bool(applied) and not mismatches,
f"落账 {len(applied)} 笔;mismatches={mismatches}")
# 独立 checkpoint 对(每笔一对)
ck_file = tworld.parent / "checkpoints.json"
ck = json.loads(ck_file.read_text(encoding="utf-8")) if ck_file.is_file() else {}
reasons = [p.get("reason") for p in (ck.get("pairs") or [])]
n_pairs = reasons.count("auto:fallback.execute")
n_posts = reasons.count("auto:fallback.execute.post")
check("D5.independent_checkpoint_pairs",
n_pairs == len(cards) and n_posts == len(cards),
f"before={n_pairs} after={n_posts} cards={len(cards)}")
# 第二张卡成功执行 = 偏差⑦(信封哈希同步重算)真实链路直接证据
check("D6.second_card_envelope_ok", len(confirm_results) >= 2 and all_ok,
"多卡逐张批准全成功(偏差⑦修复的真实链路复证)")
# 审计链完整性
audits = world_after.get("auditEvents") or []
sys.path.insert(0, str(REPO))
from server.agent_core.registry import verify_audit_chain
chain = verify_audit_chain(audits)
stage_ev = [a for a in audits if a.get("action") == "agent.fallback.execute.stage"]
exec_ev = [a for a in audits if a.get("action") == "agent.fallback.execute"
and a.get("category") == "WORLD_WRITE" and a.get("result") == "SUCCESS"]
check("G1.audit_chain_outage",
bool(chain.get("ok")) and len(stage_ev) == len(cards)
and len(exec_ev) == len(cards),
f"chain.ok={chain.get('ok')} stage={len(stage_ev)} exec={len(exec_ev)}")
finally:
kill_server(proc, server_log)
dump_results()
failed = [k for k, v in R["checks"].items() if not v["ok"]]
return 0 if not failed else 1
# ---------------------------------------------------------------------------
# phase: recovery —— 恢复注入(假 MES 上线)+ 段 D 对账协议(验收点 E/F)
# ---------------------------------------------------------------------------
def phase_recovery() -> int:
tworld = tenant_world_path()
# 用补录后的真实本地状态构造假 MES 数据集:
# EXT-1001 完全镜像本地 → MATCH;EXT-1002 进度改成一个不同值 → DRIFT。
world_now = json.loads(tworld.read_text(encoding="utf-8"))
wos = {w.get("mesExternalId"): w
for table in ("flexWorkOrders", "workOrders")
for w in world_now.get(table) or []}
dataset = {}
local1 = wos.get("EXT-1001") or {}
local2 = wos.get("EXT-1002") or {}
dataset["EXT-1001"] = {"id": "EXT-1001",
"status": local1.get("status"),
"progressPct": local1.get("progressPct"),
"qtyDone": local1.get("qtyDone")}
drift_pct = 40 if local2.get("progressPct") != 40 else 70
dataset["EXT-1002"] = {"id": "EXT-1002",
"status": local2.get("status"),
"progressPct": drift_pct,
"qtyDone": local2.get("qtyDone")}
R["fakeMesDataset"] = dataset
R["reconcileExpect"] = {"EXT-1001": "MATCH", "EXT-1002": "DRIFT"}
fp_before = world_fingerprint_of(tworld)
proc, server_log = start_server({}, "server_s6_recovery.log")
try:
with FakeMes(MES_PORT, dataset):
health = wait_health(proc)
if not health:
check("E0.recovery_server_health", False,
"server 90s 内未就绪(见 server_s6_recovery.log)")
dump_results()
return 1
check("E0.recovery_server_health", True)
opener = make_opener()
activate_license(opener)
# 验收点 E:恢复注入(readiness → connectivity=ok)
resp = http(opener, "POST", "/api/integrations/mes/readiness/probe",
{}, headers=desktop_headers())
probe = json.loads(resp.read().decode("utf-8"))
R["readinessRecovery"] = probe
check("E.mes_recovered", probe.get("connectivity") == "ok",
f"connectivity={probe.get('connectivity')}")
# 验收点 F:人工触发对账 → 报告三档 + 证据链冻结 + ALGO_RUN 审计
raw, events = _chat(opener, QUERY_RECONCILE)
(HERE / "chat_s6_recovery.sse").write_text(raw, encoding="utf-8")
reply_text = "".join(e.get("text", "") for e in events
if e.get("type") == "token")
(HERE / "reply_s6_recovery.txt").write_text(reply_text, encoding="utf-8")
R["reconcileReplyHead"] = reply_text[:600]
fb_root = DATA / "fallback"
runs = sorted([p for p in fb_root.glob("fb-*") if p.is_dir()],
key=lambda p: p.stat().st_mtime) if fb_root.is_dir() else []
run_dir = runs[-1] if runs else None
R["reconcileRunDir"] = str(run_dir) if run_dir else None
report = ""
if run_dir and (run_dir / "outbox" / "reconcile-report.md").is_file():
report = (run_dir / "outbox" / "reconcile-report.md") \
.read_text(encoding="utf-8")
R["reconcileReport"] = report
check("F1.reconcile_reply_counts",
"一致 1" in reply_text and "漂移 1" in reply_text,
reply_text.replace("\n", " ")[:200])
check("F2.reconcile_report_verdicts",
"| EXT-1001 | 9001 | MATCH |" in report
and "| EXT-1002 | 9002 | DRIFT |" in report,
report.replace("\n", " ")[:300])
manifest = {}
if run_dir and (run_dir / "outbox" / "reconcile-manifest.json").is_file():
manifest = json.loads((run_dir / "outbox" / "reconcile-manifest.json")
.read_text(encoding="utf-8"))
sha_ok = bool(manifest.get("items")) and all(
run_dir and (run_dir / it["path"]).is_file()
and hashlib.sha256((run_dir / it["path"]).read_text(encoding="utf-8")
.encode("utf-8")).hexdigest() == it["sha256"]
for it in manifest.get("items", []))
check("F3.evidence_frozen", sha_ok,
f"manifest items={len(manifest.get('items') or [])} sha256 全部应验")
world_after = json.loads(tworld.read_text(encoding="utf-8"))
recon_audits = [a for a in world_after.get("auditEvents") or []
if a.get("action") == "agent.fallback.reconcile"]
rationale = (recon_audits[-1].get("rationale") or {}) if recon_audits else {}
check("F4.reconcile_audit",
bool(recon_audits) and recon_audits[-1].get("category") == "ALGO_RUN"
and bool(rationale.get("manifestSha256"))
and rationale.get("match") == 1 and rationale.get("drift") == 1,
f"rationale={json.dumps(rationale, ensure_ascii=False)[:200]}")
# 对账纯读:世界指纹前后相等(审计键不干扰指纹口径)
fp_after = world_fingerprint_of(tworld)
check("F5.reconcile_readonly", fp_before == fp_after,
f"fp {fp_before[:12]} == {fp_after[:12]}")
# 假 MES 与真实 HTTP 适配器在环的直接证据:外部原文落盘内容即数据集
ext_file = run_dir / "inbox" / "external" / "EXT-1002.json" if run_dir else None
ext_payload = ext_file.read_text(encoding="utf-8") \
if ext_file and ext_file.is_file() else ""
check("F6.external_snapshot_real",
f'"progressPct": {drift_pct}' in ext_payload
and "UNTRUSTED_DATA" in ext_payload,
f"外部原文含漂移值 {drift_pct}")
finally:
kill_server(proc, server_log)
dump_results()
failed = [k for k, v in R["checks"].items() if not v["ok"]]
return 0 if not failed else 1
# ---------------------------------------------------------------------------
# phase: control —— 对照组:删白名单重发补录请求(验收点 G)
# ---------------------------------------------------------------------------
def phase_control() -> int:
tworld = tenant_world_path()
wl_path = SITE / "fallback-highrisk.json"
wl_backup = SITE / "fallback-highrisk.json.disabled"
if wl_path.is_file():
wl_path.rename(wl_backup) # 白名单缺失 → fail-closed 全拒
proc, server_log = start_server({}, "server_s6_control.log")
try:
health = wait_health(proc)
if not health:
check("G0.control_server_health", False,
"server 90s 内未就绪(见 server_s6_control.log)")
dump_results()
return 1
opener = make_opener()
activate_license(opener)
world_before = json.loads(tworld.read_text(encoding="utf-8"))
wos_before = {w["id"]: dict(w)
for table in ("flexWorkOrders", "workOrders")
for w in world_before.get(table) or []}
raw, events = _chat(opener, QUERY_BACKFILL)
(HERE / "chat_s6_control.sse").write_text(raw, encoding="utf-8")
reply_text = "".join(e.get("text", "") for e in events if e.get("type") == "token")
(HERE / "reply_s6_control.txt").write_text(reply_text, encoding="utf-8")
R["controlReplyHead"] = reply_text[:600]
blocks = [e["block"] for e in events
if e.get("type") == "block" and isinstance(e.get("block"), dict)]
s6_cards = [b for b in blocks
if "补录" in str((b.get("props") or {}).get("title") or "")]
world_after = json.loads(tworld.read_text(encoding="utf-8"))
wos_after = {w["id"]: dict(w)
for table in ("flexWorkOrders", "workOrders")
for w in world_after.get(table) or []}
check("G.control_no_s6_card_no_write",
not s6_cards and wos_before == wos_after,
f"S6 卡={len(s6_cards)};工单表逐行一致={wos_before == wos_after}"
f"(白名单缺失 → S6 入口物理关闭,分类降级走 P1/P2 路径)")
finally:
kill_server(proc, server_log)
if wl_backup.is_file():
wl_backup.rename(wl_path) # 还原现场供查验
dump_results()
failed = [k for k, v in R["checks"].items() if not v["ok"]]
return 0 if not failed else 1
# ---------------------------------------------------------------------------
# phase: finalize —— 收尾校验(验收点 H)+ 汇总
# ---------------------------------------------------------------------------
def phase_finalize() -> int:
time.sleep(2)
port_free = False
try:
urllib.request.urlopen(BASE + "/api/health", timeout=3)
except Exception:
port_free = True
mes_free = False
try:
urllib.request.urlopen(f"http://127.0.0.1:{MES_PORT}/health", timeout=3)
except Exception:
mes_free = True
wmi = ""
ps_exe = shutil.which("powershell.exe") or shutil.which("powershell") or "powershell.exe"
try:
wmi = subprocess.run(
[ps_exe, "-NoProfile", "-Command",
("Get-CimInstance Win32_Process | Where-Object "
"{$_.CommandLine -like '*pi-coding-agent*cli.js*' "
"-and $_.CommandLine -notlike '*Get-CimInstance*'} "
"| Select-Object -ExpandProperty ProcessId")],
capture_output=True, text=True, timeout=60, check=False).stdout.strip()
except Exception as exc:
R["piResidualCheckError"] = str(exc)
R["piResidualPids"] = [x for x in wmi.splitlines() if x.strip()]
git = subprocess.run(["git", "status", "--short", "server/data"],
cwd=str(REPO), capture_output=True, text=True, timeout=30,
check=False).stdout.strip()
R["gitServerData"] = git or "(clean)"
check("H.cleanup", port_free and mes_free and not R["piResidualPids"] and not git,
f"端口释放={port_free}/{mes_free} pi 残留={R['piResidualPids']} git={git or 'clean'}")
dump_results()
failed = [k for k, v in R["checks"].items() if not v["ok"]]
total = len(R["checks"])
print(json.dumps({"passed": total - len(failed), "total": total,
"failed": failed}, ensure_ascii=False, indent=2))
return 0 if not failed else 1
PHASES = {"setup": phase_setup, "outage": phase_outage, "recovery": phase_recovery,
"control": phase_control, "finalize": phase_finalize}
if __name__ == "__main__":
if len(sys.argv) != 2 or sys.argv[1] not in PHASES:
print(f"usage: {sys.argv[0]} <{'|'.join(PHASES)}>")
sys.exit(2)
sys.exit(PHASES[sys.argv[1]]())