aps-agent/server/agent_core/plan_runtime.py

611 lines
25 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.

# ============================================================
# 分层 Plan 运行时(moduleId: core-plan-runtime, 可重生 ✅)
# plan.md §3.1:L0-L3 共用不可变骨架;重生只追加同 planId 的新版本。
# ============================================================
from __future__ import annotations
import copy
import hashlib
import json
import os
import tempfile
import threading
import uuid
from datetime import datetime, timezone
from typing import Any, ClassVar, Literal
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
PlanLayer = Literal["L0", "L1", "L2", "L3"]
PlanStatus = Literal["DRAFT", "APPROVED", "RUNNING", "DONE", "FAILED", "SUPERSEDED"]
PlanCreator = Literal["LLM", "USER", "SYSTEM"]
_LAYER_PARENT: dict[PlanLayer, PlanLayer | None] = {
"L0": None,
"L1": "L0",
"L2": "L1",
"L3": "L2",
}
class PlanNode(BaseModel):
"""One immutable version of an L0-L3 governance plan."""
model_config = ConfigDict(frozen=True, extra="forbid")
planId: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
layer: PlanLayer
parentId: str | None
version: int = Field(ge=1)
regenCount: int = Field(ge=0)
inputsHash: str = Field(pattern=r"^[0-9a-f]{64}$")
status: PlanStatus
payload: JsonValue
evidenceRefs: tuple[str, ...] # schema required
createdAt: str = Field(min_length=1)
createdBy: PlanCreator
@model_validator(mode="after")
def validate_parent_shape(self) -> "PlanNode":
expected = _LAYER_PARENT[self.layer]
if expected is None and self.parentId is not None:
raise ValueError("L0 plan nodes cannot have a parent")
if expected is not None and self.parentId is None:
raise ValueError(f"{self.layer} plan nodes require a {expected} parent")
return self
class PlanStoreError(RuntimeError):
code = "PLAN_STORE_ERROR"
class PlanNotFoundError(PlanStoreError):
code = "PLAN_NOT_FOUND"
class PlanConflictError(PlanStoreError):
code = "PLAN_CONFLICT"
class PlanInputMismatchError(PlanConflictError):
code = "PLAN_INPUTS_HASH_MISMATCH"
class PlanRegenerationFusedError(PlanConflictError):
code = "PLAN_REGEN_FUSED"
class PlanTransitionError(PlanStoreError):
code = "PLAN_TRANSITION_INVALID"
class PlanParentError(PlanStoreError):
code = "PLAN_PARENT_INVALID"
class PlanStoreCorruptError(PlanStoreError):
code = "PLAN_STORE_CORRUPT"
def canonical_inputs_hash(inputs: JsonValue) -> str:
"""Return a stable SHA-256 for JSON inputs, independent of object key order."""
encoded = json.dumps(
inputs,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
class PlanStore:
"""Tenant-scoped append-only PlanNode version log backed by atomic JSON writes."""
schema_version = "1.0"
def __init__(self, path: str, *, max_regen: int = 3) -> None:
if max_regen < 0:
raise ValueError("max_regen must be non-negative")
self.path = path
self.max_regen = max_regen
self._lock = threading.RLock()
self._branch_views: dict[str, dict[str, Any]] = {} # branch-anchored plan snapshots (matrix 52)
self._plans = self._load()
self._active_view: str | None = None # active branch plan view (None = no anchor)
def _load(self) -> dict[str, list[dict[str, JsonValue]]]:
try:
with open(self.path, "r", encoding="utf-8") as handle:
document = json.load(handle)
except FileNotFoundError:
return {}
except (json.JSONDecodeError, OSError) as exc:
raise PlanStoreCorruptError(f"cannot read Plan store: {exc}") from exc
if not isinstance(document, dict):
raise PlanStoreCorruptError("Plan store document must be a JSON object")
if document.get("schemaVersion") != self.schema_version or not isinstance(document.get("plans"), dict):
raise PlanStoreCorruptError("unsupported or malformed Plan store document")
loaded: dict[str, list[dict[str, JsonValue]]] = {}
try:
for plan_id, raw_versions in document["plans"].items():
if not isinstance(raw_versions, list) or not raw_versions:
raise ValueError(f"plan {plan_id!r} has no versions")
nodes = [PlanNode.model_validate(item) for item in raw_versions]
if any(node.planId != plan_id for node in nodes):
raise ValueError(f"plan key {plan_id!r} does not match stored node")
if [node.version for node in nodes] != list(range(1, len(nodes) + 1)):
raise ValueError(f"plan {plan_id!r} has a non-contiguous version history")
first = nodes[0]
if any(
(node.layer, node.parentId, node.inputsHash)
!= (first.layer, first.parentId, first.inputsHash)
for node in nodes[1:]
):
raise ValueError(f"plan {plan_id!r} changes immutable lineage fields")
regen_counts = [node.regenCount for node in nodes]
# regenCount 单调不减,且每段重生(regenCount=k)以 version 递增推进,
# 状态流转(transition_status)在同一 regenCount 下追加版本——两者均合法
if regen_counts != sorted(regen_counts):
raise ValueError(f"plan {plan_id!r} has an invalid regeneration history")
loaded[plan_id] = [node.model_dump(mode="json") for node in nodes]
except (TypeError, ValueError) as exc:
raise PlanStoreCorruptError(f"invalid Plan store history: {exc}") from exc
# 分支锚定 plan 快照(矩阵 52 行;宽容解析:坏记录跳过,不阻塞 plan 主数据加载)
raw_views = document.get("branchViews")
if raw_views is not None and not isinstance(raw_views, dict):
raise PlanStoreCorruptError("invalid Plan store branch views")
if isinstance(raw_views, dict):
for branch_id, record in raw_views.items():
if not isinstance(record, dict) or not isinstance(record.get("plans"), dict):
continue
plan_versions: dict[str, int] = {}
for plan_id, version in record["plans"].items():
try:
plan_versions[str(plan_id)] = int(version)
except (TypeError, ValueError):
continue
self._branch_views[str(branch_id)] = {
**record, "plans": plan_versions,
}
return loaded
def _write(self) -> None:
directory = os.path.dirname(self.path) or "."
os.makedirs(directory, exist_ok=True)
fd, temporary = tempfile.mkstemp(dir=directory, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(
{"schemaVersion": self.schema_version, "plans": self._plans,
"branchViews": self._branch_views},
handle,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
os.replace(temporary, self.path)
except BaseException:
if os.path.exists(temporary):
os.unlink(temporary)
raise
@staticmethod
def _copy_node(raw: dict[str, JsonValue]) -> PlanNode:
return PlanNode.model_validate(copy.deepcopy(raw))
def _latest_unlocked(self, plan_id: str) -> PlanNode:
versions = self._plans.get(plan_id)
if not versions:
raise PlanNotFoundError(f"plan {plan_id!r} does not exist")
return self._copy_node(versions[-1])
# ---------------- 分支 plan 视图(矩阵 52 行:plan 版本随分支切换回退) ----------------
def _visible_versions_unlocked(self) -> dict[str, int] | None:
"""当前分支视图可见的 plan_id -> version;None 表示无分支锚定(保持既有全局行为)。"""
if self._active_view is None:
return None
record = self._branch_views.get(self._active_view)
if record is None:
return {}
return dict(record.get("plans") or {})
def _latest_visible_unlocked(self, plan_id: str) -> PlanNode:
"""分支视图下的最新节点:视图外 plan / 超出版本范围 → PlanNotFoundError。"""
view = self._visible_versions_unlocked()
if view is None:
return self._latest_unlocked(plan_id)
version = view.get(plan_id)
versions = self._plans.get(plan_id)
if version is None or not versions or version < 1 or version > len(versions):
raise PlanNotFoundError(
f"plan {plan_id!r} is not visible in branch view {self._active_view!r}"
)
return self._copy_node(versions[version - 1])
def _view_record_unlocked(self) -> dict[str, Any] | None:
"""活动分支的视图记录(惰性建表);无分支锚定返回 None。"""
if self._active_view is None:
return None
record = self._branch_views.get(self._active_view)
if record is None:
record = {
"branchId": self._active_view,
"checkpointId": None,
"capturedAt": _now_iso(),
"plans": {},
}
self._branch_views[self._active_view] = record
record.setdefault("plans", {})
return record
def _record_view_write_unlocked(self, plan_id: str, version: int) -> None:
"""把新/流转版本记录到活动分支视图(分支内创建/流转可见且可回退)。"""
record = self._view_record_unlocked()
if record is not None:
record["plans"][plan_id] = version
def _rollback_view_write_unlocked(self, plan_id: str, old_version: int | None) -> None:
"""写盘失败时回滚活动分支视图中的版本指针。"""
if self._active_view is None:
return
record = self._branch_views.get(self._active_view)
if record is None:
return
plans = record.setdefault("plans", {})
if old_version is None:
plans.pop(plan_id, None)
else:
plans[plan_id] = old_version
def plans_snapshot(self, branch_id: str, *, checkpoint_id: str | None = None) -> dict[str, Any]:
"""捕获当前可见 plan 状态为分支锚定快照(矩阵 52 行:可审计、可回退)。
记录每个可见 plan 的当前 version;随后 restore_for_branch 可把视图回退到此状态。
返回结构稳定(branchId/checkpointId/capturedAt/plans)且持久化于 branchViews。
"""
with self._lock:
view = self._visible_versions_unlocked()
if view is None:
plan_versions = {
plan_id: len(raw)
for plan_id, raw in self._plans.items() if raw
}
else:
plan_versions = dict(view)
record: dict[str, Any] = {
"branchId": branch_id,
"checkpointId": checkpoint_id,
"capturedAt": _now_iso(),
"plans": plan_versions,
}
self._branch_views[branch_id] = record
try:
self._write()
except BaseException:
self._branch_views.pop(branch_id, None)
raise
return copy.deepcopy(record)
def restore_for_branch(self, branch_id: str, *, checkpoint_id: str | None = None,
archived: dict[str, Any] | None = None) -> dict[str, Any]:
"""分支切换时激活该分支的 plan 视图(矩阵 52 行:plan 版本随分支切换回退)。
- 已有快照 → 直接激活(含此前在该分支上创建/流转的版本);
- 无快照但提供 checkpoint 归档(conversationSide.plans: plan_id -> 版本列表)
→ 按归档末版本物化视图;
- 无任何锚定数据 → 关闭视图,保持既有全局行为(无锚定兼容)。
"""
with self._lock:
record = self._branch_views.get(branch_id)
if record is None:
if not isinstance(archived, dict):
self._active_view = None # 无锚定:不启用分支过滤
return {"branchId": branch_id, "checkpointId": checkpoint_id,
"capturedAt": None, "restored": False, "planCount": 0}
plan_versions: dict[str, int] = {}
for plan_id, versions in archived.items():
if isinstance(versions, (list, tuple)) and versions:
try:
plan_versions[str(plan_id)] = len(versions)
except (TypeError, ValueError):
continue
record = {
"branchId": branch_id,
"checkpointId": checkpoint_id,
"capturedAt": _now_iso(),
"plans": plan_versions,
"restoredFrom": "checkpoint-archive",
}
self._branch_views[branch_id] = record
try:
self._write()
except BaseException:
self._branch_views.pop(branch_id, None)
raise
self._active_view = branch_id
return {"branchId": branch_id,
"checkpointId": record.get("checkpointId"),
"capturedAt": record.get("capturedAt"),
"restored": True,
"planCount": len(record.get("plans") or {})}
def active_view(self) -> dict[str, Any] | None:
"""当前活动分支 plan 视图(None = 无锚定,全局视图)。"""
with self._lock:
if self._active_view is None:
return None
record = self._branch_views.get(self._active_view)
return copy.deepcopy(record) if record is not None else None
def plan_views(self) -> dict[str, Any]:
"""全部已持久化的分支 plan 快照(审计/诊断)。"""
with self._lock:
return copy.deepcopy(self._branch_views)
def _validate_parent_unlocked(self, layer: PlanLayer, parent_id: str | None) -> None:
expected = _LAYER_PARENT[layer]
if expected is None:
if parent_id is not None:
raise PlanParentError("L0 plan nodes cannot have a parent")
return
if parent_id is None:
raise PlanParentError(f"{layer} plan nodes require a {expected} parent")
try:
parent = self._latest_visible_unlocked(parent_id)
except PlanNotFoundError as exc:
raise PlanParentError(f"parent plan {parent_id!r} does not exist") from exc
if parent.layer != expected:
raise PlanParentError(
f"{layer} plan nodes require a {expected} parent, got {parent.layer}"
)
def create(
self,
*,
layer: PlanLayer,
parent_id: str | None,
inputs: JsonValue,
payload: JsonValue,
status: PlanStatus = "DRAFT",
evidence_refs: tuple[str, ...] | list[str] = (),
created_by: PlanCreator = "SYSTEM",
plan_id: str | None = None,
) -> PlanNode:
"""Create version 1 for a new Plan; existing planIds can never be overwritten."""
with self._lock:
resolved_id = plan_id or f"plan_{uuid.uuid4().hex}"
if resolved_id in self._plans:
raise PlanConflictError(f"plan {resolved_id!r} already exists")
self._validate_parent_unlocked(layer, parent_id)
node = PlanNode(
planId=resolved_id,
layer=layer,
parentId=parent_id,
version=1,
regenCount=0,
inputsHash=canonical_inputs_hash(inputs),
status=status,
payload=copy.deepcopy(payload),
evidenceRefs=tuple(evidence_refs),
createdAt=_now_iso(),
createdBy=created_by,
)
self._plans[resolved_id] = [node.model_dump(mode="json")]
old_view: int | None = None
if self._active_view is not None:
record = self._view_record_unlocked()
old_view = record["plans"].get(resolved_id)
self._record_view_write_unlocked(resolved_id, 1)
try:
self._write()
except BaseException:
del self._plans[resolved_id]
self._rollback_view_write_unlocked(resolved_id, old_view)
raise
return node.model_copy(deep=True)
def regenerate(
self,
plan_id: str,
*,
inputs: JsonValue,
expected_inputs_hash: str,
payload: JsonValue,
status: PlanStatus = "DRAFT",
evidence_refs: tuple[str, ...] | list[str] | None = None,
created_by: PlanCreator = "SYSTEM",
) -> PlanNode:
"""Append a new version after hash validation; never mutate previous versions."""
with self._lock:
latest = self._latest_visible_unlocked(plan_id)
actual_hash = canonical_inputs_hash(inputs)
if expected_inputs_hash != actual_hash:
raise PlanInputMismatchError("expectedInputsHash does not match the supplied inputs")
if actual_hash != latest.inputsHash:
raise PlanInputMismatchError("regeneration inputs differ from the latest Plan version")
if latest.regenCount >= self.max_regen:
raise PlanRegenerationFusedError(
f"plan {plan_id!r} reached the regeneration limit ({self.max_regen})"
)
node = PlanNode(
planId=latest.planId,
layer=latest.layer,
parentId=latest.parentId,
version=latest.version + 1,
regenCount=latest.regenCount + 1,
inputsHash=latest.inputsHash,
status=status,
payload=copy.deepcopy(payload),
evidenceRefs=(latest.evidenceRefs if evidence_refs is None else tuple(evidence_refs)),
createdAt=_now_iso(),
createdBy=created_by,
)
self._plans[plan_id].append(node.model_dump(mode="json"))
old_view: int | None = None
if self._active_view is not None:
record = self._view_record_unlocked()
old_view = record["plans"].get(plan_id)
self._record_view_write_unlocked(plan_id, node.version)
try:
self._write()
except BaseException:
self._plans[plan_id].pop()
self._rollback_view_write_unlocked(plan_id, old_view)
raise
return node.model_copy(deep=True)
# 状态流转:DRAFT -> APPROVED / DRAFT -> FAILED(驳回)/ RUNNING / DONE
_ALLOWED_TRANSITIONS: ClassVar[dict[PlanStatus, frozenset[PlanStatus]]] = {
"DRAFT": frozenset({"APPROVED", "FAILED", "RUNNING", "SUPERSEDED"}),
"RUNNING": frozenset({"DONE", "FAILED", "APPROVED"}),
"APPROVED": frozenset({"DONE", "RUNNING", "SUPERSEDED"}),
"FAILED": frozenset({"DRAFT", "RUNNING", "SUPERSEDED"}),
"DONE": frozenset({"SUPERSEDED"}),
"SUPERSEDED": frozenset(),
}
def transition_status(
self,
plan_id: str,
*,
new_status: PlanStatus,
created_by: PlanCreator = "USER",
note: str = "",
) -> PlanNode:
"""Plan 状态流转(矩阵 110 行:批准/驳回审计)。
追加新版本(version+1,regenCount 不变,inputsHash/payload 不变)承载状态变化,
保证历史不可变;非法流转抛 PlanTransitionError。
Returns: 新状态版本节点。
"""
with self._lock:
latest = self._latest_visible_unlocked(plan_id)
allowed = self._ALLOWED_TRANSITIONS.get(latest.status, frozenset())
if new_status not in allowed:
raise PlanTransitionError(
f"plan {plan_id!r} status transition {latest.status} -> {new_status} not allowed"
)
payload = copy.deepcopy(latest.payload)
if note:
payload.setdefault("_note", []).append({
"status": new_status, "by": created_by, "note": note,
})
node = PlanNode(
planId=latest.planId,
layer=latest.layer,
parentId=latest.parentId,
version=latest.version + 1,
regenCount=latest.regenCount,
inputsHash=latest.inputsHash,
status=new_status,
payload=payload,
evidenceRefs=(latest.evidenceRefs + (f"status:{new_status}",)),
createdAt=_now_iso(),
createdBy=created_by,
)
self._plans[plan_id].append(node.model_dump(mode="json"))
old_view: int | None = None
if self._active_view is not None:
record = self._view_record_unlocked()
old_view = record["plans"].get(plan_id)
self._record_view_write_unlocked(plan_id, node.version)
try:
self._write()
except BaseException:
self._plans[plan_id].pop()
self._rollback_view_write_unlocked(plan_id, old_view)
raise
return node.model_copy(deep=True)
def latest(self, plan_id: str) -> PlanNode:
with self._lock:
return self._latest_visible_unlocked(plan_id)
def node_at(self, plan_id: str, version: int) -> PlanNode:
"""时间线回放:取指定版本的不可变节点(矩阵 110 行)。
版本号必须落在历史范围内;任何版本内容均不可变。
"""
with self._lock:
view = self._visible_versions_unlocked()
if view is not None and view.get(plan_id) is None:
raise PlanNotFoundError(
f"plan {plan_id!r} is not visible in branch view {self._active_view!r}"
)
versions = self._plans.get(plan_id)
if not versions:
raise PlanNotFoundError(f"plan {plan_id!r} does not exist")
max_version = len(versions)
if view is not None:
max_version = min(max_version, view[plan_id])
if version < 1 or version > max_version:
raise PlanNotFoundError(f"plan {plan_id!r} has no version {version}")
return self._copy_node(versions[version - 1])
def replay_verify(self, plan_id: str, version: int, inputs: JsonValue) -> PlanNode:
"""L2 单节点/L3 动作级重放校验(矩阵 110 行)。
给定输入重新计算 inputsHash,与指定版本的 inputsHash 比对:
一致则返回该版本节点(可复算重放的先决);不一致抛 PlanInputMismatchError。
"""
node = self.node_at(plan_id, version)
actual_hash = canonical_inputs_hash(inputs)
if actual_hash != node.inputsHash:
raise PlanInputMismatchError(
f"plan {plan_id!r} v{version} inputsHash mismatch: "
f"expected {node.inputsHash}, got {actual_hash}"
)
return node
def versions(self, plan_id: str) -> list[PlanNode]:
with self._lock:
view = self._visible_versions_unlocked()
if view is not None and view.get(plan_id) is None:
raise PlanNotFoundError(
f"plan {plan_id!r} is not visible in branch view {self._active_view!r}"
)
versions = self._plans.get(plan_id)
if not versions:
raise PlanNotFoundError(f"plan {plan_id!r} does not exist")
if view is not None:
versions = versions[:view[plan_id]]
return [self._copy_node(raw) for raw in versions]
_plan_stores: dict[tuple[str, str], PlanStore] = {}
_plan_stores_lock = threading.Lock()
def get_plan_store() -> PlanStore:
"""Return the append-only Plan store for the current tenant and project world."""
from server.auth.context import get_identity
from server.state.store import get_store
identity = get_identity()
world = get_store()
key = (identity.tenant_uuid, world.world_key)
with _plan_stores_lock:
store = _plan_stores.get(key)
if store is None:
path = os.path.join(os.path.dirname(world.path) or ".", "plans.json")
max_regen = int(os.environ.get("APS_PLAN_REGEN_LIMIT") or "3")
store = PlanStore(path, max_regen=max_regen)
_plan_stores[key] = store
return store
def reset_plan_stores() -> None:
"""Clear scoped singleton instances; intended for process reconfiguration and tests."""
with _plan_stores_lock:
_plan_stores.clear()