hypermind 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Plan / PlanDAG — typed plan nodes with Toulmin-shape edges.
|
|
2
|
+
|
|
3
|
+
A `PlanDAG` is a directed acyclic graph of `PlanNode`s. Edge kinds reuse
|
|
4
|
+
the Toulmin/IBIS shape from :mod:`hypermind.consult` (``supports``,
|
|
5
|
+
``rebuts``, ``undercuts``, ``qualifies``) and add ``depends_on`` for
|
|
6
|
+
ordering — so a plan can both express data-flow precedence AND carry
|
|
7
|
+
the deliberation argument structure that supports/rebuts each step.
|
|
8
|
+
|
|
9
|
+
`Plan.to_workflow()` compiles a DAG into a runnable workflow body,
|
|
10
|
+
walking the topological order and issuing one ``wf.step`` per node.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from hypermind.consult import ARGUMENT_EDGE_KINDS
|
|
20
|
+
|
|
21
|
+
PlanNodeKind = Literal["goal", "action", "branch", "wait", "fanout", "fanin"]
|
|
22
|
+
_VALID_PLAN_NODE_KINDS: frozenset[str] = frozenset(
|
|
23
|
+
{"goal", "action", "branch", "wait", "fanout", "fanin"}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Plan edges extend Toulmin with ``depends_on`` (precedence) so the same
|
|
27
|
+
# edge type carries both data-flow and argument structure.
|
|
28
|
+
PlanEdgeKind = Literal["supports", "rebuts", "undercuts", "qualifies", "depends_on"]
|
|
29
|
+
_VALID_PLAN_EDGE_KINDS: frozenset[str] = frozenset(set(ARGUMENT_EDGE_KINDS) | {"depends_on"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PlanNode:
|
|
34
|
+
node_id: str
|
|
35
|
+
kind: PlanNodeKind
|
|
36
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
depends_on: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.kind not in _VALID_PLAN_NODE_KINDS:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"unknown PlanNode kind {self.kind!r}; valid: {sorted(_VALID_PLAN_NODE_KINDS)}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PlanEdge:
|
|
48
|
+
src: str
|
|
49
|
+
dst: str
|
|
50
|
+
kind: PlanEdgeKind
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
if self.kind not in _VALID_PLAN_EDGE_KINDS:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"unknown PlanEdge kind {self.kind!r}; valid: {sorted(_VALID_PLAN_EDGE_KINDS)}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class PlanDAG:
|
|
61
|
+
"""A directed acyclic graph of plan nodes.
|
|
62
|
+
|
|
63
|
+
Cycles are rejected by :meth:`validate` (called automatically by
|
|
64
|
+
:meth:`topological`).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
nodes: dict[str, PlanNode] = field(default_factory=dict)
|
|
68
|
+
edges: list[PlanEdge] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
def add_node(self, node: PlanNode) -> None:
|
|
71
|
+
if node.node_id in self.nodes:
|
|
72
|
+
raise ValueError(f"duplicate node id {node.node_id!r}")
|
|
73
|
+
self.nodes[node.node_id] = node
|
|
74
|
+
|
|
75
|
+
def add_edge(
|
|
76
|
+
self,
|
|
77
|
+
src: str,
|
|
78
|
+
dst: str,
|
|
79
|
+
kind: PlanEdgeKind = "depends_on",
|
|
80
|
+
) -> None:
|
|
81
|
+
if src not in self.nodes:
|
|
82
|
+
raise KeyError(f"unknown src node {src!r}")
|
|
83
|
+
if dst not in self.nodes:
|
|
84
|
+
raise KeyError(f"unknown dst node {dst!r}")
|
|
85
|
+
edge = PlanEdge(src=src, dst=dst, kind=kind)
|
|
86
|
+
self.edges.append(edge)
|
|
87
|
+
# Mirror depends_on into the node attr for convenience.
|
|
88
|
+
if kind == "depends_on":
|
|
89
|
+
node = self.nodes[dst]
|
|
90
|
+
if src not in node.depends_on:
|
|
91
|
+
node.depends_on.append(src)
|
|
92
|
+
|
|
93
|
+
def validate(self) -> None:
|
|
94
|
+
"""Detect cycles via Kahn's algorithm; raise on failure."""
|
|
95
|
+
# Use only depends_on edges for ordering (Toulmin support/rebut
|
|
96
|
+
# edges are argument structure, not data flow).
|
|
97
|
+
indeg: dict[str, int] = {nid: 0 for nid in self.nodes}
|
|
98
|
+
adj: dict[str, list[str]] = {nid: [] for nid in self.nodes}
|
|
99
|
+
for e in self.edges:
|
|
100
|
+
if e.kind != "depends_on":
|
|
101
|
+
continue
|
|
102
|
+
indeg[e.dst] += 1
|
|
103
|
+
adj[e.src].append(e.dst)
|
|
104
|
+
queue = [nid for nid, d in indeg.items() if d == 0]
|
|
105
|
+
seen = 0
|
|
106
|
+
while queue:
|
|
107
|
+
n = queue.pop(0)
|
|
108
|
+
seen += 1
|
|
109
|
+
for m in adj[n]:
|
|
110
|
+
indeg[m] -= 1
|
|
111
|
+
if indeg[m] == 0:
|
|
112
|
+
queue.append(m)
|
|
113
|
+
if seen != len(self.nodes):
|
|
114
|
+
raise ValueError("PlanDAG has a cycle")
|
|
115
|
+
|
|
116
|
+
def topological(self) -> list[PlanNode]:
|
|
117
|
+
"""Return nodes in a topological order (Kahn's, deterministic)."""
|
|
118
|
+
self.validate()
|
|
119
|
+
indeg: dict[str, int] = {nid: 0 for nid in self.nodes}
|
|
120
|
+
adj: dict[str, list[str]] = {nid: [] for nid in self.nodes}
|
|
121
|
+
for e in self.edges:
|
|
122
|
+
if e.kind != "depends_on":
|
|
123
|
+
continue
|
|
124
|
+
indeg[e.dst] += 1
|
|
125
|
+
adj[e.src].append(e.dst)
|
|
126
|
+
# Sort the zero-indegree frontier by node_id so iteration order
|
|
127
|
+
# is deterministic across runs (replay friendliness).
|
|
128
|
+
out: list[PlanNode] = []
|
|
129
|
+
queue = sorted([nid for nid, d in indeg.items() if d == 0])
|
|
130
|
+
while queue:
|
|
131
|
+
n = queue.pop(0)
|
|
132
|
+
out.append(self.nodes[n])
|
|
133
|
+
for m in sorted(adj[n]):
|
|
134
|
+
indeg[m] -= 1
|
|
135
|
+
if indeg[m] == 0:
|
|
136
|
+
queue.append(m)
|
|
137
|
+
queue.sort()
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class Plan:
|
|
143
|
+
"""A PlanDAG plus an executor mapping (node_id → coroutine).
|
|
144
|
+
|
|
145
|
+
`to_workflow()` returns an async function whose body walks the DAG
|
|
146
|
+
in topological order and issues one ``wf.step`` per node. Callers
|
|
147
|
+
decorate the returned function with ``@workflow``.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
dag: PlanDAG
|
|
151
|
+
executors: dict[str, Callable[..., Awaitable[Any]]] = field(default_factory=dict)
|
|
152
|
+
name: str = "plan"
|
|
153
|
+
|
|
154
|
+
def to_workflow(self) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
155
|
+
ordered = self.dag.topological()
|
|
156
|
+
|
|
157
|
+
async def _body(wf: Any) -> dict[str, Any]:
|
|
158
|
+
results: dict[str, Any] = {}
|
|
159
|
+
for node in ordered:
|
|
160
|
+
fn = self.executors.get(node.node_id)
|
|
161
|
+
if fn is None:
|
|
162
|
+
# Pure structural nodes (goal/branch/wait/fanout/fanin)
|
|
163
|
+
# without an executor become no-op steps that simply
|
|
164
|
+
# record their payload.
|
|
165
|
+
async def _passthrough(
|
|
166
|
+
payload: dict[str, Any] = node.payload,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
return dict(payload)
|
|
169
|
+
|
|
170
|
+
results[node.node_id] = await wf.step(
|
|
171
|
+
f"{node.kind}:{node.node_id}",
|
|
172
|
+
_passthrough,
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
results[node.node_id] = await wf.step(
|
|
176
|
+
f"{node.kind}:{node.node_id}",
|
|
177
|
+
fn,
|
|
178
|
+
)
|
|
179
|
+
return results
|
|
180
|
+
|
|
181
|
+
_body._hm_workflow_name = self.name # type: ignore[attr-defined]
|
|
182
|
+
return _body
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Plan-and-repair — replan a workflow when its claim is disputed.
|
|
2
|
+
|
|
3
|
+
A `RepairPolicy` examines a `WorkflowState` together with a
|
|
4
|
+
`DisputeOutcome` and produces a fresh `Plan` describing how to
|
|
5
|
+
recover. The default policy implements the spec §26 SHOULD-clauses:
|
|
6
|
+
|
|
7
|
+
* On ``UNRESOLVED_PARTITION``: retry from the last checkpoint with
|
|
8
|
+
widened evidence (re-run from the most recent successfully-completed
|
|
9
|
+
step that emitted a CHECKPOINT-marked record; if none, replan from
|
|
10
|
+
scratch).
|
|
11
|
+
* On ``CLOSED`` against the workflow's claim (i.e. counter-evidence
|
|
12
|
+
prevailed): emit a compensating plan that runs ``compensate()`` for
|
|
13
|
+
every committed action.
|
|
14
|
+
|
|
15
|
+
Wiring into Deliberator: :class:`RepairOnDispute` is an
|
|
16
|
+
:class:`ActionPolicy` subclass that fires when the agent observes a
|
|
17
|
+
``dispute_close`` event whose target_kid matches a workflow-tagged
|
|
18
|
+
claim.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Protocol
|
|
25
|
+
|
|
26
|
+
from hypermind.mind.policy import (
|
|
27
|
+
ActionDecision,
|
|
28
|
+
ActionKind,
|
|
29
|
+
ActionPolicy,
|
|
30
|
+
PolicyContext,
|
|
31
|
+
)
|
|
32
|
+
from hypermind.workflow.plan import Plan, PlanDAG, PlanNode
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _DisputeOutcomeLike(Protocol):
|
|
36
|
+
"""Structural shape — accepts the harness `DisputeOutcome`, the
|
|
37
|
+
simlab `DisputeOutcome`, or any duck-typed equivalent."""
|
|
38
|
+
|
|
39
|
+
final_state: str
|
|
40
|
+
target_kid: bytes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class WorkflowDisputeContext:
|
|
45
|
+
"""Bundle of inputs a repair policy reasons over."""
|
|
46
|
+
|
|
47
|
+
workflow_state: Any # WorkflowState (avoid import cycle)
|
|
48
|
+
outcome: _DisputeOutcomeLike
|
|
49
|
+
workflow_claim_kid: bytes | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RepairPolicy(Protocol):
|
|
53
|
+
"""Decide how to recover from a workflow-affecting dispute close."""
|
|
54
|
+
|
|
55
|
+
def repair(self, ctx: WorkflowDisputeContext) -> Plan: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DefaultRepairPolicy:
|
|
59
|
+
"""Spec §26 default — retry-on-partition, compensate-on-close."""
|
|
60
|
+
|
|
61
|
+
def repair(self, ctx: WorkflowDisputeContext) -> Plan:
|
|
62
|
+
outcome = ctx.outcome
|
|
63
|
+
state_name = outcome.final_state
|
|
64
|
+
wf_state = ctx.workflow_state
|
|
65
|
+
|
|
66
|
+
dag = PlanDAG()
|
|
67
|
+
if state_name == "UNRESOLVED_PARTITION":
|
|
68
|
+
# Replan: start from the last completed checkpoint.
|
|
69
|
+
checkpoint_idx = _last_checkpoint_index(wf_state)
|
|
70
|
+
dag.add_node(
|
|
71
|
+
PlanNode(
|
|
72
|
+
node_id="replan_root",
|
|
73
|
+
kind="goal",
|
|
74
|
+
payload={
|
|
75
|
+
"rule_id": "WF-REPAIR-PARTITION-RETRY",
|
|
76
|
+
"from_step_idx": checkpoint_idx,
|
|
77
|
+
"widen_evidence": True,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
dag.add_node(
|
|
82
|
+
PlanNode(
|
|
83
|
+
node_id="resume",
|
|
84
|
+
kind="action",
|
|
85
|
+
payload={
|
|
86
|
+
"rule_id": "WF-REPAIR-RESUME",
|
|
87
|
+
"workflow_id": getattr(wf_state, "workflow_id", ""),
|
|
88
|
+
},
|
|
89
|
+
depends_on=["replan_root"],
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
dag.add_edge("replan_root", "resume", "depends_on")
|
|
93
|
+
return Plan(dag=dag, name=f"repair-partition-{getattr(wf_state, 'workflow_id', '')}")
|
|
94
|
+
|
|
95
|
+
if state_name == "CLOSED":
|
|
96
|
+
# Compensating plan: one node per completed step in reverse.
|
|
97
|
+
dag.add_node(
|
|
98
|
+
PlanNode(
|
|
99
|
+
node_id="compensate_root",
|
|
100
|
+
kind="goal",
|
|
101
|
+
payload={"rule_id": "WF-REPAIR-COMPENSATE"},
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
prev = "compensate_root"
|
|
105
|
+
for i, step in enumerate(reversed(getattr(wf_state, "steps", []))):
|
|
106
|
+
if getattr(step, "status", "") != "COMPLETED":
|
|
107
|
+
continue
|
|
108
|
+
node_id = f"compensate_{i}"
|
|
109
|
+
dag.add_node(
|
|
110
|
+
PlanNode(
|
|
111
|
+
node_id=node_id,
|
|
112
|
+
kind="action",
|
|
113
|
+
payload={
|
|
114
|
+
"rule_id": "WF-REPAIR-COMPENSATE-STEP",
|
|
115
|
+
"step_name": step.step_name,
|
|
116
|
+
"step_id": step.step_id,
|
|
117
|
+
},
|
|
118
|
+
depends_on=[prev],
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
dag.add_edge(prev, node_id, "depends_on")
|
|
122
|
+
prev = node_id
|
|
123
|
+
return Plan(dag=dag, name=f"repair-close-{getattr(wf_state, 'workflow_id', '')}")
|
|
124
|
+
|
|
125
|
+
# Unknown final state — emit a single goal node so the caller
|
|
126
|
+
# at least sees a structured plan.
|
|
127
|
+
dag.add_node(
|
|
128
|
+
PlanNode(
|
|
129
|
+
node_id="noop",
|
|
130
|
+
kind="goal",
|
|
131
|
+
payload={
|
|
132
|
+
"rule_id": "WF-REPAIR-NOOP",
|
|
133
|
+
"final_state": state_name,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return Plan(dag=dag, name="repair-noop")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _last_checkpoint_index(wf_state: Any) -> int:
|
|
141
|
+
"""Return the index of the most recent step whose name starts with
|
|
142
|
+
``checkpoint`` (case-insensitive). Returns 0 when none found."""
|
|
143
|
+
steps = getattr(wf_state, "steps", [])
|
|
144
|
+
for i, step in enumerate(reversed(steps)):
|
|
145
|
+
if "checkpoint" in step.step_name.lower():
|
|
146
|
+
return len(steps) - 1 - i
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# ActionPolicy hook for Deliberator
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class RepairOnDispute(ActionPolicy):
|
|
157
|
+
"""Emit an INTENT decision to replan when a workflow-tagged dispute closes.
|
|
158
|
+
|
|
159
|
+
The Deliberator passes a :class:`PolicyContext` whose
|
|
160
|
+
``recent_observations`` may contain entries shaped::
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
"topic": ...,
|
|
164
|
+
"kind": "dispute_close",
|
|
165
|
+
"workflow_id": "abc123",
|
|
166
|
+
"final_state": "UNRESOLVED_PARTITION" | "CLOSED" | ...,
|
|
167
|
+
"target_kid": bytes,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
On a match this policy returns an :class:`ActionDecision` with
|
|
171
|
+
``kind=ActionKind.INTENT`` carrying ``rule_id="WF-REPAIR-INTENT"``
|
|
172
|
+
and the workflow_id + final_state in the payload. Downstream
|
|
173
|
+
machinery (out of scope for this policy class) is expected to call
|
|
174
|
+
:class:`DefaultRepairPolicy` to materialise the actual replan.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
policy: RepairPolicy = None # type: ignore[assignment]
|
|
178
|
+
priority: int = 70
|
|
179
|
+
|
|
180
|
+
def __post_init__(self) -> None:
|
|
181
|
+
if self.policy is None:
|
|
182
|
+
self.policy = DefaultRepairPolicy()
|
|
183
|
+
|
|
184
|
+
def decide(self, ctx: PolicyContext) -> ActionDecision | None:
|
|
185
|
+
for obs in ctx.recent_observations:
|
|
186
|
+
if obs.get("kind") != "dispute_close":
|
|
187
|
+
continue
|
|
188
|
+
wid = obs.get("workflow_id")
|
|
189
|
+
if not wid:
|
|
190
|
+
continue
|
|
191
|
+
final_state = obs.get("final_state", "")
|
|
192
|
+
return ActionDecision(
|
|
193
|
+
kind=ActionKind.INTENT,
|
|
194
|
+
rule_id="WF-REPAIR-INTENT",
|
|
195
|
+
priority=self.priority,
|
|
196
|
+
payload={
|
|
197
|
+
"workflow_id": wid,
|
|
198
|
+
"final_state": final_state,
|
|
199
|
+
"target_kid": obs.get("target_kid"),
|
|
200
|
+
},
|
|
201
|
+
reason=f"workflow {wid} dispute closed in {final_state!r}",
|
|
202
|
+
)
|
|
203
|
+
return None
|