resume-guard 1.0.0__tar.gz
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.
- resume_guard-1.0.0/LICENSE +21 -0
- resume_guard-1.0.0/PKG-INFO +47 -0
- resume_guard-1.0.0/README.md +35 -0
- resume_guard-1.0.0/pyproject.toml +16 -0
- resume_guard-1.0.0/resume_guard/__init__.py +21 -0
- resume_guard-1.0.0/resume_guard/adapters.py +60 -0
- resume_guard-1.0.0/resume_guard/audit.py +68 -0
- resume_guard-1.0.0/resume_guard/guard.py +56 -0
- resume_guard-1.0.0/resume_guard/keys.py +40 -0
- resume_guard-1.0.0/resume_guard/ledger.py +147 -0
- resume_guard-1.0.0/resume_guard/probes.py +59 -0
- resume_guard-1.0.0/resume_guard/reconciler.py +194 -0
- resume_guard-1.0.0/resume_guard/target.py +71 -0
- resume_guard-1.0.0/resume_guard.egg-info/PKG-INFO +47 -0
- resume_guard-1.0.0/resume_guard.egg-info/SOURCES.txt +18 -0
- resume_guard-1.0.0/resume_guard.egg-info/dependency_links.txt +1 -0
- resume_guard-1.0.0/resume_guard.egg-info/requires.txt +1 -0
- resume_guard-1.0.0/resume_guard.egg-info/top_level.txt +1 -0
- resume_guard-1.0.0/setup.cfg +4 -0
- resume_guard-1.0.0/setup.py +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TextForAgents LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: resume-guard
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Resume-effect reconciliation for checkpointed agent runs: checkpoint-keyed effect ledger, read-only probe, Ed25519-signed resume verdicts.
|
|
5
|
+
Author: TextForAgents LLC
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: cryptography>=41
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# resume_guard
|
|
14
|
+
|
|
15
|
+
Resume-effect reconciliation for checkpointed agent runs — by TextForAgents LLC.
|
|
16
|
+
|
|
17
|
+
Before a checkpointed agent run resumes, ask what already landed. An effect
|
|
18
|
+
ledger keyed to checkpoint identity, a read-only probe of the target system
|
|
19
|
+
when the ledger is silent, and an Ed25519-signed verdict — SAFE /
|
|
20
|
+
RESUME-WITH-SKIPS / DO-NOT-RESUME — so a resumed node never re-sends the paid
|
|
21
|
+
call, re-books the booking, or records an approval against work the human
|
|
22
|
+
never saw.
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from resume_guard import EffectLedger, TargetSystem, Reconciler, GuardedEffects
|
|
28
|
+
|
|
29
|
+
ledger = EffectLedger("effects.db")
|
|
30
|
+
target = TargetSystem("target.json")
|
|
31
|
+
rec = Reconciler(ledger, {"paid_job": target})
|
|
32
|
+
guard = GuardedEffects(ledger, rec)
|
|
33
|
+
|
|
34
|
+
receipt, how = guard.run_effect("run-1", "ns-1", "book_and_pay",
|
|
35
|
+
"submit-job", "paid_job",
|
|
36
|
+
lambda eid: target.submit(eid, {"job": "gpu-job-1"}))
|
|
37
|
+
# kill the process here; on resume the same call returns ("...", "injected")
|
|
38
|
+
# and the effect function is never called again.
|
|
39
|
+
verdict = rec.verdict("run-1", "ns-1", "book_and_pay",
|
|
40
|
+
[{"effect_key": "submit-job", "kind": "paid_job"}])
|
|
41
|
+
assert verdict["verdict"]["state"] == "RESUME-WITH-SKIPS"
|
|
42
|
+
assert Reconciler.verify(verdict)
|
|
43
|
+
print("exactly one execution:", target.submit_calls == 1)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Hosted service: Solo $9/mo per operator (first month free, one-time trial) ·
|
|
47
|
+
Team $29/mo · per-verdict API $0.005 (x402). Support: support@textforagents.com
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# resume_guard
|
|
2
|
+
|
|
3
|
+
Resume-effect reconciliation for checkpointed agent runs — by TextForAgents LLC.
|
|
4
|
+
|
|
5
|
+
Before a checkpointed agent run resumes, ask what already landed. An effect
|
|
6
|
+
ledger keyed to checkpoint identity, a read-only probe of the target system
|
|
7
|
+
when the ledger is silent, and an Ed25519-signed verdict — SAFE /
|
|
8
|
+
RESUME-WITH-SKIPS / DO-NOT-RESUME — so a resumed node never re-sends the paid
|
|
9
|
+
call, re-books the booking, or records an approval against work the human
|
|
10
|
+
never saw.
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from resume_guard import EffectLedger, TargetSystem, Reconciler, GuardedEffects
|
|
16
|
+
|
|
17
|
+
ledger = EffectLedger("effects.db")
|
|
18
|
+
target = TargetSystem("target.json")
|
|
19
|
+
rec = Reconciler(ledger, {"paid_job": target})
|
|
20
|
+
guard = GuardedEffects(ledger, rec)
|
|
21
|
+
|
|
22
|
+
receipt, how = guard.run_effect("run-1", "ns-1", "book_and_pay",
|
|
23
|
+
"submit-job", "paid_job",
|
|
24
|
+
lambda eid: target.submit(eid, {"job": "gpu-job-1"}))
|
|
25
|
+
# kill the process here; on resume the same call returns ("...", "injected")
|
|
26
|
+
# and the effect function is never called again.
|
|
27
|
+
verdict = rec.verdict("run-1", "ns-1", "book_and_pay",
|
|
28
|
+
[{"effect_key": "submit-job", "kind": "paid_job"}])
|
|
29
|
+
assert verdict["verdict"]["state"] == "RESUME-WITH-SKIPS"
|
|
30
|
+
assert Reconciler.verify(verdict)
|
|
31
|
+
print("exactly one execution:", target.submit_calls == 1)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Hosted service: Solo $9/mo per operator (first month free, one-time trial) ·
|
|
35
|
+
Team $29/mo · per-verdict API $0.005 (x402). Support: support@textforagents.com
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "resume-guard"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Resume-effect reconciliation for checkpointed agent runs: checkpoint-keyed effect ledger, read-only probe, Ed25519-signed resume verdicts."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["cryptography>=41"]
|
|
13
|
+
authors = [{name = "TextForAgents LLC"}]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
include = ["resume_guard*"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Resume-effect reconciliation (EXP-023, P23) — production build.
|
|
2
|
+
|
|
3
|
+
Effect ledger keyed to checkpoint identity + signed resume verdicts.
|
|
4
|
+
"""
|
|
5
|
+
from .ledger import EffectLedger, derive_effect_id
|
|
6
|
+
from .target import TargetSystem, TargetUnreachable, TargetAmbiguous
|
|
7
|
+
from .reconciler import Reconciler, TIER_PROBE_CAPS
|
|
8
|
+
from .guard import GuardedEffects
|
|
9
|
+
from .audit import audit_journal
|
|
10
|
+
from .probes import IdempotencyProbe, DictStoreProbe, HttpReadOnlyProbe
|
|
11
|
+
from .adapters import LangGraphAdapter, CopilotJournalReader
|
|
12
|
+
from .keys import generate_key, load_key, pinned_set, key_id_for
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"EffectLedger", "derive_effect_id", "TargetSystem",
|
|
16
|
+
"TargetUnreachable", "TargetAmbiguous", "Reconciler",
|
|
17
|
+
"TIER_PROBE_CAPS", "GuardedEffects", "audit_journal",
|
|
18
|
+
"IdempotencyProbe", "DictStoreProbe", "HttpReadOnlyProbe",
|
|
19
|
+
"LangGraphAdapter", "CopilotJournalReader",
|
|
20
|
+
"generate_key", "load_key", "pinned_set", "key_id_for",
|
|
21
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Per-framework adapters (packet 04 hardening item 2).
|
|
2
|
+
|
|
3
|
+
Adapters translate framework checkpoint shapes into the framework-
|
|
4
|
+
neutral core's (run_id, checkpoint_ns, node) identity. No framework
|
|
5
|
+
types and no framework verdict logic live in the core; adapters hold
|
|
6
|
+
only shape translation.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LangGraphAdapter:
|
|
12
|
+
"""LangGraph checkpointer hook shape (proven by harness_langgraph.py).
|
|
13
|
+
|
|
14
|
+
LangGraph checkpoints carry config.configurable.thread_id and
|
|
15
|
+
checkpoint metadata; a checkpointer hook receives (config,
|
|
16
|
+
checkpoint) pairs. This adapter extracts the neutral identity
|
|
17
|
+
from those plain dict shapes — it imports nothing from langgraph.
|
|
18
|
+
"""
|
|
19
|
+
def identity_from_config(self, config, node):
|
|
20
|
+
cfg = (config or {}).get("configurable", {})
|
|
21
|
+
run_id = cfg.get("thread_id") or cfg.get("run_id") or ""
|
|
22
|
+
ns = cfg.get("checkpoint_ns") or ""
|
|
23
|
+
if not run_id:
|
|
24
|
+
raise ValueError("langgraph config missing thread_id")
|
|
25
|
+
return run_id, ns, node
|
|
26
|
+
|
|
27
|
+
def identity_from_checkpoint(self, checkpoint, node):
|
|
28
|
+
ck = checkpoint or {}
|
|
29
|
+
run_id = ck.get("id") or ck.get("thread_id") or ""
|
|
30
|
+
ns = ck.get("checkpoint_ns") or ck.get("ns") or ""
|
|
31
|
+
if not run_id:
|
|
32
|
+
raise ValueError("checkpoint missing id")
|
|
33
|
+
return str(run_id), str(ns), node
|
|
34
|
+
|
|
35
|
+
def declared_from_checkpoint(self, checkpoint, node):
|
|
36
|
+
"""Effects a node declared, read from checkpoint channel values."""
|
|
37
|
+
vals = (checkpoint or {}).get("channel_values", {}) or {}
|
|
38
|
+
raw = vals.get("declared_effects", [])
|
|
39
|
+
return [{"effect_key": e["effect_key"], "kind": e["kind"]}
|
|
40
|
+
for e in raw if e.get("node", node) == node]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CopilotJournalReader:
|
|
44
|
+
"""Copilot dynamic-workflow journal reader: parses a journal
|
|
45
|
+
(JSON-lines text, list of dicts, or file-like lines) into the
|
|
46
|
+
journal-completeness audit's [{node, effect_key, kind}] shape."""
|
|
47
|
+
def read(self, journal):
|
|
48
|
+
entries = []
|
|
49
|
+
if isinstance(journal, str):
|
|
50
|
+
lines = [ln for ln in journal.splitlines() if ln.strip()]
|
|
51
|
+
items = [json.loads(ln) for ln in lines]
|
|
52
|
+
else:
|
|
53
|
+
items = list(journal)
|
|
54
|
+
for it in items:
|
|
55
|
+
node = it.get("node") or it.get("step") or it.get("name")
|
|
56
|
+
key = it.get("effect_key") or it.get("key") or it.get("idempotency_key")
|
|
57
|
+
kind = it.get("kind") or it.get("effect_kind") or "paid_job"
|
|
58
|
+
if node and key:
|
|
59
|
+
entries.append({"node": node, "effect_key": key, "kind": kind})
|
|
60
|
+
return entries
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Journal-completeness audit — the crash-side inverse (CARD-022
|
|
2
|
+
evidence 2): a runtime refused resume because the run never settled
|
|
3
|
+
at a controlled checkpoint, even though its journal held complete
|
|
4
|
+
records of the steps that finished.
|
|
5
|
+
|
|
6
|
+
The audit certifies each journaled step independently: a step is
|
|
7
|
+
certified iff its effect is proven landed by the ledger or by a
|
|
8
|
+
target probe. A signed certificate is issued only when EVERY step
|
|
9
|
+
certifies; otherwise the report names the uncertified steps and no
|
|
10
|
+
certificate is issued (partial credit is reported, never certified).
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _canonical(obj):
|
|
20
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def audit_journal(ledger, targets, run_id, checkpoint_ns, journal,
|
|
24
|
+
signing_key=None):
|
|
25
|
+
"""journal: [{node, effect_key, kind}] — steps the killed run's
|
|
26
|
+
journal claims completed. Returns a report dict; 'certificate'
|
|
27
|
+
is present only on full certification."""
|
|
28
|
+
key = signing_key or Ed25519PrivateKey.generate()
|
|
29
|
+
steps = []
|
|
30
|
+
all_ok = True
|
|
31
|
+
for entry in journal:
|
|
32
|
+
eid_row = ledger.get_by_identity(run_id, checkpoint_ns,
|
|
33
|
+
entry["node"],
|
|
34
|
+
entry["effect_key"])
|
|
35
|
+
status = "uncertified"
|
|
36
|
+
evidence = None
|
|
37
|
+
if eid_row and eid_row["status"] == "LANDED":
|
|
38
|
+
status, evidence = "certified", "ledger:landed"
|
|
39
|
+
else:
|
|
40
|
+
target = targets.get(entry["kind"])
|
|
41
|
+
if target is not None:
|
|
42
|
+
try:
|
|
43
|
+
from .ledger import derive_effect_id
|
|
44
|
+
eid = derive_effect_id(run_id, checkpoint_ns,
|
|
45
|
+
entry["node"],
|
|
46
|
+
entry["effect_key"])
|
|
47
|
+
if target.lookup(eid) is not None:
|
|
48
|
+
status, evidence = "certified", "probe:landed"
|
|
49
|
+
except Exception as e:
|
|
50
|
+
evidence = "probe_failed: %s" % e
|
|
51
|
+
if evidence is None:
|
|
52
|
+
evidence = "no evidence of landing"
|
|
53
|
+
if status != "certified":
|
|
54
|
+
all_ok = False
|
|
55
|
+
steps.append({"node": entry["node"],
|
|
56
|
+
"effect_key": entry["effect_key"],
|
|
57
|
+
"status": status, "evidence": evidence})
|
|
58
|
+
report = {"run_id": run_id, "checkpoint_ns": checkpoint_ns,
|
|
59
|
+
"audited": time.time(), "steps": steps,
|
|
60
|
+
"certified": all_ok}
|
|
61
|
+
if all_ok:
|
|
62
|
+
sig = key.sign(_canonical(report))
|
|
63
|
+
from .keys import key_id_for
|
|
64
|
+
pub = key.public_key().public_bytes_raw()
|
|
65
|
+
report["certificate"] = {
|
|
66
|
+
"signature": sig.hex(), "key_id": key_id_for(pub),
|
|
67
|
+
"public_key": pub.hex()}
|
|
68
|
+
return report
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Wrapper SDK: the code a node actually calls.
|
|
2
|
+
|
|
3
|
+
run_effect() is the whole integration surface:
|
|
4
|
+
- First execution: claim INTENT (durable) -> execute -> record
|
|
5
|
+
LANDED (durable) -> return the fresh result.
|
|
6
|
+
- On resume (node re-executes from its first line, per LangGraph
|
|
7
|
+
semantics): the ledger already holds LANDED, so the recorded
|
|
8
|
+
result is injected and the effect function is NEVER called again.
|
|
9
|
+
- Concurrent duplicate resume: the atomic intent claim means only
|
|
10
|
+
one caller executes; the other reconciles against the ledger /
|
|
11
|
+
probe instead of executing.
|
|
12
|
+
"""
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from .ledger import derive_effect_id
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EffectAlreadyInFlight(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GuardedEffects:
|
|
23
|
+
def __init__(self, ledger, reconciler=None):
|
|
24
|
+
self.ledger = ledger
|
|
25
|
+
self.reconciler = reconciler
|
|
26
|
+
|
|
27
|
+
def run_effect(self, run_id, checkpoint_ns, node, effect_key, kind,
|
|
28
|
+
fn, wait_seconds=5.0):
|
|
29
|
+
eid = derive_effect_id(run_id, checkpoint_ns, node, effect_key)
|
|
30
|
+
row = self.ledger.get(eid)
|
|
31
|
+
if row and row["status"] == "LANDED":
|
|
32
|
+
return row["result"], "injected"
|
|
33
|
+
eid2, existing = self.ledger.claim_intent(
|
|
34
|
+
run_id, checkpoint_ns, node, effect_key, kind)
|
|
35
|
+
if existing is not None:
|
|
36
|
+
if existing["status"] == "LANDED":
|
|
37
|
+
return existing["result"], "injected"
|
|
38
|
+
# Another caller holds INTENT. Two cases look identical
|
|
39
|
+
# here — a LIVE concurrent resume mid-execution, and a
|
|
40
|
+
# crash between intent and landed — and they demand
|
|
41
|
+
# opposite handling, so the guard never executes on
|
|
42
|
+
# probe-absence (audit fix, EXP-023 session 1: a
|
|
43
|
+
# probe-takeover path let 6 of 8 concurrent resumes
|
|
44
|
+
# execute). Wait for the live holder to land; if nobody
|
|
45
|
+
# lands it, raise — crash recovery is the reconciler's
|
|
46
|
+
# verdict decision, not a guard takeover.
|
|
47
|
+
deadline = time.time() + wait_seconds
|
|
48
|
+
while time.time() < deadline:
|
|
49
|
+
row = self.ledger.get(eid)
|
|
50
|
+
if row and row["status"] == "LANDED":
|
|
51
|
+
return row["result"], "injected"
|
|
52
|
+
time.sleep(0.01)
|
|
53
|
+
raise EffectAlreadyInFlight(eid)
|
|
54
|
+
result = fn(eid)
|
|
55
|
+
self.ledger.record_landed(eid, result)
|
|
56
|
+
return result, "executed"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Production signing-key management (packet 04 hardening item 1).
|
|
2
|
+
|
|
3
|
+
The production Ed25519 private key is generated on the droplet and
|
|
4
|
+
held ONLY in the product's own env/secrets file (mode 0600) as
|
|
5
|
+
RESUME_GUARD_SIGNING_KEY_HEX, together with RESUME_GUARD_KEY_ID.
|
|
6
|
+
Rotation: generate a new key, move the old {key_id: public_key_hex}
|
|
7
|
+
into RESUME_GUARD_PREVIOUS_KEYS (JSON), and operators pin by key_id —
|
|
8
|
+
verdicts signed under a retired key-id still verify against the
|
|
9
|
+
pinned set, and a key that silently changes fails verification
|
|
10
|
+
because the pinned public key will not match.
|
|
11
|
+
"""
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def key_id_for(public_bytes):
|
|
20
|
+
return "ed25519-" + hashlib.sha256(public_bytes).hexdigest()[:16]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_key():
|
|
24
|
+
key = Ed25519PrivateKey.generate()
|
|
25
|
+
pub = key.public_key().public_bytes_raw()
|
|
26
|
+
return {"private_key_hex": key.private_bytes_raw().hex(),
|
|
27
|
+
"public_key_hex": pub.hex(), "key_id": key_id_for(pub)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_key(private_key_hex):
|
|
31
|
+
key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(private_key_hex))
|
|
32
|
+
pub = key.public_key().public_bytes_raw()
|
|
33
|
+
return key, key_id_for(pub), pub.hex()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pinned_set(current_key_id, current_public_hex, previous_json=""):
|
|
37
|
+
pinned = {current_key_id: current_public_hex}
|
|
38
|
+
if previous_json:
|
|
39
|
+
pinned.update(json.loads(previous_json))
|
|
40
|
+
return pinned
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Effect ledger keyed to checkpoint identity.
|
|
2
|
+
|
|
3
|
+
Durability is the point: the framework default (async checkpoint
|
|
4
|
+
writes) is exactly what loses the record that a paid call happened
|
|
5
|
+
(CARD-022, 3/3 in the repro). This ledger uses sqlite WAL with
|
|
6
|
+
synchronous=FULL, and every state transition is committed before the
|
|
7
|
+
caller proceeds — INTENT is durable *before* the effect executes,
|
|
8
|
+
LANDED is durable before the node continues.
|
|
9
|
+
|
|
10
|
+
Concurrency shape (audit fix, EXP-023 session 1): resumes happen in
|
|
11
|
+
DIFFERENT processes in real deployments, so the database file — not
|
|
12
|
+
an in-process object — is the shared state. Every operation opens
|
|
13
|
+
its own connection (WAL + busy_timeout serializes writers at the DB
|
|
14
|
+
level), and the effects PRIMARY KEY is the atomic claim. The first
|
|
15
|
+
implementation shared one connection across threads; the stress
|
|
16
|
+
harness showed statement-level crosstalk on the shared connection
|
|
17
|
+
(InterfaceError, and a loser's connection-wide rollback() deleting
|
|
18
|
+
the winner's uncommitted INTENT — 86/200 trials double-executed).
|
|
19
|
+
Per-operation connections: 0/200.
|
|
20
|
+
"""
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import sqlite3
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def derive_effect_id(run_id, checkpoint_ns, node, effect_key):
|
|
28
|
+
raw = "|".join([run_id, checkpoint_ns, node, effect_key])
|
|
29
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LedgerCorrupt(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EffectLedger:
|
|
37
|
+
def __init__(self, path):
|
|
38
|
+
self.path = path
|
|
39
|
+
try:
|
|
40
|
+
db = self._connect()
|
|
41
|
+
try:
|
|
42
|
+
db.execute(
|
|
43
|
+
"CREATE TABLE IF NOT EXISTS effects ("
|
|
44
|
+
" effect_id TEXT PRIMARY KEY,"
|
|
45
|
+
" run_id TEXT, checkpoint_ns TEXT, node TEXT,"
|
|
46
|
+
" effect_key TEXT, kind TEXT, status TEXT,"
|
|
47
|
+
" result_json TEXT, source TEXT, updated REAL)")
|
|
48
|
+
db.execute(
|
|
49
|
+
"CREATE TABLE IF NOT EXISTS checkpoints ("
|
|
50
|
+
" run_id TEXT, checkpoint_ns TEXT,"
|
|
51
|
+
" discovery_pin TEXT, updated REAL,"
|
|
52
|
+
" PRIMARY KEY (run_id, checkpoint_ns))")
|
|
53
|
+
db.commit()
|
|
54
|
+
finally:
|
|
55
|
+
db.close()
|
|
56
|
+
except sqlite3.DatabaseError as e:
|
|
57
|
+
raise LedgerCorrupt(str(e)) from e
|
|
58
|
+
|
|
59
|
+
def _connect(self):
|
|
60
|
+
db = sqlite3.connect(self.path, timeout=10.0)
|
|
61
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
62
|
+
db.execute("PRAGMA synchronous=FULL")
|
|
63
|
+
db.execute("PRAGMA busy_timeout=10000")
|
|
64
|
+
return db
|
|
65
|
+
|
|
66
|
+
def close(self):
|
|
67
|
+
pass # per-operation connections; nothing held open
|
|
68
|
+
|
|
69
|
+
# -- checkpoint discovery pin (EXCHANGE-034) ----------------------
|
|
70
|
+
def pin_discovery(self, run_id, checkpoint_ns, discovery_pin):
|
|
71
|
+
db = self._connect()
|
|
72
|
+
try:
|
|
73
|
+
db.execute(
|
|
74
|
+
"INSERT OR REPLACE INTO checkpoints VALUES (?,?,?,?)",
|
|
75
|
+
(run_id, checkpoint_ns, discovery_pin, time.time()))
|
|
76
|
+
db.commit()
|
|
77
|
+
finally:
|
|
78
|
+
db.close()
|
|
79
|
+
|
|
80
|
+
def discovery_pin(self, run_id, checkpoint_ns):
|
|
81
|
+
db = self._connect()
|
|
82
|
+
try:
|
|
83
|
+
cur = db.execute(
|
|
84
|
+
"SELECT discovery_pin FROM checkpoints WHERE run_id=? AND checkpoint_ns=?",
|
|
85
|
+
(run_id, checkpoint_ns))
|
|
86
|
+
row = cur.fetchone()
|
|
87
|
+
return row[0] if row else None
|
|
88
|
+
finally:
|
|
89
|
+
db.close()
|
|
90
|
+
|
|
91
|
+
# -- effect lifecycle ----------------------------------------------
|
|
92
|
+
def claim_intent(self, run_id, checkpoint_ns, node, effect_key, kind):
|
|
93
|
+
"""Atomically claim the right to execute this effect.
|
|
94
|
+
|
|
95
|
+
Returns (effect_id, existing_row_or_None). The PRIMARY KEY
|
|
96
|
+
insert on a fresh connection IS the claim: exactly one
|
|
97
|
+
caller (thread or process) wins it; everyone else gets the
|
|
98
|
+
existing row and must NOT execute.
|
|
99
|
+
"""
|
|
100
|
+
eid = derive_effect_id(run_id, checkpoint_ns, node, effect_key)
|
|
101
|
+
db = self._connect()
|
|
102
|
+
try:
|
|
103
|
+
try:
|
|
104
|
+
db.execute(
|
|
105
|
+
"INSERT INTO effects VALUES (?,?,?,?,?,?, 'INTENT', NULL, 'ledger', ?)",
|
|
106
|
+
(eid, run_id, checkpoint_ns, node, effect_key, kind,
|
|
107
|
+
time.time()))
|
|
108
|
+
db.commit()
|
|
109
|
+
return eid, None
|
|
110
|
+
except sqlite3.IntegrityError:
|
|
111
|
+
db.rollback()
|
|
112
|
+
finally:
|
|
113
|
+
db.close()
|
|
114
|
+
return eid, self.get(eid)
|
|
115
|
+
|
|
116
|
+
def record_landed(self, effect_id, result, source="ledger"):
|
|
117
|
+
db = self._connect()
|
|
118
|
+
try:
|
|
119
|
+
db.execute(
|
|
120
|
+
"UPDATE effects SET status='LANDED', result_json=?, source=?, updated=? WHERE effect_id=?",
|
|
121
|
+
(json.dumps(result, sort_keys=True), source, time.time(),
|
|
122
|
+
effect_id))
|
|
123
|
+
db.commit()
|
|
124
|
+
finally:
|
|
125
|
+
db.close()
|
|
126
|
+
|
|
127
|
+
def get(self, effect_id):
|
|
128
|
+
db = self._connect()
|
|
129
|
+
try:
|
|
130
|
+
cur = db.execute(
|
|
131
|
+
"SELECT effect_id, run_id, checkpoint_ns, node, effect_key,"
|
|
132
|
+
" kind, status, result_json, source FROM effects WHERE effect_id=?",
|
|
133
|
+
(effect_id,))
|
|
134
|
+
row = cur.fetchone()
|
|
135
|
+
finally:
|
|
136
|
+
db.close()
|
|
137
|
+
if row is None:
|
|
138
|
+
return None
|
|
139
|
+
return {"effect_id": row[0], "run_id": row[1],
|
|
140
|
+
"checkpoint_ns": row[2], "node": row[3],
|
|
141
|
+
"effect_key": row[4], "kind": row[5], "status": row[6],
|
|
142
|
+
"result": json.loads(row[7]) if row[7] else None,
|
|
143
|
+
"source": row[8]}
|
|
144
|
+
|
|
145
|
+
def get_by_identity(self, run_id, checkpoint_ns, node, effect_key):
|
|
146
|
+
return self.get(derive_effect_id(run_id, checkpoint_ns, node,
|
|
147
|
+
effect_key))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Read-only probe framework (packet 04 hardening item 3).
|
|
2
|
+
|
|
3
|
+
A probe can ONLY look up an effect by idempotency key. By
|
|
4
|
+
construction there is no submit/execute/run method on any probe
|
|
5
|
+
class, so a probe can never cause an effect (lab invariant: 50
|
|
6
|
+
probes -> 0 submissions). Effect classes whose only lookup is not
|
|
7
|
+
read-only get NO probe here; the reconciler holds those as
|
|
8
|
+
DO-NOT-RESUME on a silent ledger.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from .target import TargetUnreachable, TargetAmbiguous
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IdempotencyProbe:
|
|
17
|
+
"""Base probe: subclasses implement _fetch(key) -> receipt|None.
|
|
18
|
+
The public surface is lookup() only."""
|
|
19
|
+
def lookup(self, idempotency_key):
|
|
20
|
+
return self._fetch(idempotency_key)
|
|
21
|
+
|
|
22
|
+
def _fetch(self, idempotency_key): # pragma: no cover
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DictStoreProbe(IdempotencyProbe):
|
|
27
|
+
"""Probe over a durable JSON store keyed by idempotency key —
|
|
28
|
+
the generic shape for booking/charge/submission status stores."""
|
|
29
|
+
def __init__(self, store):
|
|
30
|
+
self._store = store # mapping, treated read-only
|
|
31
|
+
|
|
32
|
+
def _fetch(self, idempotency_key):
|
|
33
|
+
rec = self._store.get(idempotency_key)
|
|
34
|
+
return dict(rec) if rec else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HttpReadOnlyProbe(IdempotencyProbe):
|
|
38
|
+
"""Probe that performs an HTTP GET ONLY (urllib with an explicit
|
|
39
|
+
GET; no other method exists in this class). Non-200 -> unreachable,
|
|
40
|
+
409/ambiguous payload -> ambiguous: both fail closed upstream."""
|
|
41
|
+
def __init__(self, base_url, timeout=5.0):
|
|
42
|
+
self.base_url = base_url.rstrip("/")
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
|
|
45
|
+
def _fetch(self, idempotency_key):
|
|
46
|
+
url = self.base_url + "/" + idempotency_key
|
|
47
|
+
req = urllib.request.Request(url, method="GET")
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
50
|
+
data = json.loads(r.read().decode())
|
|
51
|
+
except urllib.error.HTTPError as e:
|
|
52
|
+
if e.code == 404:
|
|
53
|
+
return None
|
|
54
|
+
raise TargetUnreachable("probe HTTP %s" % e.code) from e
|
|
55
|
+
except Exception as e:
|
|
56
|
+
raise TargetUnreachable(str(e)) from e
|
|
57
|
+
if isinstance(data, dict) and data.get("ambiguous"):
|
|
58
|
+
raise TargetAmbiguous("target cannot confirm or deny")
|
|
59
|
+
return data
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""The reconciler: before a node re-executes, decide whether it may.
|
|
2
|
+
|
|
3
|
+
Verdict states:
|
|
4
|
+
SAFE — no declared effect landed; re-execution is safe.
|
|
5
|
+
RESUME-WITH-SKIPS— some effects already landed; inject their recorded
|
|
6
|
+
results instead of re-running them.
|
|
7
|
+
DO-NOT-RESUME — ambiguous effect state, probe failure, discovery
|
|
8
|
+
drift, or corrupt ledger; a human gets the
|
|
9
|
+
evidence packet.
|
|
10
|
+
|
|
11
|
+
Evidence order per effect: (1) the effect ledger; (2) if the ledger
|
|
12
|
+
is silent or holds only INTENT, a probe of the target system keyed by
|
|
13
|
+
the effect's idempotency key (EXCHANGE-031). A probe that finds the
|
|
14
|
+
effect backfills the ledger (source='probe') so the next resume is
|
|
15
|
+
ledger-cheap. Probing never executes anything.
|
|
16
|
+
|
|
17
|
+
Precondition (EXCHANGE-034): if the checkpoint carries a discovery
|
|
18
|
+
pin and the current discovery digest differs, any verdict escalates
|
|
19
|
+
to DO-NOT-RESUME — the recorded effects were produced under different
|
|
20
|
+
instructions than the ones now framing the agent.
|
|
21
|
+
|
|
22
|
+
Verdicts are Ed25519-signed over their canonical JSON; verify()
|
|
23
|
+
rejects any tampering.
|
|
24
|
+
"""
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
29
|
+
Ed25519PrivateKey)
|
|
30
|
+
from cryptography.exceptions import InvalidSignature
|
|
31
|
+
|
|
32
|
+
from .ledger import derive_effect_id, LedgerCorrupt
|
|
33
|
+
from .target import TargetUnreachable, TargetAmbiguous
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _canonical(obj):
|
|
37
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
TIER_PROBE_CAPS = {"solo": 20, "team": 100}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def key_id_for(public_bytes):
|
|
44
|
+
import hashlib
|
|
45
|
+
return "ed25519-" + hashlib.sha256(public_bytes).hexdigest()[:16]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Reconciler:
|
|
49
|
+
def __init__(self, ledger, targets, signing_key=None, key_id=None):
|
|
50
|
+
"""targets: {kind: TargetSystem-like with .lookup(key)}"""
|
|
51
|
+
self.ledger = ledger
|
|
52
|
+
self.targets = targets
|
|
53
|
+
self.key = signing_key or Ed25519PrivateKey.generate()
|
|
54
|
+
self.key_id = key_id or key_id_for(
|
|
55
|
+
self.key.public_key().public_bytes_raw())
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------
|
|
58
|
+
def verdict(self, run_id, checkpoint_ns, node, declared_effects,
|
|
59
|
+
current_discovery_digest=None, tier=None, probe_cap=None):
|
|
60
|
+
"""declared_effects: [{effect_key, kind}] for the node about
|
|
61
|
+
to re-execute."""
|
|
62
|
+
body = {"run_id": run_id, "checkpoint_ns": checkpoint_ns,
|
|
63
|
+
"node": node, "issued": time.time(), "effects": [],
|
|
64
|
+
"state": None, "reasons": []}
|
|
65
|
+
try:
|
|
66
|
+
pin = self.ledger.discovery_pin(run_id, checkpoint_ns)
|
|
67
|
+
except Exception as e: # corrupt ledger: fail closed
|
|
68
|
+
body["state"] = "DO-NOT-RESUME"
|
|
69
|
+
body["reasons"].append("ledger_unreadable: %s" % e)
|
|
70
|
+
return self._sign(body)
|
|
71
|
+
if pin is not None and current_discovery_digest is not None \
|
|
72
|
+
and pin != current_discovery_digest:
|
|
73
|
+
body["state"] = "DO-NOT-RESUME"
|
|
74
|
+
body["reasons"].append("discovery_drift: checkpoint pin "
|
|
75
|
+
"does not match current discovery "
|
|
76
|
+
"digest (EXCHANGE-034)")
|
|
77
|
+
for eff in declared_effects:
|
|
78
|
+
body["effects"].append(
|
|
79
|
+
{"effect_key": eff["effect_key"], "action": "hold"})
|
|
80
|
+
return self._sign(body)
|
|
81
|
+
|
|
82
|
+
# Flat-tier probe caps (packet 03/04): ledger hits are free;
|
|
83
|
+
# only effects needing a probe count against the cap. Effects
|
|
84
|
+
# beyond the cap are NOT probed: they are named as
|
|
85
|
+
# DO-NOT-RESUME evidence (fail-closed, exactly as lab-designed).
|
|
86
|
+
if probe_cap is None and tier is not None:
|
|
87
|
+
probe_cap = TIER_PROBE_CAPS.get(str(tier).lower())
|
|
88
|
+
probes_used = [0]
|
|
89
|
+
|
|
90
|
+
def _one(eff):
|
|
91
|
+
eid = derive_effect_id(run_id, checkpoint_ns, node,
|
|
92
|
+
eff["effect_key"])
|
|
93
|
+
try:
|
|
94
|
+
row = self.ledger.get(eid)
|
|
95
|
+
except Exception:
|
|
96
|
+
row = None
|
|
97
|
+
needs_probe = not (row and row["status"] == "LANDED")
|
|
98
|
+
if needs_probe and probe_cap is not None:
|
|
99
|
+
if probes_used[0] >= probe_cap:
|
|
100
|
+
return {"effect_key": eff["effect_key"],
|
|
101
|
+
"kind": eff["kind"], "effect_id": eid,
|
|
102
|
+
"action": "hold",
|
|
103
|
+
"evidence": "probe_cap_exceeded: unprobed "
|
|
104
|
+
"remainder is DO-NOT-RESUME evidence "
|
|
105
|
+
"(cap=%d)" % probe_cap}
|
|
106
|
+
probes_used[0] += 1
|
|
107
|
+
return self._reconcile_one(run_id, checkpoint_ns, node, eff)
|
|
108
|
+
|
|
109
|
+
any_skip = False
|
|
110
|
+
any_ambiguous = False
|
|
111
|
+
for eff in declared_effects:
|
|
112
|
+
entry = _one(eff)
|
|
113
|
+
body["effects"].append(entry)
|
|
114
|
+
if entry["action"] == "skip":
|
|
115
|
+
any_skip = True
|
|
116
|
+
elif entry["action"] == "hold":
|
|
117
|
+
any_ambiguous = True
|
|
118
|
+
if any_ambiguous:
|
|
119
|
+
body["state"] = "DO-NOT-RESUME"
|
|
120
|
+
body["reasons"].append("ambiguous effect state — see "
|
|
121
|
+
"per-effect evidence")
|
|
122
|
+
elif any_skip:
|
|
123
|
+
body["state"] = "RESUME-WITH-SKIPS"
|
|
124
|
+
else:
|
|
125
|
+
body["state"] = "SAFE"
|
|
126
|
+
return self._sign(body)
|
|
127
|
+
|
|
128
|
+
def _reconcile_one(self, run_id, checkpoint_ns, node, eff):
|
|
129
|
+
key = eff["effect_key"]
|
|
130
|
+
kind = eff["kind"]
|
|
131
|
+
eid = derive_effect_id(run_id, checkpoint_ns, node, key)
|
|
132
|
+
out = {"effect_key": key, "kind": kind, "effect_id": eid}
|
|
133
|
+
try:
|
|
134
|
+
row = self.ledger.get(eid)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
out.update(action="hold", evidence="ledger_unreadable: %s" % e)
|
|
137
|
+
return out
|
|
138
|
+
if row and row["status"] == "LANDED":
|
|
139
|
+
out.update(action="skip", evidence="ledger:landed",
|
|
140
|
+
result=row["result"])
|
|
141
|
+
return out
|
|
142
|
+
# Ledger silent or INTENT-only: probe the target (EXCHANGE-031).
|
|
143
|
+
target = self.targets.get(kind)
|
|
144
|
+
if target is None:
|
|
145
|
+
out.update(action="hold",
|
|
146
|
+
evidence="no probe surface for kind %s" % kind)
|
|
147
|
+
return out
|
|
148
|
+
try:
|
|
149
|
+
receipt = target.lookup(eid)
|
|
150
|
+
except (TargetUnreachable, TargetAmbiguous) as e:
|
|
151
|
+
out.update(action="hold", evidence="probe_failed: %s" % e)
|
|
152
|
+
return out
|
|
153
|
+
if receipt is not None:
|
|
154
|
+
# Ground truth: it landed. Backfill so the next verdict
|
|
155
|
+
# is ledger-cheap, then skip with the probed result.
|
|
156
|
+
if row is None:
|
|
157
|
+
self.ledger.claim_intent(run_id, checkpoint_ns, node,
|
|
158
|
+
key, kind)
|
|
159
|
+
self.ledger.record_landed(eid, receipt, source="probe")
|
|
160
|
+
out.update(action="skip", evidence="probe:landed",
|
|
161
|
+
result=receipt)
|
|
162
|
+
return out
|
|
163
|
+
out.update(action="rerun", evidence="probe:absent")
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------
|
|
167
|
+
def _sign(self, body):
|
|
168
|
+
sig = self.key.sign(_canonical(body))
|
|
169
|
+
return {"verdict": body, "signature": sig.hex(), "key_id": self.key_id,
|
|
170
|
+
"public_key": self.key.public_key().public_bytes_raw().hex()}
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def verify(signed, pinned=None):
|
|
174
|
+
"""Verify a signed verdict. If `pinned` is given ({key_id:
|
|
175
|
+
public_key_hex} — the operator's pinned key set, which is how
|
|
176
|
+
rotation is handled: old key-ids stay verifiable), the
|
|
177
|
+
verdict's key_id must be in the set and its public_key must
|
|
178
|
+
match the pinned value."""
|
|
179
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
180
|
+
Ed25519PublicKey)
|
|
181
|
+
try:
|
|
182
|
+
if pinned is not None:
|
|
183
|
+
kid = signed.get("key_id")
|
|
184
|
+
if kid not in pinned:
|
|
185
|
+
return False
|
|
186
|
+
if pinned[kid] != signed["public_key"]:
|
|
187
|
+
return False
|
|
188
|
+
pub = Ed25519PublicKey.from_public_bytes(
|
|
189
|
+
bytes.fromhex(signed["public_key"]))
|
|
190
|
+
pub.verify(bytes.fromhex(signed["signature"]),
|
|
191
|
+
_canonical(signed["verdict"]))
|
|
192
|
+
return True
|
|
193
|
+
except (InvalidSignature, Exception):
|
|
194
|
+
return False
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Durable test-double target system (paid job submission API shape).
|
|
2
|
+
|
|
3
|
+
Stands in for the external systems agent nodes act on (submit a paid
|
|
4
|
+
job, book, charge). It is a *double*, not a mock of our own code: it
|
|
5
|
+
keeps its own durable store, dedupes submissions by idempotency key
|
|
6
|
+
the way a well-behaved provider API does, and exposes a read-only
|
|
7
|
+
lookup by idempotency key — the probe surface EXCHANGE-031 requires.
|
|
8
|
+
Fault modes (unreachable / ambiguous) are switchable for adversarial
|
|
9
|
+
tests. Nothing here is a production system.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TargetUnreachable(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TargetAmbiguous(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TargetSystem:
|
|
25
|
+
def __init__(self, path):
|
|
26
|
+
self.path = path
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self.mode = "ok" # ok | unreachable | ambiguous
|
|
29
|
+
self.submit_calls = 0
|
|
30
|
+
self.probe_calls = 0
|
|
31
|
+
self._store = {}
|
|
32
|
+
if os.path.exists(path):
|
|
33
|
+
with open(path) as f:
|
|
34
|
+
self._store = json.load(f)
|
|
35
|
+
|
|
36
|
+
def _persist(self):
|
|
37
|
+
tmp = self.path + ".tmp"
|
|
38
|
+
with open(tmp, "w") as f:
|
|
39
|
+
json.dump(self._store, f)
|
|
40
|
+
f.flush()
|
|
41
|
+
os.fsync(f.fileno())
|
|
42
|
+
os.replace(tmp, self.path)
|
|
43
|
+
|
|
44
|
+
def submit(self, idempotency_key, payload):
|
|
45
|
+
"""A side effect. Deduped by idempotency key at the provider,
|
|
46
|
+
but every raw call is counted so tests can detect a guard
|
|
47
|
+
layer that failed to prevent re-execution."""
|
|
48
|
+
with self._lock:
|
|
49
|
+
if self.mode == "unreachable":
|
|
50
|
+
raise TargetUnreachable("target unreachable")
|
|
51
|
+
self.submit_calls += 1
|
|
52
|
+
if idempotency_key in self._store:
|
|
53
|
+
return dict(self._store[idempotency_key])
|
|
54
|
+
receipt = {"receipt_id": "rcpt-" + idempotency_key[:12],
|
|
55
|
+
"idempotency_key": idempotency_key,
|
|
56
|
+
"payload": payload, "status": "submitted"}
|
|
57
|
+
self._store[idempotency_key] = receipt
|
|
58
|
+
self._persist()
|
|
59
|
+
return dict(receipt)
|
|
60
|
+
|
|
61
|
+
def lookup(self, idempotency_key):
|
|
62
|
+
"""The probe: read-only. Returns the receipt, None if the
|
|
63
|
+
effect never landed, raises on unreachable/ambiguous."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
if self.mode == "unreachable":
|
|
66
|
+
raise TargetUnreachable("target unreachable")
|
|
67
|
+
if self.mode == "ambiguous":
|
|
68
|
+
raise TargetAmbiguous("target cannot confirm or deny")
|
|
69
|
+
self.probe_calls += 1
|
|
70
|
+
rec = self._store.get(idempotency_key)
|
|
71
|
+
return dict(rec) if rec else None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: resume-guard
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Resume-effect reconciliation for checkpointed agent runs: checkpoint-keyed effect ledger, read-only probe, Ed25519-signed resume verdicts.
|
|
5
|
+
Author: TextForAgents LLC
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: cryptography>=41
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# resume_guard
|
|
14
|
+
|
|
15
|
+
Resume-effect reconciliation for checkpointed agent runs — by TextForAgents LLC.
|
|
16
|
+
|
|
17
|
+
Before a checkpointed agent run resumes, ask what already landed. An effect
|
|
18
|
+
ledger keyed to checkpoint identity, a read-only probe of the target system
|
|
19
|
+
when the ledger is silent, and an Ed25519-signed verdict — SAFE /
|
|
20
|
+
RESUME-WITH-SKIPS / DO-NOT-RESUME — so a resumed node never re-sends the paid
|
|
21
|
+
call, re-books the booking, or records an approval against work the human
|
|
22
|
+
never saw.
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from resume_guard import EffectLedger, TargetSystem, Reconciler, GuardedEffects
|
|
28
|
+
|
|
29
|
+
ledger = EffectLedger("effects.db")
|
|
30
|
+
target = TargetSystem("target.json")
|
|
31
|
+
rec = Reconciler(ledger, {"paid_job": target})
|
|
32
|
+
guard = GuardedEffects(ledger, rec)
|
|
33
|
+
|
|
34
|
+
receipt, how = guard.run_effect("run-1", "ns-1", "book_and_pay",
|
|
35
|
+
"submit-job", "paid_job",
|
|
36
|
+
lambda eid: target.submit(eid, {"job": "gpu-job-1"}))
|
|
37
|
+
# kill the process here; on resume the same call returns ("...", "injected")
|
|
38
|
+
# and the effect function is never called again.
|
|
39
|
+
verdict = rec.verdict("run-1", "ns-1", "book_and_pay",
|
|
40
|
+
[{"effect_key": "submit-job", "kind": "paid_job"}])
|
|
41
|
+
assert verdict["verdict"]["state"] == "RESUME-WITH-SKIPS"
|
|
42
|
+
assert Reconciler.verify(verdict)
|
|
43
|
+
print("exactly one execution:", target.submit_calls == 1)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Hosted service: Solo $9/mo per operator (first month free, one-time trial) ·
|
|
47
|
+
Team $29/mo · per-verdict API $0.005 (x402). Support: support@textforagents.com
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.py
|
|
5
|
+
resume_guard/__init__.py
|
|
6
|
+
resume_guard/adapters.py
|
|
7
|
+
resume_guard/audit.py
|
|
8
|
+
resume_guard/guard.py
|
|
9
|
+
resume_guard/keys.py
|
|
10
|
+
resume_guard/ledger.py
|
|
11
|
+
resume_guard/probes.py
|
|
12
|
+
resume_guard/reconciler.py
|
|
13
|
+
resume_guard/target.py
|
|
14
|
+
resume_guard.egg-info/PKG-INFO
|
|
15
|
+
resume_guard.egg-info/SOURCES.txt
|
|
16
|
+
resume_guard.egg-info/dependency_links.txt
|
|
17
|
+
resume_guard.egg-info/requires.txt
|
|
18
|
+
resume_guard.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=41
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
resume_guard
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from setuptools import setup; setup()
|