# ============================================================ # Saga/补偿编排框架(moduleId: domain-saga, 可重用 ✔) # round-39 方向 P:跨内部世界状态与外部系统的 Saga/补偿 # ① 编排单元:step(name/action/幂等键 idem_key/超时 timeout_sec/重试 max_retries/补偿 compensation_action) # ② 恢复队列:saga 记录随世界状态持久化(宽容加载),resume/replay 未完成 saga # ③ 补偿注册表:动作→补偿动作映射;失败自动执行补偿链;补偿也失败 → MANUAL_TAKEOVER # ④ 超时/重试:每步超时上限 + 重试次数(指数退避简化),超限走补偿 # ⑤ 审计:每次 step 状态迁移写 saga.step.*(含 idemKey/status/compensation) # ⑥ 第一个 Saga 实例:WMS 缺料→重排→MES 下发→回执 闭环(server/aps_domain/wms_events.py) # ============================================================ from __future__ import annotations import copy import time import uuid from dataclasses import dataclass from datetime import datetime from typing import Any, Callable from server.timeutil import fmt_dt World = dict[str, Any] # ------------------------------------------------------------ # 状态机 # ------------------------------------------------------------ SAGA_STATUSES = ("PENDING", "RUNNING", "WAITING_HUMAN", "SUCCEEDED", "FAILED", "COMPENSATING", "COMPENSATED", "MANUAL_TAKEOVER") STEP_STATUSES = ("PENDING", "RUNNING", "WAITING_HUMAN", "SUCCEEDED", "FAILED", "COMPENSATED", "SKIPPED") _TERMINAL = ("SUCCEEDED", "COMPENSATED", "MANUAL_TAKEOVER") _RECOVERABLE = ("PENDING", "RUNNING", "WAITING_HUMAN", "FAILED") class SagaError(Exception): """Saga 编排基础异常。""" class SagaStepTimeout(SagaError): """步骤超时(超过 timeout_sec 或动作主动抛出)。""" class SagaStepFailed(SagaError): """步骤业务失败(动作可主动抛出,等价于抛普通异常)。""" class SagaCompensationError(SagaError): """补偿动作失败(触发 MANUAL_TAKEOVER)。""" class SagaNotFound(KeyError): """saga 记录不存在。""" def _now() -> str: return fmt_dt(datetime.now()) def _jsonable(value: Any) -> Any: """把步骤结果递归转成可 JSON 持久化形态(pydantic BaseModel → dict,其余兜底 str)。""" if hasattr(value, "model_dump"): return _jsonable(value.model_dump()) if isinstance(value, dict): return {str(k): _jsonable(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [_jsonable(v) for v in value] if value is None or isinstance(value, (str, int, float, bool)): return value return str(value) # ------------------------------------------------------------ # ① 编排单元 # ------------------------------------------------------------ @dataclass class SagaStep: """单个 saga 步骤:动作 + 幂等键 + 超时 + 重试 + 补偿动作。""" name: str action: str idem_key: str = "" timeout_sec: float = 30.0 max_retries: int = 2 compensation_action: str | None = None gate: bool = False @classmethod def from_mapping(cls, raw: dict[str, Any]) -> SagaStep: d = dict(raw or {}) keys = {"name", "action", "idem_key", "timeout_sec", "max_retries", "compensation_action", "gate", "idemKey", "timeoutSec", "maxRetries", "compensationAction"} clean: dict[str, Any] = {} for k, v in d.items(): if k not in keys: continue clean[k] = v if "idemKey" in clean and "idem_key" not in clean: clean["idem_key"] = clean.pop("idemKey") if "timeoutSec" in clean and "timeout_sec" not in clean: clean["timeout_sec"] = clean.pop("timeoutSec") if "maxRetries" in clean and "max_retries" not in clean: clean["max_retries"] = clean.pop("maxRetries") if "compensationAction" in clean and "compensation_action" not in clean: clean["compensation_action"] = clean.pop("compensationAction") return cls(**clean) def to_dict(self) -> dict[str, Any]: return { "name": self.name, "action": self.action, "idemKey": self.idem_key, "timeoutSec": float(self.timeout_sec), "maxRetries": int(self.max_retries), "compensationAction": self.compensation_action, "gate": bool(self.gate), "status": "PENDING", "attempts": 0, "timeouts": 0, "startedAt": None, "finishedAt": None, "result": None, "error": None, "compensation": None, } # ------------------------------------------------------------ # Saga 编排器 # ------------------------------------------------------------ class SagaCoordinator: """Saga 编排器:步骤执行 / 重试 / 超时 / 补偿链 / 人工接管 / 持久化恢复。 - saga 记录持久化在 store.data["sagas"](随世界状态落盘,宽容加载); - 幂等键登记在 store.data["sagaIdem"](跨 saga 去重 / 恢复时安全重放); - 补偿注册表 registry: {动作名: {"fn": callable(ctx)->dict, "compensation": 补偿动作名}}; - 补偿动作也是注册表条目:{补偿动作名: {"fn": callable(ctx)->dict}}。 """ def __init__(self, store, *, actor: str = "saga", registry: dict[str, dict[str, Any]] | None = None, clock: Callable[[], float] | None = None, sleep_fn: Callable[[float], None] | None = None, backoff_base: float = 0.0, backoff_cap: float = 60.0, second_approver: Any = None): self.store = store self.actor = actor self.registry = registry if registry is not None else default_registry() self.clock = clock or time.monotonic self.sleep_fn = sleep_fn or time.sleep self.backoff_base = backoff_base self.backoff_cap = backoff_cap self.second_approver = second_approver self._ensure_tables() # ---------------- 持久化(随世界状态,宽容加载) ---------------- def _ensure_tables(self) -> None: world = self.store.data sagas = world.setdefault("sagas", []) world["sagas"] = [s for s in sagas if isinstance(s, dict)] idem = world.setdefault("sagaIdem", {}) if not isinstance(idem, dict): world["sagaIdem"] = {} def _save(self) -> None: self.store.save() def _find(self, saga_id: str) -> dict[str, Any]: for s in self.store.data.get("sagas", []): if s.get("id") == saga_id: return s raise SagaNotFound(saga_id) # ---------------- 创建 ---------------- def create_saga(self, name: str, steps: list[SagaStep | dict[str, Any]], context: dict[str, Any] | None = None, *, dedupe_key: str | None = None) -> dict[str, Any]: """创建并持久化一条 saga;dedupe_key 相同(如 WMS 事件 eventId)时复用已有记录(幂等)。""" if dedupe_key: existing = next((s for s in self.store.data.get("sagas", []) if s.get("name") == name and s.get("dedupeKey") == dedupe_key), None) if existing is not None: return existing saga_id = f"saga-{uuid.uuid4().hex[:10]}" step_records: list[dict[str, Any]] = [] for raw in steps: step_records.append(raw.to_dict() if isinstance(raw, SagaStep) else SagaStep.from_mapping(dict(raw)).to_dict()) record: dict[str, Any] = { "id": saga_id, "name": name, "status": "PENDING", "createdAt": _now(), "updatedAt": _now(), "context": _jsonable(copy.deepcopy(context or {})), "dedupeKey": dedupe_key, "steps": step_records, "manualTakeover": False, "takeoverReason": None, "compensationReason": None, "auditRefs": [f"saga:{saga_id}"], } self.store.data.setdefault("sagas", []).append(record) self._audit_saga(record, "saga.created", result="PENDING") self._save() return record # ---------------- 执行 ---------------- def run(self, saga_id: str, *, auto_approve: bool = True) -> dict[str, Any]: """执行/续跑一条 saga:按序执行未完成步骤;失败自动进入补偿链。 - auto_approve=True:gate 步骤由编排器代行人工确认(测试/自动化模式); - auto_approve=False:gate 步骤停在 WAITING_HUMAN(中间态可见,人工门禁)。 """ saga = self._find(saga_id) if saga["status"] in ("SUCCEEDED", "COMPENSATED"): return saga if saga["status"] == "MANUAL_TAKEOVER" or saga.get("manualTakeover"): return saga # 人工接管中:需显式 retry 恢复自动化 saga["status"] = "RUNNING" saga["updatedAt"] = _now() self._audit_saga(saga, "saga.run", result="RUNNING") for step in saga["steps"]: if step["status"] in ("SUCCEEDED", "COMPENSATED", "SKIPPED"): continue if step.get("gate") and not auto_approve: step["status"] = "WAITING_HUMAN" saga["status"] = "WAITING_HUMAN" saga["updatedAt"] = _now() self._audit_step(saga, step, "saga.step.waiting_human", result="WAITING_HUMAN") self._save() return saga outcome = self._execute_step(saga, step, auto_approve=auto_approve) if outcome == "FAILED": self._compensate(saga, reason=f"step:{step['name']}") return saga saga["status"] = "SUCCEEDED" saga["updatedAt"] = _now() self._audit_saga(saga, "saga.succeeded", result="SUCCEEDED") self._save() return saga def retry(self, saga_id: str, *, auto_approve: bool = True) -> dict[str, Any]: """显式重试:回滚失败步的已发生副作用(写前快照),重置重试预算后重跑。 人工接管(MANUAL_TAKEOVER)后调用 retry 可恢复自动化。 """ saga = self._find(saga_id) if saga["status"] in ("SUCCEEDED", "COMPENSATED"): return saga ctx = self._ctx(saga, None) for step in saga["steps"]: if step["status"] == "FAILED": if step.get("checkpointId"): try: _restore_from_checkpoint({**ctx, "step": step}) except SagaCompensationError: pass # 快照缺失时保留现场,交由 run 决定 step["status"] = "PENDING" step["error"] = None step["attempts"] = 0 step["timeouts"] = 0 step["result"] = None step["finishedAt"] = None elif step["status"] == "WAITING_HUMAN": step["status"] = "PENDING" saga["manualTakeover"] = False saga["takeoverReason"] = None saga["compensationReason"] = None saga["status"] = "PENDING" saga["updatedAt"] = _now() self._audit_saga(saga, "saga.retry", result="RUNNING") self._save() return self.run(saga_id, auto_approve=auto_approve) def compensate(self, saga_id: str, *, reason: str = "manual") -> dict[str, Any]: """手动补偿(P2 人工动作):对已成功(或失败但留有写前快照)的步骤逆序补偿。""" saga = self._find(saga_id) if saga["status"] == "COMPENSATED": return saga self._compensate(saga, reason=reason) return saga def takeover(self, saga_id: str, *, reason: str = "manual") -> dict[str, Any]: """人工接管:停止自动执行,标记 MANUAL_TAKEOVER(可经 retry 恢复自动化)。""" saga = self._find(saga_id) if saga["status"] in _TERMINAL: return saga saga["manualTakeover"] = True saga["takeoverReason"] = reason saga["status"] = "MANUAL_TAKEOVER" saga["updatedAt"] = _now() self._audit_saga(saga, "saga.manual_takeover", result="MANUAL_TAKEOVER", extra={"reason": reason}) self._save() return saga def resume_all(self, *, auto_approve: bool = True) -> list[dict[str, Any]]: """恢复队列:重放所有未完成(非终态、非人工接管)的 saga。""" out: list[dict[str, Any]] = [] for saga in list(self.store.data.get("sagas", [])): if saga["status"] in _RECOVERABLE and not saga.get("manualTakeover"): out.append(self.run(saga["id"], auto_approve=auto_approve)) return out def list(self, limit: int | None = None) -> list[dict[str, Any]]: sagas = list(reversed(self.store.data.get("sagas", []))) return sagas if not limit else sagas[: int(limit)] def get(self, saga_id: str) -> dict[str, Any] | None: try: return self._find(saga_id) except SagaNotFound: return None # ---------------- 步骤执行 ---------------- def _execute_step(self, saga: dict[str, Any], step: dict[str, Any], *, auto_approve: bool) -> str: act = self.registry.get(step["action"]) if act is None: step["status"] = "FAILED" step["error"] = f"未注册动作 {step['action']}" step["finishedAt"] = _now() self._audit_step(saga, step, "saga.step.failed", result="FAILED") self._save() return "FAILED" # 幂等重放:同 idem_key 已被(其他/先前)saga 成功执行 → 复用记录,不重复外部效果 idem_key = step.get("idemKey") or "" if idem_key and step["status"] != "FAILED": prior = self.store.data.setdefault("sagaIdem", {}).get(idem_key) if prior and prior.get("step") == step["name"]: step["result"] = _jsonable(copy.deepcopy(prior.get("result"))) step["status"] = "SUCCEEDED" step["finishedAt"] = _now() self._audit_step(saga, step, "saga.step.idempotent_replayed", result="SUCCEEDED") self._save() return "OK" # 补偿性步骤执行前:自动建写前快照(回滚锚点) if step.get("compensationAction") and not step.get("checkpointId"): step["checkpointId"] = self._snapshot(saga, step) max_attempts = int(step.get("maxRetries") or 0) + 1 for attempt in range(max_attempts): step["status"] = "RUNNING" step["attempts"] = int(step.get("attempts") or 0) + 1 step["startedAt"] = _now() step["error"] = None started = self.clock() try: result = act["fn"](self._ctx(saga, step)) elapsed = self.clock() - started if float(step.get("timeoutSec") or 0) > 0 and elapsed > float(step["timeoutSec"]): raise SagaStepTimeout( f"步骤超时 {elapsed:.1f}s > {step['timeoutSec']}s") step["result"] = _jsonable(result if isinstance(result, dict) else {"value": result}) step["status"] = "SUCCEEDED" step["finishedAt"] = _now() if idem_key: self.store.data.setdefault("sagaIdem", {})[idem_key] = { "sagaId": saga["id"], "step": step["name"], "result": step["result"], "at": _now()} self._audit_step(saga, step, "saga.step.succeeded", result="SUCCEEDED") self._save() return "OK" except SagaStepTimeout as exc: step["timeouts"] = int(step.get("timeouts") or 0) + 1 step["error"] = f"timeout: {exc}" self._audit_step(saga, step, "saga.step.timeout", result="TIMEOUT") except Exception as exc: step["error"] = f"{type(exc).__name__}: {exc}" self._audit_step(saga, step, "saga.step.failed", result="FAILED") step["finishedAt"] = _now() if attempt < max_attempts - 1: delay = min(float(self.backoff_base) * (2 ** attempt), float(self.backoff_cap)) self._audit_step(saga, step, "saga.step.retry", result="RETRY", extra={"delaySec": delay, "attempt": step["attempts"]}) self._save() if delay > 0: self.sleep_fn(delay) continue break step["status"] = "FAILED" step["finishedAt"] = _now() self._audit_step(saga, step, "saga.step.failed", result="FAILED") self._save() return "FAILED" # ---------------- 补偿链 ---------------- def _compensate(self, saga: dict[str, Any], *, reason: str) -> None: saga["status"] = "COMPENSATING" saga["compensationReason"] = reason saga["updatedAt"] = _now() self._audit_saga(saga, "saga.compensating", result="COMPENSATING", extra={"reason": reason}) failed = False for step in reversed(saga["steps"]): if not self._compensatable(step): continue comp_action = step["compensationAction"] comp = self.registry.get(comp_action) if comp is None: step["compensation"] = {"status": "FAILED", "error": f"未注册补偿动作 {comp_action}", "at": _now()} failed = True self._audit_step(saga, step, "saga.step.compensation_failed", result="FAILED", extra={"compensation": comp_action}) self._save() break try: res = comp["fn"](self._ctx(saga, step)) step["compensation"] = { "status": "SUCCEEDED", "action": comp_action, "result": _jsonable(res if isinstance(res, dict) else {"value": res}), "at": _now(), } if step.get("idemKey"): self.store.data.setdefault("sagaIdem", {}).pop(step["idemKey"], None) self._audit_step(saga, step, "saga.step.compensated", result="SUCCEEDED", extra={"compensation": comp_action}) self._save() except Exception as exc: step["compensation"] = { "status": "FAILED", "action": comp_action, "error": f"{type(exc).__name__}: {exc}", "at": _now(), } failed = True self._audit_step(saga, step, "saga.step.compensation_failed", result="FAILED", extra={"compensation": comp_action}) self._save() break if failed: saga["status"] = "MANUAL_TAKEOVER" saga["manualTakeover"] = True saga["takeoverReason"] = f"compensation-failed: {reason}" else: saga["status"] = "COMPENSATED" saga["updatedAt"] = _now() self._audit_saga(saga, "saga.manual_takeover" if failed else "saga.compensated", result=saga["status"], extra={"reason": reason, "compensationReason": saga["compensationReason"]}) self._save() @staticmethod def _compensatable(step: dict[str, Any]) -> bool: if not step.get("compensationAction"): return False if (step.get("compensation") or {}).get("status") == "SUCCEEDED": return False # 已补偿 return step["status"] == "SUCCEEDED" or bool(step.get("checkpointId")) # ---------------- 基础设施 ---------------- def _ctx(self, saga: dict[str, Any], step: dict[str, Any] | None) -> dict[str, Any]: return { "store": self.store, "saga": saga, "step": step, "steps": {s["name"]: s for s in saga["steps"]}, "actor": self.actor, "context": saga.get("context") or {}, "coordinator": self, } def _snapshot(self, saga: dict[str, Any], step: dict[str, Any]) -> str: cps = self._checkpoint_store() pair = cps.create(self.store.data, label=f"saga:{saga['id']}:{step['name']} 前", reason="auto:saga.pre-step", conversation_note=f"Saga {saga['name']} 步骤 {step['name']} 执行前基线") return str(pair["pairId"]) def _checkpoint_store(self): cps = getattr(self.store, "checkpoints", None) if cps is None: from server.state.checkpoints import get_checkpoints cps = get_checkpoints() return cps def _audit_saga(self, saga: dict[str, Any], action: str, *, result: str = "SUCCESS", extra: dict[str, Any] | None = None) -> None: from server.agent_core.audit import write_audit write_audit(self.store.data, self.store.next_id, actor=self.actor, category="SAGA", action=action, target={"type": "SAGA", "id": saga["id"], "name": saga["name"]}, power="P1", rationale={"sagaId": saga["id"], "name": saga["name"], "status": saga["status"], **(extra or {})}, result=result, evidence_refs=list(saga.get("auditRefs") or [f"saga:{saga['id']}"])) def _audit_step(self, saga: dict[str, Any], step: dict[str, Any], action: str, *, result: str = "SUCCESS", extra: dict[str, Any] | None = None) -> None: from server.agent_core.audit import write_audit refs = list(saga.get("auditRefs") or [f"saga:{saga['id']}"]) step_ref = f"saga.step:{saga['id']}:{step['name']}" if step_ref not in refs: refs.append(step_ref) saga["auditRefs"] = refs write_audit(self.store.data, self.store.next_id, actor=self.actor, category="SAGA", action=action, target={"type": "SAGA_STEP", "sagaId": saga["id"], "step": step["name"]}, power="P1", rationale={"sagaId": saga["id"], "step": step["name"], "action": step["action"], "idemKey": step.get("idemKey"), "status": step["status"], "attempts": step.get("attempts"), "compensation": step.get("compensationAction"), **(extra or {})}, result=result, evidence_refs=refs) # ------------------------------------------------------------ # ⑥ WMS 缺料闭环 Saga 动作(动作注册表默认值) # ------------------------------------------------------------ def _act_wms_consume_event(ctx: dict[str, Any]) -> dict[str, Any]: """动作:WMS 事件消费(eventId 幂等)。""" from server.aps_domain import wms_events event = ctx["context"].get("event") or {} r = wms_events.consume_event(ctx["store"], event, actor=ctx["actor"]) if r.get("duplicate"): return {**r, "replayed": True} return r def _act_wms_evaluate_impact(ctx: dict[str, Any]) -> dict[str, Any]: """动作:缺料影响半径(沙盒只读差分)。""" from server.aps_domain import wms_events event = ctx["context"].get("event") or {} return wms_events.evaluate_shortage_impact(ctx["store"].data, event) def _act_wms_stage_solution(ctx: dict[str, Any]) -> dict[str, Any]: """动作:生成 Explore 方案卡(P2 门禁卡;确认点在下一 gate 步骤)。""" from server.aps_domain import wms_events store = ctx["store"] event = ctx["context"].get("event") or {} session_id = (ctx["context"].get("sessionId")) or "wms" return wms_events.stage_shortage_solution(store, event, session_id=session_id, actor=ctx["actor"]) def _act_flex_reschedule(ctx: dict[str, Any]) -> dict[str, Any]: """执行一次经确认的 L4 重排,并只返回本次确认实际创建的精确版本。""" from server.aps_domain.workflow import execute_confirmed store = ctx["store"] stage_res = (ctx["steps"].get("stage_solution") or {}).get("result") or {} confirm_id = str(stage_res.get("confirmId") or "") if not confirm_id: raise SagaStepFailed("缺少方案卡 confirmId(stage_solution 未成功)") before_versions = list(store.data.get("flexScheduleVersions") or []) before_ids = {str(row.get("id")) for row in before_versions} msg = execute_confirmed(store, confirm_id, approve=True, actor=ctx["actor"]) after_versions = list(store.data.get("flexScheduleVersions") or []) new_versions = [row for row in after_versions if str(row.get("id")) not in before_ids] if len(after_versions) <= len(before_versions) or len(new_versions) != 1: raise SagaStepFailed( "本次重排确认未创建唯一的新排产版本,拒绝沿用历史 DRAFT 版本:" f"confirmId={confirm_id}; message={msg}" ) version = new_versions[0] version_id = version.get("id") event_id = str((ctx.get("context", {}).get("event") or {}).get("eventId") or "") approval = next( ( event for event in reversed(store.data.get("auditEvents") or []) if event.get("action") == "flex.reschedule.approve" and str((event.get("rationale") or {}).get("confirmId") or "") == confirm_id and str((event.get("target") or {}).get("id")) == str(version_id) ), None, ) expected_event_ref = f"wms-event:{event_id}" if event_id else "" if approval is None or ( expected_event_ref and expected_event_ref not in (approval.get("evidenceRefs") or []) ): raise SagaStepFailed( "新排产版本缺少与当前确认卡/WMS 事件一致的审计证据,拒绝进入发布门禁:" f"confirmId={confirm_id}; versionId={version_id}; eventId={event_id or '-'}" ) return { "message": msg, "versionId": version_id, "versionNo": version.get("versionNo"), "confirmId": confirm_id, "eventId": event_id or None, "approvalAuditId": approval.get("id"), } def _act_schedule_publish_stage(ctx: dict[str, Any]) -> dict[str, Any]: """Stage an exact flex-version P2 publication card after rescheduling.""" from server.aps_domain.workflow import stage_schedule_publish store = ctx["store"] reschedule_result = (ctx["steps"].get("reschedule") or {}).get("result") or {} version_id = reschedule_result.get("versionId") if not version_id: raise SagaStepFailed("缺少重排版本 versionId(reschedule 未成功)") session_id = ctx["context"].get("sessionId") or "wms" reply = stage_schedule_publish( store, session_id=session_id, actor=ctx["actor"], track="flex", version_id=int(version_id), ) block = next( ( item for item in reply.blocks or [] if getattr(item, "type", None) == "confirm-card" ), None, ) confirm_id = str((getattr(block, "props", None) or {}).get("confirmId") or "") if not confirm_id: raise SagaStepFailed(f"柔性版本发布暂存失败: {reply.text}") return { "staged": True, "confirmId": confirm_id, "versionId": int(version_id), "message": reply.text, } def _act_schedule_publish(ctx: dict[str, Any]) -> dict[str, Any]: """Approve the independent P2 publication gate without touching MES.""" from server.aps_domain.workflow import execute_confirmed store = ctx["store"] stage_result = (ctx["steps"].get("stage_publish") or {}).get("result") or {} confirm_id = str(stage_result.get("confirmId") or "") version_id = stage_result.get("versionId") if not confirm_id or not version_id: raise SagaStepFailed("缺少发布确认卡或目标版本(stage_publish 未成功)") message = execute_confirmed( store, confirm_id, approve=True, actor=ctx["actor"], ) version = next( ( row for row in store.data.get("flexScheduleVersions") or [] if row.get("id") == int(version_id) ), None, ) if version is None or str(version.get("status") or "").upper() != "PUBLISHED": raise SagaStepFailed(f"柔性版本发布失败: {message}") return { "published": True, "confirmId": confirm_id, "versionId": int(version_id), "message": message, } def _act_mes_dispatch_stage(ctx: dict[str, Any]) -> dict[str, Any]: """Stage P3 MES dispatch only for the exact published reschedule version.""" from server.aps_domain import wms_events store = ctx["store"] event = ctx["context"].get("event") or {} event_id = str(event.get("eventId") or "") session_id = ctx["context"].get("sessionId") or "wms" reschedule_result = (ctx["steps"].get("reschedule") or {}).get("result") or {} expected_version_id = reschedule_result.get("versionId") versions = store.data.get("flexScheduleVersions") or [] latest = versions[-1] if versions else None if ( latest is None or latest.get("id") != expected_version_id or str(latest.get("status") or "").upper() != "PUBLISHED" ): raise SagaStepFailed("MES 下发暂存失败: 重排目标版本不是当前已发布执行基准") result = wms_events.stage_dispatch( store, event_id, session_id=session_id, actor=ctx["actor"], ) if not result.get("staged"): raise SagaStepFailed(f"MES 下发暂存失败: {result.get('message')}") staged_version_id = (result.get("validation") or {}).get("versionId") if int(staged_version_id or 0) != int(expected_version_id or 0): raise SagaStepFailed("MES dispatch staging failed: confirmation card is not bound to the rescheduled version") return result def _act_mes_dispatch(ctx: dict[str, Any]) -> dict[str, Any]: """动作(gate):P3 双人确认 → MES 下发 → 回执镜像写回审计。 失败时保留部分结果(已创建外部工单/执行凭据)供补偿链撤销。 """ from server.aps_domain import wms_events store = ctx["store"] event = ctx["context"].get("event") or {} event_id = str(event.get("eventId") or "") stage_res = (ctx["steps"].get("stage_dispatch") or {}).get("result") or {} block = stage_res.get("block") or {} confirm_id = str((block.get("props") or {}).get("confirmId") or stage_res.get("confirmId") or "") if not confirm_id: raise SagaStepFailed("缺少 MES 下发确认卡 confirmId(stage_dispatch 未成功)") step = ctx["step"] prior = (step.get("result") or {}).get("executionGrant") if isinstance(step.get("result"), dict) else None grant = prior or _p3_double_approve(ctx["coordinator"], confirm_id) reschedule_res = (ctx["steps"].get("reschedule") or {}).get("result") or {} version_id = reschedule_res.get("versionId") validation = stage_res.get("validation") or {} dispatch_evidence = [f"schedule-version:{int(version_id or 0)}"] if validation.get("evidenceRef"): dispatch_evidence.append(str(validation["evidenceRef"])) cps = ctx["coordinator"]._checkpoint_store() try: res = wms_events.execute_dispatch_receipt( store, event_id=event_id, confirm_id=confirm_id, execution_grant=grant, version_id=int(version_id or 0), before_snapshot=str(step.get("checkpointId") or ""), checkpoint_store=cps, evidence_refs=dispatch_evidence, actor=ctx["actor"]) except Exception as exc: # 保留部分执行信息(已创建的外部工单/凭据)供补偿链撤销 step["result"] = _jsonable({ "partial": True, "error": str(exc), "executionGrant": grant, "created": _external_wo_ids(store, version_id), }) ctx["coordinator"]._save() raise return {**res, "executionGrant": grant} def _p3_double_approve(coordinator: SagaCoordinator, confirm_id: str) -> str: """P3 双人职责分离确认:当前身份第一重 + approver-2 第二重 → executionGrant。""" from server.agent_core import harness from server.auth.context import IdentityContext, bind_identity, reset_identity first = harness.take_confirmation(confirm_id, approve=True) if first is None: raise SagaStepFailed("MES P3 确认卡不可用(可能已处理)") if first.get("needsSecondConfirm"): approver = coordinator.second_approver or IdentityContext( 2002, "approver-2", "Approver 2", "platform", roles=("planner",)) token = bind_identity(approver) try: second = harness.take_confirmation(confirm_id, approve=True) finally: reset_identity(token) if second is None: raise SagaStepFailed("MES P3 二次确认失败") grant = second.get("executionGrant") else: grant = first.get("executionGrant") if not grant: raise SagaStepFailed("MES P3 未获得执行凭据") return str(grant) def _external_wo_ids(store, version_id) -> list[str]: """从 mesLinks 反查某版本已下发的外部工单 ID(补偿撤销用)。""" out: list[str] = [] for link in store.data.get("mesLinks", []): if link.get("kind") != "dispatch": continue if version_id is not None and link.get("versionId") != version_id: continue if link.get("externalWoId") and link["externalWoId"] not in out: out.append(link["externalWoId"]) return out # ---------------- 补偿动作 ---------------- def _restore_from_checkpoint(ctx: dict[str, Any]) -> dict[str, Any]: """按步骤写前快照恢复世界(保留审计链与 saga 日志,回滚业务表到步骤前)。""" store = ctx["store"] step = ctx["step"] pair_id = step.get("checkpointId") if not pair_id: raise SagaCompensationError(f"步骤 {step['name']} 缺少写前快照,无法回滚") cps = ctx["coordinator"]._checkpoint_store() pair = cps.get(pair_id) if pair is None: raise SagaCompensationError(f"写前快照 {pair_id} 不存在,无法回滚") world = copy.deepcopy(pair["world"]) current = store.data for key in ("auditEvents", "sagas", "sagaIdem", "mesCancellations", "wmsReceiptVoids"): if key in current: world[key] = current[key] _replace_world(store, world) return {"pairId": pair_id, "label": pair.get("label")} def _replace_world(store, world: dict[str, Any]) -> None: if hasattr(store, "restore"): store.restore(world) else: store.data.clear() store.data.update(world) def _comp_flex_reschedule_rollback(ctx: dict[str, Any]) -> dict[str, Any]: """补偿:重排失败 → 回滚到步骤执行前版本(写前快照恢复)。""" restored = _restore_from_checkpoint(ctx) return {"worldRestore": restored, "message": "重排已回滚到步骤执行前版本"} def _comp_mes_cancel_dispatch(ctx: dict[str, Any]) -> dict[str, Any]: """补偿:MES 下发失败 → 撤销外部工单 + 回执 VOIDED + 世界回滚到下发前。""" from server.aps_domain import wms_events from server.aps_domain.mes import cancel_dispatch store = ctx["store"] step = ctx["step"] result = step.get("result") or {} external_ids = list(result.get("created") or []) version_id = (ctx["steps"].get("reschedule") or {}).get("result", {}).get("versionId") if not external_ids: external_ids = _external_wo_ids(store, version_id) cancel_res = cancel_dispatch(store, external_wo_ids=external_ids, idem_key=step.get("idemKey"), actor=ctx["actor"], reason="saga-compensation") rollback: dict[str, Any] = {} try: rollback = wms_events.rollback_receipt( store, event_id=(ctx["context"].get("event") or {}).get("eventId"), actor=ctx["actor"], reason="saga-compensation") except Exception as exc: rollback = {"error": f"{type(exc).__name__}: {exc}"} restored = _restore_from_checkpoint(ctx) return {"cancel": cancel_res, "receiptRollback": rollback, "worldRestore": restored} # ------------------------------------------------------------ # 默认动作注册表(动作 → 补偿动作) # ------------------------------------------------------------ def default_registry() -> dict[str, dict[str, Any]]: return { "wms.consume_event": {"fn": _act_wms_consume_event, "compensation": None}, "wms.evaluate_impact": {"fn": _act_wms_evaluate_impact, "compensation": None}, "wms.stage_solution": {"fn": _act_wms_stage_solution, "compensation": None}, "flex.reschedule": { "fn": _act_flex_reschedule, "compensation": "flex.reschedule.rollback", }, "flex.reschedule.rollback": {"fn": _comp_flex_reschedule_rollback}, "schedule.publish.stage": {"fn": _act_schedule_publish_stage, "compensation": None}, "schedule.publish": {"fn": _act_schedule_publish, "compensation": None}, "mes.dispatch.stage": {"fn": _act_mes_dispatch_stage, "compensation": None}, "mes.dispatch": {"fn": _act_mes_dispatch, "compensation": "mes.cancel_dispatch"}, "mes.cancel_dispatch": {"fn": _comp_mes_cancel_dispatch}, } # ------------------------------------------------------------ # 便捷入口 # ------------------------------------------------------------ def get_coordinator(store, *, actor: str = "saga", registry: dict[str, dict[str, Any]] | None = None, **kwargs) -> SagaCoordinator: """按世界状态构造(或复用)Saga 编排器。""" return SagaCoordinator(store, actor=actor, registry=registry, **kwargs) def build_wms_shortage_saga(coordinator: SagaCoordinator, event: dict[str, Any], *, session_id: str = "wms", actor: str = "saga", timeout_sec: float = 30.0, max_retries: int = 2) -> dict[str, Any]: """Build the governed WMS shortage closure Saga. consume -> impact -> solution card -> P2 reschedule -> P2 publish -> P3 MES dispatch -> receipt. Publication and dispatch remain separate gates. """ event_id = str(event.get("eventId") or "") if not event_id: raise ValueError("WMS 缺料 Saga 需要事件 eventId") steps = [ SagaStep( "consume_event", "wms.consume_event", idem_key=f"wms-event:{event_id}", timeout_sec=timeout_sec, max_retries=max_retries, ), SagaStep( "evaluate_impact", "wms.evaluate_impact", idem_key=f"impact:{event_id}", timeout_sec=timeout_sec, max_retries=max_retries, ), SagaStep( "stage_solution", "wms.stage_solution", idem_key=f"stage:{event_id}", timeout_sec=timeout_sec, max_retries=max_retries, ), SagaStep( "reschedule", "flex.reschedule", idem_key=f"reschedule:{event_id}", timeout_sec=timeout_sec, max_retries=max_retries, compensation_action="flex.reschedule.rollback", gate=True, ), SagaStep( "stage_publish", "schedule.publish.stage", idem_key=f"publish-stage:{event_id}", timeout_sec=timeout_sec, max_retries=max_retries, ), SagaStep( "publish", "schedule.publish", idem_key=f"publish:{event_id}", timeout_sec=timeout_sec, max_retries=max_retries, gate=True, ), SagaStep( "stage_dispatch", "mes.dispatch.stage", idem_key=f"dispatch-stage:{event_id}", timeout_sec=timeout_sec, max_retries=max_retries, ), SagaStep( "dispatch", "mes.dispatch", idem_key=f"dispatch:{event_id}", timeout_sec=timeout_sec, max_retries=max_retries, compensation_action="mes.cancel_dispatch", gate=True, ), ] return coordinator.create_saga( "wms-shortage-closure", steps, context={"event": event, "sessionId": session_id}, dedupe_key=event_id, )