specunode 0.1.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.
- specunode/__init__.py +94 -0
- specunode/api.py +170 -0
- specunode/buffer/__init__.py +0 -0
- specunode/buffer/dispatcher.py +171 -0
- specunode/buffer/idempotency.py +160 -0
- specunode/buffer/store_buffer.py +1031 -0
- specunode/canonical.py +184 -0
- specunode/cli.py +712 -0
- specunode/config.py +279 -0
- specunode/core/__init__.py +0 -0
- specunode/core/branch.py +439 -0
- specunode/core/decision.py +171 -0
- specunode/core/effects.py +271 -0
- specunode/core/graph.py +248 -0
- specunode/core/hazards.py +254 -0
- specunode/core/loop.py +107 -0
- specunode/core/model.py +1964 -0
- specunode/core/policy.py +242 -0
- specunode/core/scheduler.py +2873 -0
- specunode/core/state.py +842 -0
- specunode/drafters/__init__.py +0 -0
- specunode/drafters/base.py +89 -0
- specunode/drafters/t0_stream.py +87 -0
- specunode/drafters/t1_pattern.py +255 -0
- specunode/drafters/t2_model.py +164 -0
- specunode/ids.py +97 -0
- specunode/integrations/__init__.py +0 -0
- specunode/integrations/anthropic.py +325 -0
- specunode/integrations/langgraph.py +328 -0
- specunode/integrations/mcp_proxy.py +713 -0
- specunode/integrations/plain.py +225 -0
- specunode/journal/__init__.py +0 -0
- specunode/journal/entries.py +214 -0
- specunode/journal/journal.py +1260 -0
- specunode/journal/ledger.py +1560 -0
- specunode/journal/replay.py +1070 -0
- specunode/journal/schema.sql +74 -0
- specunode/py.typed +0 -0
- specunode/runner.py +121 -0
- specunode/specunode.yaml.example +123 -0
- specunode/testing/__init__.py +0 -0
- specunode/testing/faults.py +124 -0
- specunode/testing/models.py +177 -0
- specunode/testing/world.py +707 -0
- specunode/verify/__init__.py +0 -0
- specunode/verify/equivalence.py +507 -0
- specunode/verify/gate.py +49 -0
- specunode/verify/witness.py +216 -0
- specunode-0.1.0.dist-info/METADATA +534 -0
- specunode-0.1.0.dist-info/RECORD +53 -0
- specunode-0.1.0.dist-info/WHEEL +4 -0
- specunode-0.1.0.dist-info/entry_points.txt +2 -0
- specunode-0.1.0.dist-info/licenses/LICENSE +202 -0
specunode/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""SpecuNode -- agents that take real actions, and never take one twice.
|
|
2
|
+
|
|
3
|
+
A runtime for AI agents whose tools change the world. Every write is held until the model's
|
|
4
|
+
decision behind it is durable, claimed in a journal under a deterministic key before it is sent,
|
|
5
|
+
and never sent twice -- a crash is resumed onto the same keys, and a reply lost in a crash is
|
|
6
|
+
either asked about (``reconcile``) or handed to a human, never guessed at. Every run replays.
|
|
7
|
+
|
|
8
|
+
The public API is exposed lazily, so ``import specunode`` stays cheap and does not pull in the
|
|
9
|
+
optional integrations (LangGraph, MCP, Anthropic) until they are used::
|
|
10
|
+
|
|
11
|
+
import specunode
|
|
12
|
+
|
|
13
|
+
@specunode.tool(effect="write")
|
|
14
|
+
async def charge_card(customer_id: str, amount: float) -> dict: ...
|
|
15
|
+
|
|
16
|
+
runtime = specunode.Runtime(specunode.graph(nodes, route), tools=[charge_card])
|
|
17
|
+
result = await runtime.run(inputs)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
#: Public name -> (module, attribute). Imported on first use.
|
|
28
|
+
_EXPORTS: dict[str, tuple[str, str]] = {
|
|
29
|
+
"tool": ("specunode.integrations.plain", "tool"),
|
|
30
|
+
"node": ("specunode.integrations.plain", "node"),
|
|
31
|
+
"graph": ("specunode.api", "graph"),
|
|
32
|
+
"Runtime": ("specunode.api", "Runtime"),
|
|
33
|
+
"current_idempotency_key": ("specunode.api", "current_idempotency_key"),
|
|
34
|
+
"agent_loop": ("specunode.core.loop", "agent_loop"),
|
|
35
|
+
"RunSession": ("specunode.core.graph", "RunSession"),
|
|
36
|
+
"Decision": ("specunode.core.decision", "Decision"),
|
|
37
|
+
"FreeText": ("specunode.core.decision", "FreeText"),
|
|
38
|
+
"ToolCall": ("specunode.core.decision", "ToolCall"),
|
|
39
|
+
"EffectClass": ("specunode.core.effects", "EffectClass"),
|
|
40
|
+
"Policy": ("specunode.core.policy", "Policy"),
|
|
41
|
+
"RunResult": ("specunode.core.scheduler", "RunResult"),
|
|
42
|
+
"ToolDispatchError": ("specunode.buffer.dispatcher", "ToolDispatchError"),
|
|
43
|
+
"ModelError": ("specunode.core.model", "ModelError"),
|
|
44
|
+
"TurnAbandoned": ("specunode.core.model", "TurnAbandoned"),
|
|
45
|
+
"AnthropicModel": ("specunode.integrations.anthropic", "AnthropicModel"),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"AnthropicModel",
|
|
50
|
+
"Decision",
|
|
51
|
+
"EffectClass",
|
|
52
|
+
"FreeText",
|
|
53
|
+
"ModelError",
|
|
54
|
+
"Policy",
|
|
55
|
+
"RunResult",
|
|
56
|
+
"RunSession",
|
|
57
|
+
"Runtime",
|
|
58
|
+
"ToolCall",
|
|
59
|
+
"ToolDispatchError",
|
|
60
|
+
"TurnAbandoned",
|
|
61
|
+
"__version__",
|
|
62
|
+
"agent_loop",
|
|
63
|
+
"current_idempotency_key",
|
|
64
|
+
"graph",
|
|
65
|
+
"node",
|
|
66
|
+
"tool",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING: # the same names, for type checkers and editors
|
|
70
|
+
from specunode.api import Runtime, current_idempotency_key, graph
|
|
71
|
+
from specunode.buffer.dispatcher import ToolDispatchError
|
|
72
|
+
from specunode.core.decision import Decision, FreeText, ToolCall
|
|
73
|
+
from specunode.core.effects import EffectClass
|
|
74
|
+
from specunode.core.graph import RunSession
|
|
75
|
+
from specunode.core.loop import agent_loop
|
|
76
|
+
from specunode.core.model import ModelError, TurnAbandoned
|
|
77
|
+
from specunode.core.policy import Policy
|
|
78
|
+
from specunode.core.scheduler import RunResult
|
|
79
|
+
from specunode.integrations.anthropic import AnthropicModel
|
|
80
|
+
from specunode.integrations.plain import node, tool
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def __getattr__(name: str) -> Any:
|
|
84
|
+
try:
|
|
85
|
+
module, attribute = _EXPORTS[name]
|
|
86
|
+
except KeyError:
|
|
87
|
+
raise AttributeError(f"module 'specunode' has no attribute {name!r}") from None
|
|
88
|
+
value = getattr(importlib.import_module(module), attribute)
|
|
89
|
+
globals()[name] = value
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def __dir__() -> list[str]:
|
|
94
|
+
return sorted({*globals(), *_EXPORTS})
|
specunode/api.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The front door: declare tools and nodes, build a graph, run it, resume it.
|
|
2
|
+
|
|
3
|
+
Every piece here exists elsewhere in the package. What this module adds is the wiring every
|
|
4
|
+
first run needs and nobody should have to write -- a journal, a store buffer, a dispatcher and a
|
|
5
|
+
scheduler, assembled with every safety check left on -- so that a run is::
|
|
6
|
+
|
|
7
|
+
import specunode
|
|
8
|
+
|
|
9
|
+
@specunode.tool(effect="write")
|
|
10
|
+
async def charge_card(customer_id: str, amount: float) -> dict: ...
|
|
11
|
+
|
|
12
|
+
@specunode.node()
|
|
13
|
+
async def bill(session: specunode.RunSession) -> specunode.Decision: ...
|
|
14
|
+
|
|
15
|
+
runtime = specunode.Runtime(specunode.graph([bill], route), tools=[charge_card])
|
|
16
|
+
result = await runtime.run({"customer_id": "cus-1"})
|
|
17
|
+
# killed half way? await runtime.resume(result.run_id)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import AsyncIterator, Callable, Iterable, Mapping, Sequence
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from specunode.buffer.dispatcher import Dispatcher
|
|
28
|
+
from specunode.buffer.store_buffer import StoreBuffer
|
|
29
|
+
from specunode.canonical import JsonValue
|
|
30
|
+
from specunode.core.effects import ToolRegistry
|
|
31
|
+
from specunode.core.graph import GraphAdapter
|
|
32
|
+
from specunode.core.model import (
|
|
33
|
+
JournaledModel,
|
|
34
|
+
ModelClient,
|
|
35
|
+
ModelError,
|
|
36
|
+
ModelResponse,
|
|
37
|
+
RequestEnvelope,
|
|
38
|
+
StreamEvent,
|
|
39
|
+
call_scope,
|
|
40
|
+
)
|
|
41
|
+
from specunode.core.policy import Policy
|
|
42
|
+
from specunode.core.scheduler import RunResult, Scheduler
|
|
43
|
+
from specunode.ids import new_ulid
|
|
44
|
+
from specunode.integrations.plain import PlainAdapter, registry_of
|
|
45
|
+
from specunode.journal.journal import Journal, is_postgres_dsn
|
|
46
|
+
|
|
47
|
+
__all__ = ["Runtime", "current_idempotency_key", "graph"]
|
|
48
|
+
|
|
49
|
+
#: Where a run's journal lives unless told otherwise: beside the code that runs it.
|
|
50
|
+
DEFAULT_JOURNAL = Path(".specunode") / "journal.db"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def graph(
|
|
54
|
+
nodes: Iterable[Callable[..., Any]],
|
|
55
|
+
route: Callable[[Mapping[str, JsonValue]], str | Sequence[str] | None],
|
|
56
|
+
) -> PlainAdapter:
|
|
57
|
+
"""A graph from decorated node functions and a router.
|
|
58
|
+
|
|
59
|
+
The router is handed the committed state and returns the next node's name, a list of names
|
|
60
|
+
to run side by side and retire in that order, or ``None`` to end the run.
|
|
61
|
+
"""
|
|
62
|
+
return PlainAdapter.of(nodes, route)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def current_idempotency_key() -> str:
|
|
66
|
+
"""The key the runtime is dispatching this call under. Pass it to the upstream.
|
|
67
|
+
|
|
68
|
+
Deterministic: the same call at the same point of the same run derives the same key on a
|
|
69
|
+
resume or a replay, which is what lets an upstream that honours idempotency keys -- and a
|
|
70
|
+
tool's ``reconcile`` -- recognise a request it has already seen. Only defined inside a tool
|
|
71
|
+
while the runtime is dispatching it.
|
|
72
|
+
"""
|
|
73
|
+
scope = call_scope.get()
|
|
74
|
+
key = scope.effect_key if scope is not None else ""
|
|
75
|
+
if not key:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"current_idempotency_key() is only defined inside a tool the runtime is "
|
|
78
|
+
"dispatching; a read, or a call made outside a run, has no key"
|
|
79
|
+
)
|
|
80
|
+
return key
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class _NoModel:
|
|
85
|
+
"""The target of a runtime built without a model: fine until something asks it."""
|
|
86
|
+
|
|
87
|
+
def _refuse(self) -> ModelError:
|
|
88
|
+
return ModelError(
|
|
89
|
+
"this Runtime was built without a model, and a node asked one; pass model=..."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def complete(self, envelope: RequestEnvelope) -> ModelResponse:
|
|
93
|
+
raise self._refuse()
|
|
94
|
+
|
|
95
|
+
async def stream(self, envelope: RequestEnvelope) -> AsyncIterator[StreamEvent]:
|
|
96
|
+
raise self._refuse()
|
|
97
|
+
yield # type: ignore[unreachable] # pragma: no cover - makes this an async generator
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Runtime:
|
|
102
|
+
"""A graph, its tools and a journal: everything a run and its resume need.
|
|
103
|
+
|
|
104
|
+
``tools`` is a list of functions decorated with ``@specunode.tool``, or a registry.
|
|
105
|
+
``model`` is the model the graph's nodes ask; a graph whose nodes never ask one needs none.
|
|
106
|
+
``journal`` is a path to a SQLite file (created if absent) or a ``Journal``.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
graph: GraphAdapter
|
|
110
|
+
tools: ToolRegistry | Sequence[Callable[..., Any]] = ()
|
|
111
|
+
model: ModelClient | None = None
|
|
112
|
+
journal: Journal | str | Path = DEFAULT_JOURNAL
|
|
113
|
+
#: Guessing is off: it needs a drafter, and without one it only adds bookkeeping to every
|
|
114
|
+
#: receipt. Issuing a read the moment its block parses is not guessing, and stays on.
|
|
115
|
+
policy: Policy = field(default_factory=lambda: Policy(speculation=False))
|
|
116
|
+
reducers: Mapping[str, str] = field(default_factory=dict)
|
|
117
|
+
#: Attempts per effect before it is dead-lettered, and the first backoff between them.
|
|
118
|
+
max_attempts: int = 3
|
|
119
|
+
base_delay_ms: float = 50.0
|
|
120
|
+
|
|
121
|
+
def _journal(self) -> Journal:
|
|
122
|
+
if isinstance(self.journal, Journal):
|
|
123
|
+
return self.journal
|
|
124
|
+
if is_postgres_dsn(self.journal):
|
|
125
|
+
# Not through ``Path``, which folds the DSN's ``//`` into ``/`` and made a SQLite
|
|
126
|
+
# file of it -- in a folder named ``postgresql:``.
|
|
127
|
+
return Journal(str(self.journal))
|
|
128
|
+
path = Path(self.journal)
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
return Journal(path)
|
|
131
|
+
|
|
132
|
+
def _registry(self) -> ToolRegistry:
|
|
133
|
+
if isinstance(self.tools, ToolRegistry):
|
|
134
|
+
return self.tools
|
|
135
|
+
return registry_of(self.tools)
|
|
136
|
+
|
|
137
|
+
def scheduler(self) -> Scheduler:
|
|
138
|
+
"""A scheduler wired to this runtime's journal -- one per run or resume."""
|
|
139
|
+
journal = self._journal()
|
|
140
|
+
registry = self._registry()
|
|
141
|
+
return Scheduler(
|
|
142
|
+
graph=self.graph,
|
|
143
|
+
registry=registry,
|
|
144
|
+
journal=journal,
|
|
145
|
+
buffer=StoreBuffer(journal=journal, run_id=""),
|
|
146
|
+
dispatcher=Dispatcher(
|
|
147
|
+
registry=registry,
|
|
148
|
+
max_attempts=self.max_attempts,
|
|
149
|
+
base_delay_ms=self.base_delay_ms,
|
|
150
|
+
),
|
|
151
|
+
target=JournaledModel(self.model or _NoModel(), journal),
|
|
152
|
+
policy=self.policy,
|
|
153
|
+
reducers=dict(self.reducers),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def run(
|
|
157
|
+
self, inputs: Mapping[str, JsonValue] | None = None, *, run_id: str | None = None
|
|
158
|
+
) -> RunResult:
|
|
159
|
+
"""Run the graph to the end. ``result.run_id`` is what :meth:`resume` takes."""
|
|
160
|
+
return await self.scheduler().run(run_id or new_ulid(), dict(inputs or {}))
|
|
161
|
+
|
|
162
|
+
async def resume(self, run_id: str, *, ask_abandoned: bool = False) -> RunResult:
|
|
163
|
+
"""Continue a run a crash interrupted, without sending again what already went out.
|
|
164
|
+
|
|
165
|
+
``ask_abandoned``: where a node keeps waiting on a turn the crashed run had stopped
|
|
166
|
+
waiting for, the model is asked again, live, at the point the node would be stopped
|
|
167
|
+
with ``TurnAbandoned`` -- that turn only, and not one the node stops waiting for again
|
|
168
|
+
as the crashed run did. Its answer may differ from what was acted on.
|
|
169
|
+
"""
|
|
170
|
+
return await self.scheduler().resume(run_id, ask_abandoned=ask_abandoned)
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Dispatch: at-least-once, with deterministic idempotency keys.
|
|
2
|
+
|
|
3
|
+
The docs say "at-least-once dispatch with deterministic idempotency keys" and never claim
|
|
4
|
+
"exactly-once", because the only thing that makes a repeated delivery harmless is the *tool*
|
|
5
|
+
honouring its key -- and whether it does is the developer's word, measured in attack 7.4
|
|
6
|
+
rather than assumed. The vocabulary test fails the build on an unqualified claim to the
|
|
7
|
+
contrary, including one made here.
|
|
8
|
+
|
|
9
|
+
Backoff is exponential and **unjittered by default**. Jitter is the usual advice and it is
|
|
10
|
+
wrong here: the chaos matrix has to reproduce a failure it found, and a random delay makes a
|
|
11
|
+
leak that appeared once unreproducible.
|
|
12
|
+
|
|
13
|
+
Every failure reports whether the request *left the process*. That single bit is what keeps
|
|
14
|
+
the ambiguous crash window narrow: a connection refused before any bytes went out is safe to
|
|
15
|
+
retry, while a timeout after the request was sent is the two-generals boundary and is treated
|
|
16
|
+
as such.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
from specunode.canonical import JsonValue
|
|
27
|
+
from specunode.core.effects import ToolRegistry, UnknownTool
|
|
28
|
+
from specunode.core.model import CallScope, call_scope
|
|
29
|
+
|
|
30
|
+
__all__ = ["DispatchOutcome", "Dispatcher", "ToolDispatchError"]
|
|
31
|
+
|
|
32
|
+
Sent = Literal["no", "maybe"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolDispatchError(RuntimeError):
|
|
36
|
+
"""A dispatch attempt failed.
|
|
37
|
+
|
|
38
|
+
``sent`` is the load-bearing field. ``"no"`` means nothing left this process, so a retry
|
|
39
|
+
cannot duplicate anything. ``"maybe"`` -- the default, because it is the safe assumption --
|
|
40
|
+
means the upstream may already have acted.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, sent: Sent = "maybe", retriable: bool = True) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.sent: Sent = sent
|
|
46
|
+
self.retriable = retriable
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class DispatchOutcome:
|
|
51
|
+
ok: bool
|
|
52
|
+
ack: JsonValue = None
|
|
53
|
+
attempts: int = 0
|
|
54
|
+
error: str | None = None
|
|
55
|
+
sent: Sent = "no"
|
|
56
|
+
#: True when the dispatcher was asked not to call the tool. Carried through to the journal
|
|
57
|
+
#: and the ledger, because a row that reads DISPATCHED for an effect that never left is
|
|
58
|
+
#: exactly the kind of untrue record this project exists to make impossible.
|
|
59
|
+
dry_run: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Dispatcher:
|
|
64
|
+
"""Sends a staged effect to the world, with bounded retries."""
|
|
65
|
+
|
|
66
|
+
registry: ToolRegistry
|
|
67
|
+
max_attempts: int = 5
|
|
68
|
+
base_delay_ms: float = 50.0
|
|
69
|
+
cap_delay_ms: float = 5000.0
|
|
70
|
+
#: Off by default so a chaos run can reproduce what it found. Turn it on in production,
|
|
71
|
+
#: where a thundering herd matters more than reproducibility.
|
|
72
|
+
jitter: bool = False
|
|
73
|
+
#: Never call the tool; report the effect as it would have been sent.
|
|
74
|
+
#:
|
|
75
|
+
#: This exists for ``specunode replay``. Replaying a run against its journal re-drives the
|
|
76
|
+
#: graph, and a graph that re-drives dispatches -- so a replay of a run that charged a card
|
|
77
|
+
#: would charge it again, from a command whose whole purpose is to answer a question about
|
|
78
|
+
#: the past. The default there is therefore to dispatch nothing, and sending for real is
|
|
79
|
+
#: behind a flag that names what it is for.
|
|
80
|
+
dry_run: bool = False
|
|
81
|
+
|
|
82
|
+
def _delay_ms(self, attempt: int) -> float:
|
|
83
|
+
return float(min(self.base_delay_ms * (2.0 ** (attempt - 1)), self.cap_delay_ms))
|
|
84
|
+
|
|
85
|
+
async def dispatch(
|
|
86
|
+
self,
|
|
87
|
+
tool: str,
|
|
88
|
+
args: dict[str, JsonValue],
|
|
89
|
+
*,
|
|
90
|
+
idempotency_key: str,
|
|
91
|
+
branch_id: str,
|
|
92
|
+
before_attempt: Callable[[], Awaitable[None]] | None = None,
|
|
93
|
+
) -> DispatchOutcome:
|
|
94
|
+
"""Call ``tool``, retrying until it acks or the attempts run out.
|
|
95
|
+
|
|
96
|
+
A retry is only ever safe for one of two reasons: the attempt that failed demonstrably
|
|
97
|
+
sent nothing (``sent="no"``), or the tool declared a repeat harmless (``idempotent``).
|
|
98
|
+
After a failure that may have taken effect, a tool that did not declare it is not
|
|
99
|
+
called again -- the outcome is reported as ``sent="maybe"``, and the store buffer asks
|
|
100
|
+
the upstream (the tool's ``reconcile``) or dead-letters it. Retrying anyway charged a
|
|
101
|
+
card twice whenever a gateway took the charge and timed out on the reply, with the
|
|
102
|
+
default settings and no crash at all.
|
|
103
|
+
|
|
104
|
+
``sent`` on a failed outcome is ``"maybe"`` if *any* attempt may have taken effect, not
|
|
105
|
+
whatever the last one said: an attempt that landed followed by one that was refused is
|
|
106
|
+
still an effect that may be out.
|
|
107
|
+
|
|
108
|
+
The call runs under a :class:`CallScope` carrying the branch and the key, which is how
|
|
109
|
+
the fake world attributes every mutation -- and therefore how the leak test can state
|
|
110
|
+
its invariant as a set comparison.
|
|
111
|
+
|
|
112
|
+
``before_attempt`` runs before every attempt, and stops the dispatch by raising: the
|
|
113
|
+
store buffer checks there that this process still holds the run.
|
|
114
|
+
"""
|
|
115
|
+
spec = self.registry.get(tool)
|
|
116
|
+
if self.dry_run:
|
|
117
|
+
# Resolved above, so an unknown tool is still an error here rather than something
|
|
118
|
+
# a dry run silently reports as fine and a real one then fails on.
|
|
119
|
+
return DispatchOutcome(
|
|
120
|
+
ok=True, ack={"dry_run": True}, attempts=0, sent="no", dry_run=True
|
|
121
|
+
)
|
|
122
|
+
last_error: str | None = None
|
|
123
|
+
maybe_sent = False
|
|
124
|
+
|
|
125
|
+
for attempt in range(1, self.max_attempts + 1):
|
|
126
|
+
if before_attempt is not None:
|
|
127
|
+
await before_attempt()
|
|
128
|
+
scope = CallScope(branch_id=branch_id, effect_key=idempotency_key, speculative=False)
|
|
129
|
+
token = call_scope.set(scope)
|
|
130
|
+
retriable = True
|
|
131
|
+
try:
|
|
132
|
+
ack = await spec.fn(**args)
|
|
133
|
+
return DispatchOutcome(ok=True, ack=ack, attempts=attempt, sent="maybe")
|
|
134
|
+
except UnknownTool as exc:
|
|
135
|
+
# Not retriable and not ambiguous: there was nothing to call.
|
|
136
|
+
return DispatchOutcome(
|
|
137
|
+
ok=False,
|
|
138
|
+
attempts=attempt,
|
|
139
|
+
error=str(exc),
|
|
140
|
+
sent="maybe" if maybe_sent else "no",
|
|
141
|
+
)
|
|
142
|
+
except ToolDispatchError as exc:
|
|
143
|
+
last_error, retriable = str(exc), exc.retriable
|
|
144
|
+
# Anything but a plain "no" may have landed -- fail safe on a value the tool
|
|
145
|
+
# got wrong, rather than retrying a write because it did not say "maybe".
|
|
146
|
+
maybe_sent = maybe_sent or exc.sent != "no"
|
|
147
|
+
except asyncio.CancelledError:
|
|
148
|
+
# A drain is never speculative, so a cancellation here is the process going
|
|
149
|
+
# away rather than a squash. Do not swallow it.
|
|
150
|
+
raise
|
|
151
|
+
except Exception as exc: # an adapter that raised something of its own
|
|
152
|
+
last_error, maybe_sent = f"{type(exc).__name__}: {exc}", True
|
|
153
|
+
finally:
|
|
154
|
+
call_scope.reset(token)
|
|
155
|
+
|
|
156
|
+
if not retriable or (maybe_sent and not spec.idempotent):
|
|
157
|
+
return DispatchOutcome(
|
|
158
|
+
ok=False,
|
|
159
|
+
attempts=attempt,
|
|
160
|
+
error=last_error,
|
|
161
|
+
sent="maybe" if maybe_sent else "no",
|
|
162
|
+
)
|
|
163
|
+
if attempt < self.max_attempts:
|
|
164
|
+
await asyncio.sleep(self._delay_ms(attempt) / 1000.0)
|
|
165
|
+
|
|
166
|
+
return DispatchOutcome(
|
|
167
|
+
ok=False,
|
|
168
|
+
attempts=self.max_attempts,
|
|
169
|
+
error=last_error,
|
|
170
|
+
sent="maybe" if maybe_sent else "no",
|
|
171
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Idempotency keys: one preimage, three derivations (Hard Rule 8).
|
|
2
|
+
|
|
3
|
+
key = blake2b(run_id | branch_lineage | node_id | step_index | tool_name | canonical(args))
|
|
4
|
+
|
|
5
|
+
The spec's formula, with the framing made explicit: the preimage is a canonical JSON *object*,
|
|
6
|
+
never a concatenation, because ``("ab", "c")`` and ``("a", "bc")`` concatenate to the same
|
|
7
|
+
bytes and two different effects must never share a key.
|
|
8
|
+
|
|
9
|
+
Three values come out of that one preimage, and confusing them is the subtle failure this
|
|
10
|
+
module exists to prevent:
|
|
11
|
+
|
|
12
|
+
``key`` -- lineage-bearing, internal
|
|
13
|
+
Indexes the store buffer, names a :class:`StagedEffect`, and appears in the journal and the
|
|
14
|
+
ledger. The lineage is what keeps two siblings' staged rows apart *before* resolution:
|
|
15
|
+
they occupy the same ``step_index`` and the same ``node_id`` by construction, so without it
|
|
16
|
+
their entries alias and discarding the loser would drop the winner's row.
|
|
17
|
+
|
|
18
|
+
``nkey`` -- lineage-free, run-scoped
|
|
19
|
+
The dedupe table's primary key, and the idempotency token handed to the tool adapter. It
|
|
20
|
+
must be lineage-free because the same logical effect legitimately arrives down different
|
|
21
|
+
paths: a stalled branch's effect is discarded and re-staged by the sequential re-execution
|
|
22
|
+
under a new branch id, and a resume re-mints branch ids entirely. Keyed on ``key``, every
|
|
23
|
+
one of those would re-dispatch -- a second charge on the same card.
|
|
24
|
+
|
|
25
|
+
``ekey`` -- lineage-free *and* run-free, comparison only
|
|
26
|
+
Used by :func:`~specunode.verify.equivalence.normalise_for_equivalence` and nowhere else.
|
|
27
|
+
It carries a different domain prefix so that it cannot be mistaken for a dispatch key even
|
|
28
|
+
if it were passed somewhere one was expected.
|
|
29
|
+
|
|
30
|
+
Lineage contributes no uniqueness among *dispatched* effects, because at most one branch per
|
|
31
|
+
program position ever retires. That is exactly why stripping it is sound for dedupe and for
|
|
32
|
+
equivalence, and exactly why it must stay in ``key``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections.abc import Mapping, Sequence
|
|
38
|
+
from hashlib import blake2b
|
|
39
|
+
from typing import NewType
|
|
40
|
+
|
|
41
|
+
from specunode.canonical import JsonValue, canonical
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"EQUIV_DOMAIN",
|
|
45
|
+
"KEY_DOMAIN",
|
|
46
|
+
"DedupeKey",
|
|
47
|
+
"EquivalenceKey",
|
|
48
|
+
"IdempotencyKey",
|
|
49
|
+
"dedupe_key",
|
|
50
|
+
"equivalence_key",
|
|
51
|
+
"idempotency_key",
|
|
52
|
+
"key_preimage",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
#: Domain separators, so a key can never collide with a journal entry hash or a ledger
|
|
56
|
+
#: signature preimage even when the same bytes are fed to all three.
|
|
57
|
+
KEY_DOMAIN = b"specunode/idempotency/v1\x00"
|
|
58
|
+
EQUIV_DOMAIN = b"specunode/equiv-key/v1\x00"
|
|
59
|
+
|
|
60
|
+
IdempotencyKey = NewType("IdempotencyKey", str)
|
|
61
|
+
DedupeKey = NewType("DedupeKey", str)
|
|
62
|
+
EquivalenceKey = NewType("EquivalenceKey", str)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def key_preimage(
|
|
66
|
+
*,
|
|
67
|
+
run_id: str,
|
|
68
|
+
lineage: Sequence[str],
|
|
69
|
+
node_id: str,
|
|
70
|
+
step_index: int,
|
|
71
|
+
tool_name: str,
|
|
72
|
+
args: Mapping[str, JsonValue],
|
|
73
|
+
) -> bytes:
|
|
74
|
+
"""The canonical bytes every key derivation hashes.
|
|
75
|
+
|
|
76
|
+
Public, because the property tests and ``docs/replay.md`` assert on these exact bytes: a
|
|
77
|
+
key nobody can reproduce by hand is a key nobody can audit.
|
|
78
|
+
"""
|
|
79
|
+
return canonical(
|
|
80
|
+
{
|
|
81
|
+
"run_id": run_id,
|
|
82
|
+
"lineage": list(lineage),
|
|
83
|
+
"node_id": node_id,
|
|
84
|
+
"step_index": step_index,
|
|
85
|
+
"tool": tool_name,
|
|
86
|
+
"args": args,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _digest(domain: bytes, preimage: bytes) -> str:
|
|
92
|
+
return blake2b(domain + preimage, digest_size=32).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def idempotency_key(
|
|
96
|
+
*,
|
|
97
|
+
run_id: str,
|
|
98
|
+
lineage: Sequence[str],
|
|
99
|
+
node_id: str,
|
|
100
|
+
step_index: int,
|
|
101
|
+
tool_name: str,
|
|
102
|
+
args: Mapping[str, JsonValue],
|
|
103
|
+
) -> IdempotencyKey:
|
|
104
|
+
"""Hard Rule 8's key, verbatim. Internal: indexes the store buffer, never leaves."""
|
|
105
|
+
return IdempotencyKey(
|
|
106
|
+
_digest(
|
|
107
|
+
KEY_DOMAIN,
|
|
108
|
+
key_preimage(
|
|
109
|
+
run_id=run_id,
|
|
110
|
+
lineage=lineage,
|
|
111
|
+
node_id=node_id,
|
|
112
|
+
step_index=step_index,
|
|
113
|
+
tool_name=tool_name,
|
|
114
|
+
args=args,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def dedupe_key(
|
|
121
|
+
*,
|
|
122
|
+
run_id: str,
|
|
123
|
+
node_id: str,
|
|
124
|
+
step_index: int,
|
|
125
|
+
tool_name: str,
|
|
126
|
+
args: Mapping[str, JsonValue],
|
|
127
|
+
) -> DedupeKey:
|
|
128
|
+
"""The value the dedupe table and the tool adapter see. Lineage-free by construction."""
|
|
129
|
+
return DedupeKey(
|
|
130
|
+
_digest(
|
|
131
|
+
KEY_DOMAIN,
|
|
132
|
+
key_preimage(
|
|
133
|
+
run_id=run_id,
|
|
134
|
+
lineage=(),
|
|
135
|
+
node_id=node_id,
|
|
136
|
+
step_index=step_index,
|
|
137
|
+
tool_name=tool_name,
|
|
138
|
+
args=args,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def equivalence_key(
|
|
145
|
+
*, node_id: str, step_index: int, tool_name: str, args: Mapping[str, JsonValue]
|
|
146
|
+
) -> EquivalenceKey:
|
|
147
|
+
"""Comparison only (Hard Rule 9). Run-free, lineage-free, and never dispatchable."""
|
|
148
|
+
return EquivalenceKey(
|
|
149
|
+
_digest(
|
|
150
|
+
EQUIV_DOMAIN,
|
|
151
|
+
key_preimage(
|
|
152
|
+
run_id="",
|
|
153
|
+
lineage=(),
|
|
154
|
+
node_id=node_id,
|
|
155
|
+
step_index=step_index,
|
|
156
|
+
tool_name=tool_name,
|
|
157
|
+
args=args,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
)
|